feat: publish prefereces stored in filesystem
This commit is contained in:
99
API.md
99
API.md
@@ -2450,6 +2450,9 @@ None
|
|||||||
- [meta.getProjectMetadata](#metagetprojectmetadata)
|
- [meta.getProjectMetadata](#metagetprojectmetadata)
|
||||||
- [meta.setProjectMetadata](#metasetprojectmetadata)
|
- [meta.setProjectMetadata](#metasetprojectmetadata)
|
||||||
- [meta.updateProjectMetadata](#metaupdateprojectmetadata)
|
- [meta.updateProjectMetadata](#metaupdateprojectmetadata)
|
||||||
|
- [meta.getPublishingPreferences](#metagetpublishingpreferences)
|
||||||
|
- [meta.setPublishingPreferences](#metasetpublishingpreferences)
|
||||||
|
- [meta.clearPublishingPreferences](#metaclearpublishingpreferences)
|
||||||
|
|
||||||
### meta.getTags
|
### meta.getTags
|
||||||
|
|
||||||
@@ -2766,6 +2769,89 @@ None # or
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### meta.getPublishingPreferences
|
||||||
|
|
||||||
|
Get publishing preferences for the active project.
|
||||||
|
|
||||||
|
**Parameters**
|
||||||
|
|
||||||
|
- None
|
||||||
|
|
||||||
|
**Response specification**
|
||||||
|
|
||||||
|
- Return type: `PublishingPreferences | null`
|
||||||
|
- Nullability: Returns `None` when no matching value exists.
|
||||||
|
- Data structures: `PublishingPreferences`
|
||||||
|
|
||||||
|
**Example call**
|
||||||
|
|
||||||
|
```python
|
||||||
|
from bds_api import bds
|
||||||
|
result = await bds.meta.get_publishing_preferences()
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example response**
|
||||||
|
|
||||||
|
```python
|
||||||
|
None # or
|
||||||
|
{
|
||||||
|
'sshHost': 'value',
|
||||||
|
'sshUser': 'value',
|
||||||
|
'sshRemotePath': 'value',
|
||||||
|
'sshMode': 'scp'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### meta.setPublishingPreferences
|
||||||
|
|
||||||
|
Set publishing preferences for the active project.
|
||||||
|
|
||||||
|
**Parameters**
|
||||||
|
|
||||||
|
- prefs (dict, required)
|
||||||
|
|
||||||
|
**Response specification**
|
||||||
|
|
||||||
|
- Return type: `void`
|
||||||
|
|
||||||
|
**Example call**
|
||||||
|
|
||||||
|
```python
|
||||||
|
from bds_api import bds
|
||||||
|
result = await bds.meta.set_publishing_preferences(prefs={})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example response**
|
||||||
|
|
||||||
|
```python
|
||||||
|
None
|
||||||
|
```
|
||||||
|
|
||||||
|
### meta.clearPublishingPreferences
|
||||||
|
|
||||||
|
Clear publishing preferences for the active project.
|
||||||
|
|
||||||
|
**Parameters**
|
||||||
|
|
||||||
|
- None
|
||||||
|
|
||||||
|
**Response specification**
|
||||||
|
|
||||||
|
- Return type: `void`
|
||||||
|
|
||||||
|
**Example call**
|
||||||
|
|
||||||
|
```python
|
||||||
|
from bds_api import bds
|
||||||
|
result = await bds.meta.clear_publishing_preferences()
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example response**
|
||||||
|
|
||||||
|
```python
|
||||||
|
None
|
||||||
|
```
|
||||||
|
|
||||||
[↑ Back to Table of contents](#table-of-contents)
|
[↑ Back to Table of contents](#table-of-contents)
|
||||||
|
|
||||||
## tags
|
## tags
|
||||||
@@ -3396,6 +3482,19 @@ result = await bds.publish.upload_site(credentials={})
|
|||||||
|
|
||||||
Shared structures referenced by response types are defined once here.
|
Shared structures referenced by response types are defined once here.
|
||||||
|
|
||||||
|
### PublishingPreferences
|
||||||
|
|
||||||
|
Publishing connection preferences stored in meta/publishing.json (shareable, no secrets).
|
||||||
|
|
||||||
|
**Fields**
|
||||||
|
|
||||||
|
- sshHost (`string`, required): SSH hostname for publishing.
|
||||||
|
- sshUser (`string`, required): SSH username for publishing.
|
||||||
|
- sshRemotePath (`string`, required): Remote path on the server.
|
||||||
|
- sshMode (`'scp' | 'rsync'`, required): Upload mode (scp or rsync).
|
||||||
|
|
||||||
|
[↑ Back to Table of contents](#table-of-contents)
|
||||||
|
|
||||||
### ProjectData
|
### ProjectData
|
||||||
|
|
||||||
Project metadata stored in the app database.
|
Project metadata stored in the app database.
|
||||||
|
|||||||
@@ -35,6 +35,17 @@ export interface CategoryRenderSettings {
|
|||||||
showTitle: boolean;
|
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 {
|
export interface CategoryMetadata extends CategoryRenderSettings {
|
||||||
title: string;
|
title: string;
|
||||||
}
|
}
|
||||||
@@ -73,6 +84,15 @@ function sanitizePublicUrl(value: unknown): string | undefined {
|
|||||||
return trimmed.length > 0 ? trimmed : 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 {
|
function sanitizeCategoryTitle(value: unknown, fallback: string): string {
|
||||||
const trimmed = typeof value === 'string' ? value.trim() : '';
|
const trimmed = typeof value === 'string' ? value.trim() : '';
|
||||||
return trimmed.length > 0 ? trimmed : fallback;
|
return trimmed.length > 0 ? trimmed : fallback;
|
||||||
@@ -183,6 +203,7 @@ export class MetaEngine extends EventEmitter {
|
|||||||
private tags: Set<string> = new Set();
|
private tags: Set<string> = new Set();
|
||||||
private categories: Set<string> = new Set();
|
private categories: Set<string> = new Set();
|
||||||
private projectMetadata: ProjectMetadata | null = null;
|
private projectMetadata: ProjectMetadata | null = null;
|
||||||
|
private publishingPreferences: PublishingPreferences | null = null;
|
||||||
private initialized: boolean = false;
|
private initialized: boolean = false;
|
||||||
private startupSyncPromise: Promise<void> | null = null;
|
private startupSyncPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
@@ -226,6 +247,10 @@ export class MetaEngine extends EventEmitter {
|
|||||||
return path.join(this.getMetaDir(), 'category-meta.json');
|
return path.join(this.getMetaDir(), 'category-meta.json');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getPublishingPreferencesFilePath(): string {
|
||||||
|
return path.join(this.getMetaDir(), 'publishing.json');
|
||||||
|
}
|
||||||
|
|
||||||
setProjectContext(projectId: string, dataDir?: string): void {
|
setProjectContext(projectId: string, dataDir?: string): void {
|
||||||
const nextDataDir = dataDir || null;
|
const nextDataDir = dataDir || null;
|
||||||
if (this.currentProjectId === projectId && this.dataDir === nextDataDir) {
|
if (this.currentProjectId === projectId && this.dataDir === nextDataDir) {
|
||||||
@@ -238,6 +263,7 @@ export class MetaEngine extends EventEmitter {
|
|||||||
this.tags.clear();
|
this.tags.clear();
|
||||||
this.categories.clear();
|
this.categories.clear();
|
||||||
this.projectMetadata = null;
|
this.projectMetadata = null;
|
||||||
|
this.publishingPreferences = null;
|
||||||
this.initialized = false;
|
this.initialized = false;
|
||||||
this.startupSyncPromise = null;
|
this.startupSyncPromise = null;
|
||||||
}
|
}
|
||||||
@@ -327,6 +353,43 @@ export class MetaEngine extends EventEmitter {
|
|||||||
this.emit('projectMetadataChanged', this.projectMetadata);
|
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).
|
* Add a new tag to the available tags list (in-memory only).
|
||||||
* Note: Tag persistence is handled by TagEngine.
|
* 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.
|
* Load project metadata from the filesystem.
|
||||||
*/
|
*/
|
||||||
@@ -772,6 +876,9 @@ export class MetaEngine extends EventEmitter {
|
|||||||
await this.saveProjectMetadata();
|
await this.saveProjectMetadata();
|
||||||
await this.saveCategoryMetadata();
|
await this.saveCategoryMetadata();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle publishing preferences (load from file if it exists)
|
||||||
|
await this.loadPublishingPreferences();
|
||||||
|
|
||||||
this.initialized = true;
|
this.initialized = true;
|
||||||
console.log(`[MetaEngine] Sync complete. Tags: ${this.tags.size}, Categories: ${this.categories.size}`);
|
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.getProjectMetadata': 'getProjectMetadata',
|
||||||
'meta.setProjectMetadata': 'setProjectMetadata',
|
'meta.setProjectMetadata': 'setProjectMetadata',
|
||||||
'meta.updateProjectMetadata': 'updateProjectMetadata',
|
'meta.updateProjectMetadata': 'updateProjectMetadata',
|
||||||
|
'meta.getPublishingPreferences': 'getPublishingPreferences',
|
||||||
|
'meta.setPublishingPreferences': 'setPublishingPreferences',
|
||||||
|
'meta.clearPublishingPreferences': 'clearPublishingPreferences',
|
||||||
'tags.getAll': 'getAllTags',
|
'tags.getAll': 'getAllTags',
|
||||||
'tags.getWithCounts': 'getTagsWithCounts',
|
'tags.getWithCounts': 'getTagsWithCounts',
|
||||||
'tags.get': 'getTag',
|
'tags.get': 'getTag',
|
||||||
|
|||||||
@@ -1090,6 +1090,24 @@ export function registerIpcHandlers(): void {
|
|||||||
return engine.getProjectMetadata();
|
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 ============
|
// ============ Tag Management Handlers ============
|
||||||
|
|
||||||
safeHandle('tags:getAll', async () => {
|
safeHandle('tags:getAll', async () => {
|
||||||
|
|||||||
@@ -177,6 +177,9 @@ export const electronAPI: ElectronAPI = {
|
|||||||
getProjectMetadata: () => ipcRenderer.invoke('meta:getProjectMetadata'),
|
getProjectMetadata: () => ipcRenderer.invoke('meta:getProjectMetadata'),
|
||||||
setProjectMetadata: (metadata: { name: string; description?: string }) => ipcRenderer.invoke('meta:setProjectMetadata', metadata),
|
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),
|
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)
|
// Tag Management (advanced tag operations)
|
||||||
|
|||||||
@@ -58,6 +58,13 @@ export interface CategoryMetadata extends CategoryRenderSettings {
|
|||||||
title: string;
|
title: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PublishingPreferences {
|
||||||
|
sshHost: string;
|
||||||
|
sshUser: string;
|
||||||
|
sshRemotePath: string;
|
||||||
|
sshMode: 'scp' | 'rsync';
|
||||||
|
}
|
||||||
|
|
||||||
export interface ProjectData {
|
export interface ProjectData {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -637,6 +644,9 @@ export interface ElectronAPI {
|
|||||||
getProjectMetadata: () => Promise<ProjectMetadata | null>;
|
getProjectMetadata: () => Promise<ProjectMetadata | null>;
|
||||||
setProjectMetadata: (metadata: { name: string; description?: string }) => 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>;
|
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: {
|
tags: {
|
||||||
getAll: () => Promise<TagData[]>;
|
getAll: () => Promise<TagData[]>;
|
||||||
|
|||||||
@@ -158,6 +158,9 @@ const METHODS_V1: PythonApiMethodContractV1[] = [
|
|||||||
method('meta.getProjectMetadata', 'Read active project metadata.', [], 'ProjectMetadata | null'),
|
method('meta.getProjectMetadata', 'Read active project metadata.', [], 'ProjectMetadata | null'),
|
||||||
method('meta.setProjectMetadata', 'Set project metadata.', [requiredObject('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.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.getAll', 'Fetch all tags.', [], 'TagData[]'),
|
||||||
method('tags.getWithCounts', 'Fetch tags with counts.', [], 'TagWithCount[]'),
|
method('tags.getWithCounts', 'Fetch tags with counts.', [], 'TagWithCount[]'),
|
||||||
@@ -191,6 +194,16 @@ const METHODS_V1: PythonApiMethodContractV1[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const DATA_STRUCTURES_V1: PythonApiDataStructureContractV1[] = [
|
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',
|
name: 'ProjectData',
|
||||||
description: 'Project metadata stored in the app database.',
|
description: 'Project metadata stored in the app database.',
|
||||||
|
|||||||
@@ -507,17 +507,16 @@ const App: React.FC = () => {
|
|||||||
unsubscribers.push(
|
unsubscribers.push(
|
||||||
window.electronAPI?.on('menu:uploadSite', async () => {
|
window.electronAPI?.on('menu:uploadSite', async () => {
|
||||||
try {
|
try {
|
||||||
const stored = localStorage.getItem('bds-credentials');
|
const prefs = await window.electronAPI?.meta.getPublishingPreferences();
|
||||||
if (!stored) {
|
if (!prefs) {
|
||||||
showToast.error(tr('app.uploadSiteNoCredentials'));
|
showToast.error(tr('app.uploadSiteNoCredentials'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const credentials = JSON.parse(stored);
|
if (!prefs.sshHost || !prefs.sshUser || !prefs.sshRemotePath) {
|
||||||
if (!credentials.sshHost || !credentials.sshUser || !credentials.sshRemotePath) {
|
|
||||||
showToast.error(tr('app.uploadSiteNoCredentials'));
|
showToast.error(tr('app.uploadSiteNoCredentials'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await window.electronAPI?.publish.uploadSite(credentials);
|
await window.electronAPI?.publish.uploadSite(prefs);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Site upload failed:', error);
|
console.error('Site upload failed:', error);
|
||||||
showToast.error(tr('app.uploadSiteFailed'));
|
showToast.error(tr('app.uploadSiteFailed'));
|
||||||
|
|||||||
@@ -234,9 +234,27 @@ export const SettingsView: React.FC = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadSettings = async () => {
|
const loadSettings = async () => {
|
||||||
try {
|
try {
|
||||||
const savedCreds = localStorage.getItem('bds-credentials');
|
// Load publishing preferences from project meta (shareable)
|
||||||
if (savedCreds) {
|
const publishingPrefs = await window.electronAPI?.meta.getPublishingPreferences();
|
||||||
setCredentials({ ...defaultCredentials, ...JSON.parse(savedCreds) });
|
if (publishingPrefs) {
|
||||||
|
setCredentials({
|
||||||
|
sshHost: publishingPrefs.sshHost || '',
|
||||||
|
sshUser: publishingPrefs.sshUser || '',
|
||||||
|
sshRemotePath: publishingPrefs.sshRemotePath || '',
|
||||||
|
sshMode: publishingPrefs.sshMode || 'scp',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Migrate from localStorage if meta file doesn't exist yet
|
||||||
|
const savedCreds = localStorage.getItem('bds-credentials');
|
||||||
|
if (savedCreds) {
|
||||||
|
const parsed = { ...defaultCredentials, ...JSON.parse(savedCreds) };
|
||||||
|
setCredentials(parsed);
|
||||||
|
// Migrate to meta file and remove from localStorage
|
||||||
|
if (parsed.sshHost || parsed.sshRemotePath) {
|
||||||
|
await window.electronAPI?.meta.setPublishingPreferences(parsed);
|
||||||
|
localStorage.removeItem('bds-credentials');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load categories from backend (project-scoped)
|
// Load categories from backend (project-scoped)
|
||||||
@@ -290,7 +308,7 @@ export const SettingsView: React.FC = () => {
|
|||||||
|
|
||||||
const handleSavePublishing = async () => {
|
const handleSavePublishing = async () => {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem('bds-credentials', JSON.stringify(credentials));
|
await window.electronAPI?.meta.setPublishingPreferences(credentials);
|
||||||
showToast.success(t('settings.toast.publishingSaved'));
|
showToast.success(t('settings.toast.publishingSaved'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to save publishing credentials:', error);
|
console.error('Failed to save publishing credentials:', error);
|
||||||
@@ -298,10 +316,10 @@ export const SettingsView: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClearCredentials = () => {
|
const handleClearCredentials = async () => {
|
||||||
const newCreds = { ...credentials, sshHost: '', sshUser: '', sshRemotePath: '', sshMode: 'scp' as const };
|
const newCreds = { ...credentials, sshHost: '', sshUser: '', sshRemotePath: '', sshMode: 'scp' as const };
|
||||||
setCredentials(newCreds);
|
setCredentials(newCreds);
|
||||||
localStorage.setItem('bds-credentials', JSON.stringify(newCreds));
|
await window.electronAPI?.meta.clearPublishingPreferences();
|
||||||
showToast.success(t('settings.toast.credentialsCleared', { type: 'SSH' }));
|
showToast.success(t('settings.toast.credentialsCleared', { type: 'SSH' }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1076,6 +1076,148 @@ describe('MetaEngine', () => {
|
|||||||
expect(collectTagsSpy).toHaveBeenCalledTimes(1);
|
expect(collectTagsSpy).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should normalize sshMode to scp when publishing.json has invalid sshMode', async () => {
|
||||||
|
const metaDir = metaEngine.getMetaDir();
|
||||||
|
mockFiles.set(normalizePath(`${metaDir}/project.json`), JSON.stringify({
|
||||||
|
name: 'Project',
|
||||||
|
}));
|
||||||
|
mockFiles.set(normalizePath(`${metaDir}/publishing.json`), JSON.stringify({
|
||||||
|
sshHost: 'example.com',
|
||||||
|
sshUser: 'deploy',
|
||||||
|
sshRemotePath: '/var/www',
|
||||||
|
sshMode: 'invalid-mode',
|
||||||
|
}));
|
||||||
|
|
||||||
|
await metaEngine.syncOnStartup();
|
||||||
|
|
||||||
|
const prefs = await metaEngine.getPublishingPreferences();
|
||||||
|
expect(prefs?.sshMode).toBe('scp');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Publishing Preferences', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await metaEngine.syncOnStartup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should save and load publishing preferences', async () => {
|
||||||
|
await metaEngine.setPublishingPreferences({
|
||||||
|
sshHost: 'myserver.com',
|
||||||
|
sshUser: 'webmaster',
|
||||||
|
sshRemotePath: '/srv/blog',
|
||||||
|
sshMode: 'rsync',
|
||||||
|
});
|
||||||
|
|
||||||
|
const prefs = await metaEngine.getPublishingPreferences();
|
||||||
|
expect(prefs).toEqual({
|
||||||
|
sshHost: 'myserver.com',
|
||||||
|
sshUser: 'webmaster',
|
||||||
|
sshRemotePath: '/srv/blog',
|
||||||
|
sshMode: 'rsync',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should persist publishing preferences to meta/publishing.json', async () => {
|
||||||
|
const metaDir = metaEngine.getMetaDir();
|
||||||
|
|
||||||
|
await metaEngine.setPublishingPreferences({
|
||||||
|
sshHost: 'host.example.com',
|
||||||
|
sshUser: 'user',
|
||||||
|
sshRemotePath: '/var/www',
|
||||||
|
sshMode: 'scp',
|
||||||
|
});
|
||||||
|
|
||||||
|
const publishingPath = normalizePath(`${metaDir}/publishing.json`);
|
||||||
|
expect(mockFiles.has(publishingPath)).toBe(true);
|
||||||
|
const parsed = JSON.parse(mockFiles.get(publishingPath)!);
|
||||||
|
expect(parsed.sshHost).toBe('host.example.com');
|
||||||
|
expect(parsed.sshUser).toBe('user');
|
||||||
|
expect(parsed.sshRemotePath).toBe('/var/www');
|
||||||
|
expect(parsed.sshMode).toBe('scp');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clear publishing preferences by removing the file', async () => {
|
||||||
|
const metaDir = metaEngine.getMetaDir();
|
||||||
|
|
||||||
|
await metaEngine.setPublishingPreferences({
|
||||||
|
sshHost: 'example.com',
|
||||||
|
sshUser: 'user',
|
||||||
|
sshRemotePath: '/var/www',
|
||||||
|
sshMode: 'scp',
|
||||||
|
});
|
||||||
|
|
||||||
|
await metaEngine.clearPublishingPreferences();
|
||||||
|
|
||||||
|
const prefs = await metaEngine.getPublishingPreferences();
|
||||||
|
expect(prefs).toBeNull();
|
||||||
|
|
||||||
|
const publishingPath = normalizePath(`${metaDir}/publishing.json`);
|
||||||
|
expect(mockFiles.has(publishingPath)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should trim whitespace from string fields', async () => {
|
||||||
|
await metaEngine.setPublishingPreferences({
|
||||||
|
sshHost: ' example.com ',
|
||||||
|
sshUser: ' user ',
|
||||||
|
sshRemotePath: ' /var/www ',
|
||||||
|
sshMode: 'rsync',
|
||||||
|
});
|
||||||
|
|
||||||
|
const prefs = await metaEngine.getPublishingPreferences();
|
||||||
|
expect(prefs?.sshHost).toBe('example.com');
|
||||||
|
expect(prefs?.sshUser).toBe('user');
|
||||||
|
expect(prefs?.sshRemotePath).toBe('/var/www');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should default sshMode to scp when invalid', async () => {
|
||||||
|
await metaEngine.setPublishingPreferences({
|
||||||
|
sshHost: 'example.com',
|
||||||
|
sshUser: 'user',
|
||||||
|
sshRemotePath: '/var/www',
|
||||||
|
sshMode: 'invalid' as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
const prefs = await metaEngine.getPublishingPreferences();
|
||||||
|
expect(prefs?.sshMode).toBe('scp');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should emit publishingPreferencesChanged event on set', async () => {
|
||||||
|
const listener = vi.fn();
|
||||||
|
metaEngine.on('publishingPreferencesChanged', listener);
|
||||||
|
|
||||||
|
await metaEngine.setPublishingPreferences({
|
||||||
|
sshHost: 'example.com',
|
||||||
|
sshUser: 'user',
|
||||||
|
sshRemotePath: '/var/www',
|
||||||
|
sshMode: 'scp',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(listener).toHaveBeenCalledWith({
|
||||||
|
sshHost: 'example.com',
|
||||||
|
sshUser: 'user',
|
||||||
|
sshRemotePath: '/var/www',
|
||||||
|
sshMode: 'scp',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should emit publishingPreferencesChanged with null on clear', async () => {
|
||||||
|
await metaEngine.setPublishingPreferences({
|
||||||
|
sshHost: 'example.com',
|
||||||
|
sshUser: 'user',
|
||||||
|
sshRemotePath: '/var/www',
|
||||||
|
sshMode: 'scp',
|
||||||
|
});
|
||||||
|
|
||||||
|
const listener = vi.fn();
|
||||||
|
metaEngine.on('publishingPreferencesChanged', listener);
|
||||||
|
|
||||||
|
await metaEngine.clearPublishingPreferences();
|
||||||
|
|
||||||
|
expect(listener).toHaveBeenCalledWith(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Sync on Startup (continued)', () => {
|
||||||
it('should use custom dataDir when provided in setProjectContext', () => {
|
it('should use custom dataDir when provided in setProjectContext', () => {
|
||||||
const customDataDir = path.join('custom', 'data', 'path');
|
const customDataDir = path.join('custom', 'data', 'path');
|
||||||
metaEngine.setProjectContext('project-with-custom-dir', customDataDir);
|
metaEngine.setProjectContext('project-with-custom-dir', customDataDir);
|
||||||
@@ -1084,6 +1226,49 @@ describe('MetaEngine', () => {
|
|||||||
expect(normalizePath(metaDir)).toContain(normalizePath(customDataDir));
|
expect(normalizePath(metaDir)).toContain(normalizePath(customDataDir));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should load publishing preferences from publishing.json during syncOnStartup', async () => {
|
||||||
|
const metaDir = metaEngine.getMetaDir();
|
||||||
|
mockFiles.set(normalizePath(`${metaDir}/project.json`), JSON.stringify({
|
||||||
|
name: 'Synced Project',
|
||||||
|
}));
|
||||||
|
mockFiles.set(normalizePath(`${metaDir}/publishing.json`), JSON.stringify({
|
||||||
|
sshHost: 'example.com',
|
||||||
|
sshUser: 'deploy',
|
||||||
|
sshRemotePath: '/var/www/blog',
|
||||||
|
sshMode: 'rsync',
|
||||||
|
}));
|
||||||
|
|
||||||
|
await metaEngine.syncOnStartup();
|
||||||
|
|
||||||
|
const prefs = await metaEngine.getPublishingPreferences();
|
||||||
|
expect(prefs).toEqual({
|
||||||
|
sshHost: 'example.com',
|
||||||
|
sshUser: 'deploy',
|
||||||
|
sshRemotePath: '/var/www/blog',
|
||||||
|
sshMode: 'rsync',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null publishing preferences when publishing.json does not exist', async () => {
|
||||||
|
await metaEngine.syncOnStartup();
|
||||||
|
|
||||||
|
const prefs = await metaEngine.getPublishingPreferences();
|
||||||
|
expect(prefs).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle malformed publishing.json gracefully during syncOnStartup', async () => {
|
||||||
|
const metaDir = metaEngine.getMetaDir();
|
||||||
|
mockFiles.set(normalizePath(`${metaDir}/project.json`), JSON.stringify({
|
||||||
|
name: 'Synced Project',
|
||||||
|
}));
|
||||||
|
mockFiles.set(normalizePath(`${metaDir}/publishing.json`), '{"sshHost":');
|
||||||
|
|
||||||
|
await expect(metaEngine.syncOnStartup()).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
const prefs = await metaEngine.getPublishingPreferences();
|
||||||
|
expect(prefs).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('should ignore and remove dataPath from project.json during syncOnStartup', async () => {
|
it('should ignore and remove dataPath from project.json during syncOnStartup', async () => {
|
||||||
const metaDir = metaEngine.getMetaDir();
|
const metaDir = metaEngine.getMetaDir();
|
||||||
const oldPath = path.join('old', 'path', 'from', 'file');
|
const oldPath = path.join('old', 'path', 'from', 'file');
|
||||||
|
|||||||
Reference in New Issue
Block a user