feat: publish prefereces stored in filesystem
This commit is contained in:
@@ -35,6 +35,17 @@ export interface CategoryRenderSettings {
|
||||
showTitle: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishing preferences stored in meta/publishing.json.
|
||||
* Contains only non-secret connection details that can be shared among collaborators.
|
||||
*/
|
||||
export interface PublishingPreferences {
|
||||
sshHost: string;
|
||||
sshUser: string;
|
||||
sshRemotePath: string;
|
||||
sshMode: 'scp' | 'rsync';
|
||||
}
|
||||
|
||||
export interface CategoryMetadata extends CategoryRenderSettings {
|
||||
title: string;
|
||||
}
|
||||
@@ -73,6 +84,15 @@ function sanitizePublicUrl(value: unknown): string | undefined {
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function normalizePublishingPreferences(prefs: PublishingPreferences): PublishingPreferences {
|
||||
return {
|
||||
sshHost: String(prefs.sshHost ?? '').trim(),
|
||||
sshUser: String(prefs.sshUser ?? '').trim(),
|
||||
sshRemotePath: String(prefs.sshRemotePath ?? '').trim(),
|
||||
sshMode: prefs.sshMode === 'rsync' ? 'rsync' : 'scp',
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeCategoryTitle(value: unknown, fallback: string): string {
|
||||
const trimmed = typeof value === 'string' ? value.trim() : '';
|
||||
return trimmed.length > 0 ? trimmed : fallback;
|
||||
@@ -183,6 +203,7 @@ export class MetaEngine extends EventEmitter {
|
||||
private tags: Set<string> = new Set();
|
||||
private categories: Set<string> = new Set();
|
||||
private projectMetadata: ProjectMetadata | null = null;
|
||||
private publishingPreferences: PublishingPreferences | null = null;
|
||||
private initialized: boolean = false;
|
||||
private startupSyncPromise: Promise<void> | null = null;
|
||||
|
||||
@@ -226,6 +247,10 @@ export class MetaEngine extends EventEmitter {
|
||||
return path.join(this.getMetaDir(), 'category-meta.json');
|
||||
}
|
||||
|
||||
private getPublishingPreferencesFilePath(): string {
|
||||
return path.join(this.getMetaDir(), 'publishing.json');
|
||||
}
|
||||
|
||||
setProjectContext(projectId: string, dataDir?: string): void {
|
||||
const nextDataDir = dataDir || null;
|
||||
if (this.currentProjectId === projectId && this.dataDir === nextDataDir) {
|
||||
@@ -238,6 +263,7 @@ export class MetaEngine extends EventEmitter {
|
||||
this.tags.clear();
|
||||
this.categories.clear();
|
||||
this.projectMetadata = null;
|
||||
this.publishingPreferences = null;
|
||||
this.initialized = false;
|
||||
this.startupSyncPromise = null;
|
||||
}
|
||||
@@ -327,6 +353,43 @@ export class MetaEngine extends EventEmitter {
|
||||
this.emit('projectMetadataChanged', this.projectMetadata);
|
||||
}
|
||||
|
||||
// ── Publishing Preferences ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get publishing preferences for the current project.
|
||||
*/
|
||||
async getPublishingPreferences(): Promise<PublishingPreferences | null> {
|
||||
return this.publishingPreferences;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set publishing preferences for the current project.
|
||||
* Persists to meta/publishing.json so they can be shared across collaborators.
|
||||
*/
|
||||
async setPublishingPreferences(prefs: PublishingPreferences): Promise<void> {
|
||||
this.publishingPreferences = normalizePublishingPreferences(prefs);
|
||||
await this.savePublishingPreferences();
|
||||
this.emit('publishingPreferencesChanged', this.publishingPreferences);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear publishing preferences for the current project.
|
||||
* Removes meta/publishing.json.
|
||||
*/
|
||||
async clearPublishingPreferences(): Promise<void> {
|
||||
this.publishingPreferences = null;
|
||||
try {
|
||||
const filePath = this.getPublishingPreferencesFilePath();
|
||||
await fs.unlink(filePath);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
console.error('[MetaEngine] Failed to delete publishing preferences:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
this.emit('publishingPreferencesChanged', null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new tag to the available tags list (in-memory only).
|
||||
* Note: Tag persistence is handled by TagEngine.
|
||||
@@ -455,6 +518,47 @@ export class MetaEngine extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save publishing preferences to the filesystem.
|
||||
*/
|
||||
private async savePublishingPreferences(): Promise<void> {
|
||||
if (!this.publishingPreferences) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.ensureMetaDirExists();
|
||||
const filePath = this.getPublishingPreferencesFilePath();
|
||||
await this.writeJsonFileAtomically(filePath, this.publishingPreferences);
|
||||
} catch (error) {
|
||||
console.error('[MetaEngine] Failed to save publishing preferences:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load publishing preferences from the filesystem.
|
||||
*/
|
||||
private async loadPublishingPreferences(): Promise<void> {
|
||||
try {
|
||||
const filePath = this.getPublishingPreferencesFilePath();
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
const parsed = JSON.parse(content) as PublishingPreferences;
|
||||
this.publishingPreferences = normalizePublishingPreferences(parsed);
|
||||
} catch (error) {
|
||||
if (isJsonParseError(error)) {
|
||||
console.warn('[MetaEngine] Failed to parse publishing preferences JSON, using null:', error);
|
||||
this.publishingPreferences = null;
|
||||
return;
|
||||
}
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
console.error('[MetaEngine] Failed to load publishing preferences:', error);
|
||||
throw error;
|
||||
}
|
||||
// File doesn't exist, that's OK
|
||||
this.publishingPreferences = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load project metadata from the filesystem.
|
||||
*/
|
||||
@@ -772,6 +876,9 @@ export class MetaEngine extends EventEmitter {
|
||||
await this.saveProjectMetadata();
|
||||
await this.saveCategoryMetadata();
|
||||
}
|
||||
|
||||
// Handle publishing preferences (load from file if it exists)
|
||||
await this.loadPublishingPreferences();
|
||||
|
||||
this.initialized = true;
|
||||
console.log(`[MetaEngine] Sync complete. Tags: ${this.tags.size}, Categories: ${this.categories.size}`);
|
||||
|
||||
@@ -170,6 +170,9 @@ const METHOD_NAME_MAP: Record<string, string> = {
|
||||
'meta.getProjectMetadata': 'getProjectMetadata',
|
||||
'meta.setProjectMetadata': 'setProjectMetadata',
|
||||
'meta.updateProjectMetadata': 'updateProjectMetadata',
|
||||
'meta.getPublishingPreferences': 'getPublishingPreferences',
|
||||
'meta.setPublishingPreferences': 'setPublishingPreferences',
|
||||
'meta.clearPublishingPreferences': 'clearPublishingPreferences',
|
||||
'tags.getAll': 'getAllTags',
|
||||
'tags.getWithCounts': 'getTagsWithCounts',
|
||||
'tags.get': 'getTag',
|
||||
|
||||
@@ -1090,6 +1090,24 @@ export function registerIpcHandlers(): void {
|
||||
return engine.getProjectMetadata();
|
||||
});
|
||||
|
||||
safeHandle('meta:getPublishingPreferences', async () => {
|
||||
const engine = getMetaEngine();
|
||||
await ensureMetaReady(engine);
|
||||
return engine.getPublishingPreferences();
|
||||
});
|
||||
|
||||
safeHandle('meta:setPublishingPreferences', async (_, prefs: { sshHost: string; sshUser: string; sshRemotePath: string; sshMode: 'scp' | 'rsync' }) => {
|
||||
const engine = getMetaEngine();
|
||||
await ensureMetaContext(engine);
|
||||
await engine.setPublishingPreferences(prefs);
|
||||
});
|
||||
|
||||
safeHandle('meta:clearPublishingPreferences', async () => {
|
||||
const engine = getMetaEngine();
|
||||
await ensureMetaContext(engine);
|
||||
await engine.clearPublishingPreferences();
|
||||
});
|
||||
|
||||
// ============ Tag Management Handlers ============
|
||||
|
||||
safeHandle('tags:getAll', async () => {
|
||||
|
||||
@@ -177,6 +177,9 @@ export const electronAPI: ElectronAPI = {
|
||||
getProjectMetadata: () => ipcRenderer.invoke('meta:getProjectMetadata'),
|
||||
setProjectMetadata: (metadata: { name: string; description?: string }) => ipcRenderer.invoke('meta:setProjectMetadata', metadata),
|
||||
updateProjectMetadata: (updates: { name?: string; description?: string; dataPath?: string; publicUrl?: string; mainLanguage?: string; defaultAuthor?: string; maxPostsPerPage?: number; blogmarkCategory?: string; pythonRuntimeMode?: 'webworker' | 'main-thread'; picoTheme?: import('./shared/picoThemes').PicoThemeName; categoryMetadata?: Record<string, { renderInLists: boolean; showTitle: boolean; title: string }>; categorySettings?: Record<string, { renderInLists: boolean; showTitle: boolean }> }) => ipcRenderer.invoke('meta:updateProjectMetadata', updates),
|
||||
getPublishingPreferences: () => ipcRenderer.invoke('meta:getPublishingPreferences'),
|
||||
setPublishingPreferences: (prefs: { sshHost: string; sshUser: string; sshRemotePath: string; sshMode: 'scp' | 'rsync' }) => ipcRenderer.invoke('meta:setPublishingPreferences', prefs),
|
||||
clearPublishingPreferences: () => ipcRenderer.invoke('meta:clearPublishingPreferences'),
|
||||
},
|
||||
|
||||
// Tag Management (advanced tag operations)
|
||||
|
||||
@@ -58,6 +58,13 @@ export interface CategoryMetadata extends CategoryRenderSettings {
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface PublishingPreferences {
|
||||
sshHost: string;
|
||||
sshUser: string;
|
||||
sshRemotePath: string;
|
||||
sshMode: 'scp' | 'rsync';
|
||||
}
|
||||
|
||||
export interface ProjectData {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -637,6 +644,9 @@ export interface ElectronAPI {
|
||||
getProjectMetadata: () => Promise<ProjectMetadata | null>;
|
||||
setProjectMetadata: (metadata: { name: string; description?: string }) => Promise<ProjectMetadata | null>;
|
||||
updateProjectMetadata: (updates: { name?: string; description?: string; dataPath?: string; publicUrl?: string; mainLanguage?: string; defaultAuthor?: string; maxPostsPerPage?: number; blogmarkCategory?: string; pythonRuntimeMode?: 'webworker' | 'main-thread'; picoTheme?: import('./picoThemes').PicoThemeName; categoryMetadata?: Record<string, CategoryMetadata>; categorySettings?: Record<string, CategoryRenderSettings> }) => Promise<ProjectMetadata | null>;
|
||||
getPublishingPreferences: () => Promise<PublishingPreferences | null>;
|
||||
setPublishingPreferences: (prefs: PublishingPreferences) => Promise<void>;
|
||||
clearPublishingPreferences: () => Promise<void>;
|
||||
};
|
||||
tags: {
|
||||
getAll: () => Promise<TagData[]>;
|
||||
|
||||
@@ -158,6 +158,9 @@ const METHODS_V1: PythonApiMethodContractV1[] = [
|
||||
method('meta.getProjectMetadata', 'Read active project metadata.', [], 'ProjectMetadata | null'),
|
||||
method('meta.setProjectMetadata', 'Set project metadata.', [requiredObject('metadata')], 'ProjectMetadata | null'),
|
||||
method('meta.updateProjectMetadata', 'Update project metadata.', [requiredObject('updates')], 'ProjectMetadata | null'),
|
||||
method('meta.getPublishingPreferences', 'Get publishing preferences for the active project.', [], 'PublishingPreferences | null'),
|
||||
method('meta.setPublishingPreferences', 'Set publishing preferences for the active project.', [requiredObject('prefs')], 'void'),
|
||||
method('meta.clearPublishingPreferences', 'Clear publishing preferences for the active project.', [], 'void'),
|
||||
|
||||
method('tags.getAll', 'Fetch all tags.', [], 'TagData[]'),
|
||||
method('tags.getWithCounts', 'Fetch tags with counts.', [], 'TagWithCount[]'),
|
||||
@@ -191,6 +194,16 @@ const METHODS_V1: PythonApiMethodContractV1[] = [
|
||||
];
|
||||
|
||||
const DATA_STRUCTURES_V1: PythonApiDataStructureContractV1[] = [
|
||||
{
|
||||
name: 'PublishingPreferences',
|
||||
description: 'Publishing connection preferences stored in meta/publishing.json (shareable, no secrets).',
|
||||
fields: [
|
||||
{ name: 'sshHost', type: 'string', required: true, description: 'SSH hostname for publishing.' },
|
||||
{ name: 'sshUser', type: 'string', required: true, description: 'SSH username for publishing.' },
|
||||
{ name: 'sshRemotePath', type: 'string', required: true, description: 'Remote path on the server.' },
|
||||
{ name: 'sshMode', type: "'scp' | 'rsync'", required: true, description: 'Upload mode (scp or rsync).' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'ProjectData',
|
||||
description: 'Project metadata stored in the app database.',
|
||||
|
||||
Reference in New Issue
Block a user