fix: next round of cleanups

This commit is contained in:
2026-02-27 11:26:00 +01:00
parent c6edacba51
commit 18e0557ef5
25 changed files with 1021 additions and 1415 deletions

View File

@@ -0,0 +1,56 @@
import * as path from 'path';
import * as fsPromises from 'fs/promises';
import { app } from 'electron';
import { getProjectEngine } from './ProjectEngine';
import { getDatabase } from '../database';
/**
* Adapter that wraps app-level IPC handler logic for use by the Python API layer.
* Provides safe, read-only app methods without requiring Electron UI facilities.
*/
export class AppApiAdapter {
async getDataPaths(): Promise<{ database: string; posts: string; media: string }> {
const projectEngine = getProjectEngine();
const activeProject = await projectEngine.getActiveProject();
const projectId = activeProject?.id || 'default';
const paths = projectEngine.getProjectPaths(projectId, activeProject?.dataPath);
return {
database: getDatabase().getDataPaths().database,
posts: paths.posts,
media: paths.media,
};
}
async getSystemLanguage(): Promise<string> {
return app.getLocale();
}
async getDefaultProjectPath(projectId: string): Promise<string> {
return getProjectEngine().getDefaultProjectBaseDir(projectId);
}
async readProjectMetadata(folderPath: string): Promise<{ name?: string; description?: string; publicUrl?: string; mainLanguage?: string } | null> {
const metaPath = path.join(folderPath, 'meta', 'project.json');
try {
const content = await fsPromises.readFile(metaPath, 'utf-8');
const metadata = JSON.parse(content);
return {
name: metadata.name || undefined,
description: metadata.description || undefined,
publicUrl: metadata.publicUrl || undefined,
mainLanguage: metadata.mainLanguage || undefined,
};
} catch {
return null;
}
}
}
let instance: AppApiAdapter | null = null;
export function getAppApiAdapter(): AppApiAdapter {
if (!instance) {
instance = new AppApiAdapter();
}
return instance;
}

View File

@@ -0,0 +1,80 @@
import { getGitEngine } from './GitEngine';
import { getProjectEngine } from './ProjectEngine';
import type {
GitAvailability,
RepoState,
GitStatusDto,
GitHistoryEntry,
GitRemoteStateDto,
GitActionResult,
} from './GitEngine';
export type { GitAvailability, RepoState, GitStatusDto, GitHistoryEntry, GitRemoteStateDto, GitActionResult };
/**
* Adapter that wraps GitEngine for use by the Python API layer.
* Auto-resolves projectPath from the active project so Python scripts
* don't need to pass it.
*/
export class GitApiAdapter {
private async resolveProjectPath(): Promise<string> {
const project = await getProjectEngine().getActiveProject();
if (!project?.dataPath) {
throw new Error('No active project with a data path');
}
return project.dataPath;
}
async checkAvailability(): Promise<GitAvailability> {
return getGitEngine().checkAvailability();
}
async getRepoState(): Promise<RepoState> {
const projectPath = await this.resolveProjectPath();
return getGitEngine().getRepoState(projectPath);
}
async getStatus(): Promise<GitStatusDto> {
const projectPath = await this.resolveProjectPath();
return getGitEngine().getStatus(projectPath);
}
async getHistory(limit?: number): Promise<GitHistoryEntry[]> {
const projectPath = await this.resolveProjectPath();
return getGitEngine().getHistory(projectPath, limit);
}
async getRemoteState(): Promise<GitRemoteStateDto> {
const projectPath = await this.resolveProjectPath();
return getGitEngine().getRemoteState(projectPath);
}
async fetch(): Promise<GitActionResult> {
const projectPath = await this.resolveProjectPath();
return getGitEngine().fetch(projectPath);
}
async pull(): Promise<GitActionResult> {
const projectPath = await this.resolveProjectPath();
return getGitEngine().pull(projectPath);
}
async push(): Promise<GitActionResult> {
const projectPath = await this.resolveProjectPath();
return getGitEngine().push(projectPath);
}
async commitAll(message: string): Promise<GitActionResult> {
const projectPath = await this.resolveProjectPath();
return getGitEngine().commitAll(projectPath, message);
}
}
let instance: GitApiAdapter | null = null;
export function getGitApiAdapter(): GitApiAdapter {
if (!instance) {
instance = new GitApiAdapter();
}
return instance;
}

View File

@@ -0,0 +1,73 @@
import { getProjectEngine } from './ProjectEngine';
import { getPublishEngine, type PublishCredentials } from './PublishEngine';
import { taskManager } from './TaskManager';
export interface PublishSiteResult {
htmlFilesUploaded: number;
thumbnailFilesUploaded: number;
mediaFilesUploaded: number;
filesSkipped: number;
}
/**
* Adapter that wraps PublishEngine for use by the Python API layer.
* Mirrors the orchestration logic from publishHandlers.ts: sets project
* context, launches three parallel upload tasks, and returns aggregate results.
*/
export class PublishApiAdapter {
async uploadSite(credentials: PublishCredentials): Promise<PublishSiteResult> {
const project = await getProjectEngine().getActiveProject();
if (!project) {
throw new Error('No active project');
}
const publishEngine = getPublishEngine();
publishEngine.setProjectContext(project.id, project.dataPath!);
const ts = Date.now();
const groupId = `publish-${ts}`;
const groupName = 'Site Publishing';
const htmlTask = taskManager.runTask({
id: `publish-html-${ts}`,
name: 'Upload HTML',
groupId,
groupName,
execute: (onProgress) => publishEngine.uploadHtml(credentials, onProgress),
});
const thumbsTask = taskManager.runTask({
id: `publish-thumbnails-${ts}`,
name: 'Upload Thumbnails',
groupId,
groupName,
execute: (onProgress) => publishEngine.uploadThumbnails(credentials, onProgress),
});
const mediaTask = taskManager.runTask({
id: `publish-media-${ts}`,
name: 'Upload Media',
groupId,
groupName,
execute: (onProgress) => publishEngine.uploadMedia(credentials, onProgress),
});
const [html, thumbnails, media] = await Promise.all([htmlTask, thumbsTask, mediaTask]);
return {
htmlFilesUploaded: html.filesUploaded,
thumbnailFilesUploaded: thumbnails.filesUploaded,
mediaFilesUploaded: media.filesUploaded,
filesSkipped: html.filesSkipped + thumbnails.filesSkipped + media.filesSkipped,
};
}
}
let instance: PublishApiAdapter | null = null;
export function getPublishApiAdapter(): PublishApiAdapter {
if (!instance) {
instance = new PublishApiAdapter();
}
return instance;
}

View File

@@ -94,6 +94,18 @@ export const ENGINE_MAP: Record<string, EngineGetter> = {
const { taskManager } = require('../engine/TaskManager');
return taskManager;
},
sync: () => {
const { getGitApiAdapter } = require('../engine/GitApiAdapter');
return getGitApiAdapter();
},
publish: () => {
const { getPublishApiAdapter } = require('../engine/PublishApiAdapter');
return getPublishApiAdapter();
},
app: () => {
const { getAppApiAdapter } = require('../engine/AppApiAdapter');
return getAppApiAdapter();
},
};
// Map API method names to engine method names where they differ
@@ -199,10 +211,7 @@ export async function invokeMainProcessPythonApi(method: string, args: Record<st
'media.importDialog', 'media.replaceFileDialog', 'media.getFilePath',
'app.openFolder', 'app.selectFolder', 'app.showItemInFolder',
'app.getTitleBarMetrics', 'app.notifyRendererReady', 'app.triggerMenuAction',
'app.getBlogmarkBookmarklet', 'app.copyToClipboard',
'chat.sendMessage', 'chat.abortMessage', 'chat.analyzeTaxonomy',
'chat.analyzeMediaImage',
'sync.configure', 'sync.start', 'sync.stopAutoSync',
'app.getBlogmarkBookmarklet', 'app.copyToClipboard', 'app.setPreviewPostTarget',
]);
if (unsafeMethods.has(method)) {

View File

@@ -11,6 +11,7 @@ import { getTagEngine } from '../engine/TagEngine';
import { getPostMediaEngine } from '../engine/PostMediaEngine';
import { getScriptEngine, type CreateScriptInput, type UpdateScriptInput } from '../engine/ScriptEngine';
import { getGitEngine } from '../engine/GitEngine';
import { getGitApiAdapter } from '../engine/GitApiAdapter';
import { taskManager, TaskProgress } from '../engine/TaskManager';
import { getDatabase } from '../database';
import { media } from '../database/schema';
@@ -811,6 +812,44 @@ export function registerIpcHandlers(): void {
return taskManager.clearCompletedTasks();
});
// ============ Sync Handlers (git operations via GitApiAdapter) ============
safeHandle('sync:checkAvailability', async () => {
return getGitApiAdapter().checkAvailability();
});
safeHandle('sync:getRepoState', async () => {
return getGitApiAdapter().getRepoState();
});
safeHandle('sync:getStatus', async () => {
return getGitApiAdapter().getStatus();
});
safeHandle('sync:getHistory', async (_, limit?: number) => {
return getGitApiAdapter().getHistory(limit);
});
safeHandle('sync:getRemoteState', async () => {
return getGitApiAdapter().getRemoteState();
});
safeHandle('sync:fetch', async () => {
return getGitApiAdapter().fetch();
});
safeHandle('sync:pull', async () => {
return getGitApiAdapter().pull();
});
safeHandle('sync:push', async () => {
return getGitApiAdapter().push();
});
safeHandle('sync:commitAll', async (_, message: string) => {
return getGitApiAdapter().commitAll(message);
});
// ============ App Handlers ============
safeHandle('app:getDataPaths', async () => {

View File

@@ -127,15 +127,17 @@ export const electronAPI: ElectronAPI = {
rebuild: () => ipcRenderer.invoke('postMedia:rebuild'),
},
// Sync
// Sync (git operations via GitApiAdapter)
sync: {
configure: (config: unknown) => ipcRenderer.invoke('sync:configure', config),
start: (direction?: 'push' | 'pull' | 'bidirectional') => ipcRenderer.invoke('sync:start', direction),
checkAvailability: () => ipcRenderer.invoke('sync:checkAvailability'),
getRepoState: () => ipcRenderer.invoke('sync:getRepoState'),
getStatus: () => ipcRenderer.invoke('sync:getStatus'),
isConfigured: () => ipcRenderer.invoke('sync:isConfigured'),
getPendingCount: () => ipcRenderer.invoke('sync:getPendingCount'),
getLog: (limit?: number) => ipcRenderer.invoke('sync:getLog', limit),
stopAutoSync: () => ipcRenderer.invoke('sync:stopAutoSync'),
getHistory: (limit?: number) => ipcRenderer.invoke('sync:getHistory', limit),
getRemoteState: () => ipcRenderer.invoke('sync:getRemoteState'),
fetch: () => ipcRenderer.invoke('sync:fetch'),
pull: () => ipcRenderer.invoke('sync:pull'),
push: () => ipcRenderer.invoke('sync:push'),
commitAll: (message: string) => ipcRenderer.invoke('sync:commitAll', message),
},
// Tasks

View File

@@ -164,18 +164,6 @@ export interface TaskProgress {
groupName?: string;
}
export interface SyncConfig {
autoSync: boolean;
syncInterval: number;
}
export interface SyncResult {
success: boolean;
pushed: number;
pulled: number;
conflicts: number;
errors: string[];
}
export interface PaginatedPostsResult {
items: PostData[];
@@ -607,13 +595,15 @@ export interface ElectronAPI {
rebuild: () => Promise<void>;
};
sync: {
configure: (config: SyncConfig) => Promise<void>;
start: (direction?: 'push' | 'pull' | 'bidirectional') => Promise<SyncResult>;
getStatus: () => Promise<'idle' | 'syncing' | 'error'>;
isConfigured: () => Promise<boolean>;
getPendingCount: () => Promise<{ posts: number; media: number }>;
getLog: (limit?: number) => Promise<unknown[]>;
stopAutoSync: () => Promise<void>;
checkAvailability: () => Promise<{ gitFound: boolean; version?: string }>;
getRepoState: () => Promise<{ isRepo: boolean; rootPath?: string; currentBranch?: string; hasRemote: boolean }>;
getStatus: () => Promise<{ files: Array<{ path: string; status: string; previousPath?: string }>; counts: { untracked: number; modified: number; deleted: number; renamed: number; staged: number } }>;
getHistory: (limit?: number) => Promise<Array<{ hash: string; shortHash: string; date: string; subject: string; author: string }>>;
getRemoteState: () => Promise<{ localBranch: string | null; upstreamBranch: string | null; hasUpstream: boolean; ahead: number; behind: number }>;
fetch: () => Promise<{ success: boolean; code?: string; error?: string; guidance?: string[] }>;
pull: () => Promise<{ success: boolean; code?: string; error?: string; guidance?: string[] }>;
push: () => Promise<{ success: boolean; code?: string; error?: string; guidance?: string[] }>;
commitAll: (message: string) => Promise<{ success: boolean; code?: string; error?: string; guidance?: string[] }>;
};
tasks: {
getAll: () => Promise<TaskProgress[]>;

View File

@@ -51,7 +51,6 @@ const optionalNumber = (name: string): PythonApiParamContractV1 => ({ name, type
const requiredObject = (name: string): PythonApiParamContractV1 => ({ name, type: 'object', required: true });
const optionalObject = (name: string): PythonApiParamContractV1 => ({ name, type: 'object', required: false });
const requiredArray = (name: string): PythonApiParamContractV1 => ({ name, type: 'array', required: true });
const requiredAny = (name: string): PythonApiParamContractV1 => ({ name, type: 'any', required: true });
const requiredStringOrNull = (name: string): PythonApiParamContractV1 => ({ name, type: 'stringOrNull', required: true });
function method(
@@ -172,34 +171,23 @@ const METHODS_V1: PythonApiMethodContractV1[] = [
method('tags.getPostsWithTag', 'Get posts using a tag.', [requiredString('tagId')], 'string[]'),
method('tags.syncFromPosts', 'Sync tag index from posts.', [], 'SyncTagsResult'),
method('chat.checkReady', 'Check chat backend readiness.', [], 'ChatReadyStatus'),
method('chat.validateApiKey', 'Validate chat API key and list available models.', [requiredString('apiKey')], '{ isValid: boolean; models: ChatModel[] }'),
method('chat.setApiKey', 'Store chat API key.', [requiredString('apiKey')], '{ success: boolean; error?: string }'),
method('chat.getApiKey', 'Get stored chat API key status.', [], 'ChatApiKeyStatus'),
method('chat.getAvailableModels', 'Get available chat models and selected default.', [], '{ success: boolean; models?: ChatModel[]; selectedModel?: string; error?: string }'),
method('chat.setDefaultModel', 'Set default chat model.', [requiredString('modelId')], '{ success: boolean; error?: string }'),
method('chat.getSystemPrompt', 'Get configured system prompt.', [], '{ success: boolean; prompt?: string; error?: string }'),
method('chat.setSystemPrompt', 'Set system prompt.', [requiredString('prompt')], '{ success: boolean; error?: string }'),
method('chat.getConversations', 'Fetch all chat conversations.', [], 'ChatConversation[]'),
method('chat.createConversation', 'Create a chat conversation.', [optionalString('title'), optionalString('model')], 'ChatConversation'),
method('chat.getConversation', 'Fetch one chat conversation by id.', [requiredString('id')], 'ChatConversation | null'),
method('chat.updateConversation', 'Update chat conversation metadata.', [requiredString('id'), requiredObject('updates')], 'ChatConversation | null'),
method('chat.deleteConversation', 'Delete chat conversation by id.', [requiredString('id')], 'boolean'),
method('chat.sendMessage', 'Send message to chat conversation.', [requiredString('conversationId'), requiredString('message'), optionalObject('metadata')], '{ success: boolean; message?: string; error?: string }'),
method('chat.abortMessage', 'Abort active streaming chat response.', [requiredString('conversationId')], 'void'),
method('chat.getHistory', 'Get message history for conversation.', [requiredString('conversationId')], 'ChatMessage[]'),
method('chat.clearMessages', 'Clear messages for conversation.', [requiredString('conversationId')], 'void'),
method('chat.setConversationModel', 'Set model for a conversation.', [requiredString('conversationId'), requiredString('modelId')], 'void'),
method('chat.analyzeTaxonomy', 'Analyze categories and tags using AI.', [requiredArray('categories'), requiredArray('tags'), requiredString('modelId')], '{ success: boolean; categoryMappings?: Record<string, string>; tagMappings?: Record<string, string>; error?: string }'),
method('chat.analyzeMediaImage', 'Analyze media image and propose metadata.', [requiredString('mediaId'), optionalString('language')], '{ success: boolean; title?: string; alt?: string; caption?: string; error?: string }'),
// NOTE: chat namespace intentionally excluded from Python API.
// AI/chat features (sendMessage, analyzeTaxonomy, analyzeMediaImage, etc.) are
// expensive external API calls that require user oversight and interactive streaming.
// This namespace can be re-added in a future version if AI-from-Python becomes a
// supported use case with proper rate limiting and cost controls.
method('sync.configure', 'Configure sync.', [requiredObject('config')], 'void'),
method('sync.start', 'Start sync operation.', [optionalString('direction')], 'SyncResult'),
method('sync.getStatus', 'Get sync status.', [], "'idle' | 'syncing' | 'error'"),
method('sync.isConfigured', 'Check if sync is configured.', [], 'boolean'),
method('sync.getPendingCount', 'Get pending sync item count.', [], '{ posts: number; media: number }'),
method('sync.getLog', 'Get sync log.', [optionalNumber('limit')], 'unknown[]'),
method('sync.stopAutoSync', 'Stop automatic sync.', [], 'void'),
method('sync.checkAvailability', 'Check if git is available.', [], 'GitAvailability'),
method('sync.getRepoState', 'Get repository state for active project.', [], 'RepoState'),
method('sync.getStatus', 'Get working tree status for active project.', [], 'GitStatusDto'),
method('sync.getHistory', 'Get commit history for active project.', [optionalNumber('limit')], 'GitHistoryEntry[]'),
method('sync.getRemoteState', 'Get remote tracking state for active project.', [], 'GitRemoteStateDto'),
method('sync.fetch', 'Fetch from remote for active project.', [], 'GitActionResult'),
method('sync.pull', 'Pull from remote for active project.', [], 'GitActionResult'),
method('sync.push', 'Push to remote for active project.', [], 'GitActionResult'),
method('sync.commitAll', 'Stage all changes and commit for active project.', [requiredString('message')], 'GitActionResult'),
method('publish.uploadSite', 'Upload rendered site to remote server via SSH.', [requiredObject('credentials')], 'PublishSiteResult'),
];
const DATA_STRUCTURES_V1: PythonApiDataStructureContractV1[] = [
@@ -310,60 +298,67 @@ const DATA_STRUCTURES_V1: PythonApiDataStructureContractV1[] = [
],
},
{
name: 'ChatConversation',
description: 'Chat conversation container.',
name: 'GitAvailability',
description: 'Git installation availability check result.',
fields: [
{ name: 'id', type: 'string', required: true, description: 'Unique conversation identifier.' },
{ name: 'title', type: 'string', required: true, description: 'Conversation title.' },
{ name: 'model', type: 'string', required: false, description: 'Optional model id used by this conversation.' },
{ name: 'createdAt', type: 'string', required: true, description: 'Creation timestamp (ISO string).' },
{ name: 'updatedAt', type: 'string', required: true, description: 'Last update timestamp (ISO string).' },
{ name: 'gitFound', type: 'boolean', required: true, description: 'Whether git executable was found.' },
{ name: 'version', type: 'string', required: false, description: 'Git version string when available.' },
],
},
{
name: 'ChatMessage',
description: 'Single message entry in a conversation history.',
name: 'RepoState',
description: 'Repository state for the active project.',
fields: [
{ name: 'id', type: 'string', required: true, description: 'Unique message identifier.' },
{ name: 'conversationId', type: 'string', required: true, description: 'Owning conversation id.' },
{ name: 'role', type: "'user' | 'assistant' | 'system' | 'tool'", required: true, description: 'Message author role.' },
{ name: 'content', type: 'string', required: true, description: 'Message text content.' },
{ name: 'toolCallId', type: 'string', required: false, description: 'Tool call id when associated with tool output.' },
{ name: 'toolCalls', type: 'string', required: false, description: 'Serialized tool call payload when present.' },
{ name: 'createdAt', type: 'string', required: true, description: 'Creation timestamp (ISO string).' },
{ name: 'isRepo', type: 'boolean', required: true, description: 'Whether the project directory is a git repository.' },
{ name: 'rootPath', type: 'string', required: false, description: 'Repository root path.' },
{ name: 'currentBranch', type: 'string', required: false, description: 'Current branch name.' },
{ name: 'hasRemote', type: 'boolean', required: true, description: 'Whether a remote is configured.' },
],
},
{
name: 'ChatModel',
description: 'Available chat model descriptor.',
name: 'GitStatusDto',
description: 'Working tree status with file list and counts.',
fields: [
{ name: 'id', type: 'string', required: true, description: 'Model identifier.' },
{ name: 'name', type: 'string', required: true, description: 'Human-readable model name.' },
{ name: 'provider', type: 'string', required: false, description: 'Model provider name.' },
{ name: 'files', type: 'Array<{ path: string; status: string; previousPath?: string }>', required: true, description: 'List of changed files with status.' },
{ name: 'counts', type: '{ untracked: number; modified: number; deleted: number; renamed: number; staged: number }', required: true, description: 'Counts by change type.' },
],
},
{
name: 'ChatReadyStatus',
description: 'Chat backend readiness status.',
name: 'GitRemoteStateDto',
description: 'Remote tracking state for the active project branch.',
fields: [
{ name: 'ready', type: 'boolean', required: true, description: 'Whether chat backend is ready.' },
{ name: 'error', type: 'string', required: false, description: 'Error description when not ready.' },
{ name: 'backend', type: 'string', required: false, description: 'Selected backend identifier.' },
{ name: 'localBranch', type: 'string | null', required: true, description: 'Local branch name.' },
{ name: 'upstreamBranch', type: 'string | null', required: true, description: 'Upstream tracking branch name.' },
{ name: 'hasUpstream', type: 'boolean', required: true, description: 'Whether an upstream is configured.' },
{ name: 'ahead', type: 'number', required: true, description: 'Commits ahead of upstream.' },
{ name: 'behind', type: 'number', required: true, description: 'Commits behind upstream.' },
],
},
{
name: 'ChatApiKeyStatus',
description: 'Stored API key state for chat provider.',
name: 'GitActionResult',
description: 'Result from a git operation (fetch, pull, push, commit).',
fields: [
{ name: 'hasKey', type: 'boolean', required: true, description: 'Whether a key is configured.' },
{ name: 'maskedKey', type: 'string', required: true, description: 'Masked key representation for UI display.' },
{ name: 'success', type: 'boolean', required: true, description: 'Whether the operation succeeded.' },
{ name: 'code', type: 'string', required: false, description: "Error code when failed ('auth-required', 'conflict', 'network', 'action-failed')." },
{ name: 'error', type: 'string', required: false, description: 'Error message when failed.' },
{ name: 'guidance', type: 'string[]', required: false, description: 'Guidance messages for resolving failures.' },
],
},
{
name: 'PublishSiteResult',
description: 'Aggregate result from uploading the rendered site.',
fields: [
{ name: 'htmlFilesUploaded', type: 'number', required: true, description: 'Number of HTML files uploaded.' },
{ name: 'thumbnailFilesUploaded', type: 'number', required: true, description: 'Number of thumbnail files uploaded.' },
{ name: 'mediaFilesUploaded', type: 'number', required: true, description: 'Number of media files uploaded.' },
{ name: 'filesSkipped', type: 'number', required: true, description: 'Total files skipped (already up-to-date).' },
],
},
];
export const BDS_PYTHON_API_CONTRACT_V1: PythonApiContractV1 = {
version: '1.6.0',
generatedAt: '2026-02-25T00:00:00.000Z',
version: '1.7.0',
generatedAt: '2026-02-27T00:00:00.000Z',
methods: METHODS_V1,
dataStructures: DATA_STRUCTURES_V1,
};