feat: first cut at publishing tool

This commit is contained in:
2026-02-26 16:52:29 +01:00
parent 74d6035f4a
commit 1666e6bba9
21 changed files with 976 additions and 39 deletions

View File

@@ -0,0 +1,370 @@
import { EventEmitter } from 'events';
import path from 'path';
import fs from 'fs/promises';
import { constants as fsConstants } from 'fs';
import { Client as scpClient, type ScpClient } from 'node-scp';
import rsync from 'rsyncwrapper';
export interface PublishCredentials {
sshHost: string;
sshUser: string;
sshRemotePath: string;
sshMode: 'scp' | 'rsync';
}
export interface PublishResult {
htmlFilesUploaded: number;
thumbnailFilesUploaded: number;
mediaFilesUploaded: number;
filesSkipped: number;
}
type ProgressCallback = (progress: number, message: string) => void;
/** Files with these extensions are excluded from media uploads (metadata sidecars). */
const META_EXTENSION = '.meta';
export class PublishEngine extends EventEmitter {
private projectId: string | null = null;
private dataDir: string | null = null;
constructor() {
super();
}
setProjectContext(projectId: string, dataDir: string): void {
this.projectId = projectId;
this.dataDir = dataDir;
}
async uploadSite(
credentials: PublishCredentials,
onProgress: ProgressCallback,
): Promise<PublishResult> {
if (!this.dataDir || !this.projectId) {
throw new Error('No project context set');
}
this.validateCredentials(credentials);
const htmlDir = path.join(this.dataDir, 'html');
const thumbnailsDir = path.join(this.dataDir, 'thumbnails');
const mediaDir = path.join(this.dataDir, 'media');
// Verify the generated site exists
await this.ensureDirectoryExists(htmlDir, 'Generated site not found. Please render the site first.');
const result: PublishResult = {
htmlFilesUploaded: 0,
thumbnailFilesUploaded: 0,
mediaFilesUploaded: 0,
filesSkipped: 0,
};
if (credentials.sshMode === 'rsync') {
await this.uploadViaRsync(credentials, htmlDir, thumbnailsDir, mediaDir, result, onProgress);
} else {
await this.uploadViaScp(credentials, htmlDir, thumbnailsDir, mediaDir, result, onProgress);
}
onProgress(100, 'Upload complete');
return result;
}
// ── SCP mode ──────────────────────────────────────────────────────────────
private async uploadViaScp(
credentials: PublishCredentials,
htmlDir: string,
thumbnailsDir: string,
mediaDir: string,
result: PublishResult,
onProgress: ProgressCallback,
): Promise<void> {
const client = await scpClient({
host: credentials.sshHost,
username: credentials.sshUser,
agent: process.env.SSH_AUTH_SOCK,
});
try {
// Phase 1: html/ → remote root (033%)
onProgress(0, 'Uploading HTML files...');
const htmlResult = await this.scpUploadDirectory(
client,
htmlDir,
credentials.sshRemotePath,
(p, msg) => onProgress(Math.round(p * 0.33), msg),
);
result.htmlFilesUploaded = htmlResult.uploaded;
result.filesSkipped += htmlResult.skipped;
// Phase 2: thumbnails/ → remote/thumbnails/ (3366%)
onProgress(33, 'Uploading thumbnails...');
if (await this.directoryExists(thumbnailsDir)) {
const thumbResult = await this.scpUploadDirectory(
client,
thumbnailsDir,
path.posix.join(credentials.sshRemotePath, 'thumbnails'),
(p, msg) => onProgress(33 + Math.round(p * 0.33), msg),
);
result.thumbnailFilesUploaded = thumbResult.uploaded;
result.filesSkipped += thumbResult.skipped;
}
// Phase 3: media/ → remote/media/ (6699%), excluding .meta files
onProgress(66, 'Uploading media files...');
if (await this.directoryExists(mediaDir)) {
const mediaResult = await this.scpUploadDirectory(
client,
mediaDir,
path.posix.join(credentials.sshRemotePath, 'media'),
(p, msg) => onProgress(66 + Math.round(p * 0.33), msg),
(name) => !name.endsWith(META_EXTENSION),
);
result.mediaFilesUploaded = mediaResult.uploaded;
result.filesSkipped += mediaResult.skipped;
}
} finally {
client.close();
}
}
/**
* Recursively upload a local directory to a remote path via SCP/SFTP.
* Only uploads files that are newer than the remote version.
*/
private async scpUploadDirectory(
client: ScpClient,
localDir: string,
remoteDir: string,
onProgress: ProgressCallback,
fileFilter?: (name: string) => boolean,
): Promise<{ uploaded: number; skipped: number }> {
// Collect all files first for progress tracking
const files = await this.collectFiles(localDir, '', fileFilter);
let uploaded = 0;
let skipped = 0;
if (files.length === 0) {
onProgress(100, 'No files to upload');
return { uploaded: 0, skipped: 0 };
}
// Ensure remote directory exists
await this.scpEnsureDir(client, remoteDir);
// Track created directories to avoid redundant mkdir calls
const createdDirs = new Set<string>();
for (let i = 0; i < files.length; i++) {
const relativePath = files[i];
const localPath = path.join(localDir, relativePath);
const remotePath = path.posix.join(remoteDir, relativePath.split(path.sep).join('/'));
// Ensure parent directory exists on remote
const remoteParent = path.posix.dirname(remotePath);
if (!createdDirs.has(remoteParent)) {
await this.scpEnsureDir(client, remoteParent);
createdDirs.add(remoteParent);
}
// Check if we need to upload (compare mtime)
const localStat = await fs.stat(localPath);
const needsUpload = await this.scpNeedsUpload(client, remotePath, localStat.mtimeMs);
if (needsUpload) {
await client.uploadFile(localPath, remotePath);
uploaded++;
} else {
skipped++;
}
const progress = ((i + 1) / files.length) * 100;
onProgress(progress, `${uploaded} uploaded, ${skipped} skipped (${i + 1}/${files.length})`);
}
return { uploaded, skipped };
}
/**
* Check if a local file is newer than the remote file.
* Returns true if upload is needed.
*/
private async scpNeedsUpload(
client: ScpClient,
remotePath: string,
localMtimeMs: number,
): Promise<boolean> {
try {
const remoteStat = await client.stat(remotePath);
// SSH2 Stats.mtime is in seconds, localMtimeMs is in milliseconds
const remoteMtimeMs = remoteStat.mtime * 1000;
return localMtimeMs > remoteMtimeMs;
} catch {
// File doesn't exist on remote → needs upload
return true;
}
}
private async scpEnsureDir(client: ScpClient, remoteDir: string): Promise<void> {
try {
await client.mkdir(remoteDir, undefined, { recursive: true });
} catch {
// Directory may already exist
}
}
// ── rsync mode ────────────────────────────────────────────────────────────
private async uploadViaRsync(
credentials: PublishCredentials,
htmlDir: string,
thumbnailsDir: string,
mediaDir: string,
result: PublishResult,
onProgress: ProgressCallback,
): Promise<void> {
const remoteDest = `${credentials.sshUser}@${credentials.sshHost}:${credentials.sshRemotePath}`;
// Phase 1: html/ → remote root (033%)
onProgress(0, 'Syncing HTML files via rsync...');
const htmlCount = await this.rsyncDirectory(
htmlDir + '/',
remoteDest + '/',
);
result.htmlFilesUploaded = htmlCount;
onProgress(33, 'HTML sync complete');
// Phase 2: thumbnails/ → remote/thumbnails/ (3366%)
if (await this.directoryExists(thumbnailsDir)) {
onProgress(33, 'Syncing thumbnails via rsync...');
const thumbCount = await this.rsyncDirectory(
thumbnailsDir + '/',
remoteDest + '/thumbnails/',
);
result.thumbnailFilesUploaded = thumbCount;
}
onProgress(66, 'Thumbnails sync complete');
// Phase 3: media/ → remote/media/ (6699%), excluding .meta files
if (await this.directoryExists(mediaDir)) {
onProgress(66, 'Syncing media files via rsync...');
const mediaCount = await this.rsyncDirectory(
mediaDir + '/',
remoteDest + '/media/',
['*.meta'],
);
result.mediaFilesUploaded = mediaCount;
}
onProgress(99, 'Media sync complete');
}
/**
* Run rsync for a directory with incremental (--update --times) and recursive transfer.
* Returns estimated file count from stdout.
*/
private rsyncDirectory(
src: string,
dest: string,
exclude?: string[],
): Promise<number> {
return new Promise((resolve, reject) => {
rsync(
{
src,
dest,
ssh: true,
recursive: true,
times: true,
args: ['--update', '--compress'],
exclude: exclude || [],
},
(error, stdout, _stderr, _cmd) => {
if (error) {
reject(error);
} else {
// Count uploaded files from rsync stdout (each transferred file gets a line)
const lines = stdout.trim().split('\n').filter((l: string) => l.length > 0);
resolve(lines.length);
}
},
);
});
}
// ── Helpers ───────────────────────────────────────────────────────────────
/**
* Recursively collect all file paths relative to baseDir, with optional filter.
*/
private async collectFiles(
baseDir: string,
prefix: string,
filter?: (name: string) => boolean,
): Promise<string[]> {
const files: string[] = [];
let entries;
try {
entries = await fs.readdir(baseDir, { withFileTypes: true });
} catch {
return files;
}
for (const entry of entries) {
const relativePath = prefix ? path.join(prefix, entry.name) : entry.name;
if (entry.isDirectory()) {
const subFiles = await this.collectFiles(
path.join(baseDir, entry.name),
relativePath,
filter,
);
files.push(...subFiles);
} else if (entry.isFile()) {
if (!filter || filter(entry.name)) {
files.push(relativePath);
}
}
}
return files;
}
private validateCredentials(credentials: PublishCredentials): void {
if (!credentials.sshHost?.trim()) {
throw new Error('SSH host is required');
}
if (!credentials.sshUser?.trim()) {
throw new Error('SSH user is required');
}
if (!credentials.sshRemotePath?.trim()) {
throw new Error('Remote path is required');
}
}
private async ensureDirectoryExists(dirPath: string, errorMessage: string): Promise<void> {
try {
await fs.access(dirPath, fsConstants.F_OK);
} catch {
throw new Error(errorMessage);
}
}
private async directoryExists(dirPath: string): Promise<boolean> {
try {
await fs.access(dirPath, fsConstants.F_OK);
return true;
} catch {
return false;
}
}
}
// Singleton
let publishEngine: PublishEngine | null = null;
export function getPublishEngine(): PublishEngine {
if (!publishEngine) {
publishEngine = new PublishEngine();
}
return publishEngine;
}

View File

@@ -108,3 +108,9 @@ export {
type CreateScriptInput,
type UpdateScriptInput,
} from './ScriptEngine';
export {
PublishEngine,
getPublishEngine,
type PublishCredentials,
type PublishResult,
} from './PublishEngine';

View File

@@ -18,6 +18,7 @@ import { APP_MENU_ACTION_EVENT_MAP, APP_MENU_WEB_CONTENTS_ACTIONS, type AppMenuA
import { generateBlogmarkBookmarkletSource } from '../shared/blogmark';
import { registerMetadataDiffHandlers } from './metadataDiffHandlers';
import { registerBlogHandlers } from './blogHandlers';
import { registerPublishHandlers } from './publishHandlers';
/**
* Wrap an IPC handler so that "Database is closing" errors during shutdown
@@ -1433,6 +1434,7 @@ export function registerIpcHandlers(): void {
registerMetadataDiffHandlers(safeHandle);
registerBlogHandlers(safeHandle);
registerPublishHandlers(safeHandle);
// ============ Event Forwarding ============

View File

@@ -0,0 +1,30 @@
import { getProjectEngine } from '../engine/ProjectEngine';
import { getPublishEngine, type PublishCredentials } from '../engine/PublishEngine';
import { taskManager } from '../engine/TaskManager';
type SafeHandle = (channel: string, handler: (...args: any[]) => Promise<any>) => void;
export function registerPublishHandlers(safeHandle: SafeHandle): void {
safeHandle('publish:uploadSite', async (_event: unknown, credentials: PublishCredentials) => {
const projectEngine = getProjectEngine();
const project = await projectEngine.getActiveProject();
if (!project) {
throw new Error('No active project');
}
const publishEngine = getPublishEngine();
publishEngine.setProjectContext(project.id, project.dataPath!);
return taskManager.runTask({
id: `publish-upload-${Date.now()}`,
name: 'Upload Site',
groupId: 'publish',
groupName: 'Site Publishing',
execute: async (onProgress) => {
return publishEngine.uploadSite(credentials, (progress, message) => {
onProgress(progress, message);
});
},
});
});
}

View File

@@ -272,6 +272,12 @@ export const electronAPI: ElectronAPI = {
regenerateCalendar: () => ipcRenderer.invoke('blog:regenerateCalendar'),
},
// Site publishing (SCP/rsync)
publish: {
uploadSite: (credentials: { sshHost: string; sshUser: string; sshRemotePath: string; sshMode: 'scp' | 'rsync' }) =>
ipcRenderer.invoke('publish:uploadSite', credentials),
},
menu: {
get: () => ipcRenderer.invoke('menu:get'),
save: (menu: import('./shared/electronApi').MenuDocument) => ipcRenderer.invoke('menu:save', menu),

View File

@@ -709,6 +709,19 @@ export interface ElectronAPI {
applyValidation: (report: SiteValidationReport) => Promise<SiteValidationApplyResult>;
regenerateCalendar: () => Promise<CalendarRegenerationResult>;
};
publish: {
uploadSite: (credentials: {
sshHost: string;
sshUser: string;
sshRemotePath: string;
sshMode: 'scp' | 'rsync';
}) => Promise<{
htmlFilesUploaded: number;
thumbnailFilesUploaded: number;
mediaFilesUploaded: number;
filesSkipped: number;
}>;
};
menu: {
get: () => Promise<MenuDocument>;
save: (menu: MenuDocument) => Promise<MenuDocument>;

View File

@@ -41,6 +41,7 @@
"menu.item.generateSitemap": "Site rendern",
"menu.item.regenerateCalendar": "Kalender neu erzeugen",
"menu.item.validateSite": "Website validieren",
"menu.item.uploadSite": "Website hochladen",
"menu.item.about": "Über Blogging Desktop Server",
"menu.item.openDocumentation": "Dokumentation öffnen",
"menu.item.openApiDocumentation": "API-Dokumentation",

View File

@@ -41,6 +41,7 @@
"menu.item.generateSitemap": "Render Site",
"menu.item.regenerateCalendar": "Regenerate Calendar",
"menu.item.validateSite": "Validate Site",
"menu.item.uploadSite": "Upload Site",
"menu.item.about": "About Blogging Desktop Server",
"menu.item.openDocumentation": "Open Documentation",
"menu.item.openApiDocumentation": "API documentation",

View File

@@ -41,6 +41,7 @@
"menu.item.generateSitemap": "Renderizar sitio",
"menu.item.regenerateCalendar": "Regenerar calendario",
"menu.item.validateSite": "Validar sitio",
"menu.item.uploadSite": "Subir sitio",
"menu.item.about": "Acerca de Blogging Desktop Server",
"menu.item.openDocumentation": "Abrir documentación",
"menu.item.openApiDocumentation": "Documentación API",

View File

@@ -41,6 +41,7 @@
"menu.item.generateSitemap": "Rendre le site",
"menu.item.regenerateCalendar": "Régénérer le calendrier",
"menu.item.validateSite": "Valider le site",
"menu.item.uploadSite": "Publier le site",
"menu.item.about": "À propos de Blogging Desktop Server",
"menu.item.openDocumentation": "Ouvrir la documentation",
"menu.item.openApiDocumentation": "Documentation API",

View File

@@ -41,6 +41,7 @@
"menu.item.generateSitemap": "Renderizza sito",
"menu.item.regenerateCalendar": "Rigenera calendario",
"menu.item.validateSite": "Valida sito",
"menu.item.uploadSite": "Carica sito",
"menu.item.about": "Informazioni su Blogging Desktop Server",
"menu.item.openDocumentation": "Apri documentazione",
"menu.item.openApiDocumentation": "Documentazione API",

View File

@@ -36,6 +36,7 @@ export type AppMenuAction =
| 'generateSitemap'
| 'regenerateCalendar'
| 'validateSite'
| 'uploadSite'
| 'openDocumentation'
| 'openApiDocumentation'
| 'about'
@@ -132,6 +133,8 @@ export const APP_MENU_GROUPS: AppMenuGroupDefinition[] = [
{ label: 'menu.item.generateSitemap', action: 'generateSitemap', accelerator: 'CmdOrCtrl+R' },
{ label: 'menu.item.regenerateCalendar', action: 'regenerateCalendar' },
{ label: 'menu.item.validateSite', action: 'validateSite', accelerator: 'CmdOrCtrl+Shift+L' },
{ label: '', action: 'blog-separator-4', separator: true },
{ label: 'menu.item.uploadSite', action: 'uploadSite', accelerator: 'CmdOrCtrl+Shift+U' },
],
},
{
@@ -169,6 +172,7 @@ export const APP_MENU_ACTION_EVENT_MAP: Partial<Record<AppMenuAction, string>> =
generateSitemap: 'menu:generateSitemap',
regenerateCalendar: 'menu:regenerateCalendar',
validateSite: 'menu:validateSite',
uploadSite: 'menu:uploadSite',
openDocumentation: 'menu:openDocumentation',
openApiDocumentation: 'menu:openApiDocumentation',
about: 'menu:about',

View File

@@ -495,6 +495,27 @@ const App: React.FC = () => {
}) || (() => {})
);
unsubscribers.push(
window.electronAPI?.on('menu:uploadSite', async () => {
try {
const stored = localStorage.getItem('bds-credentials');
if (!stored) {
showToast.error(tr('app.uploadSiteNoCredentials'));
return;
}
const credentials = JSON.parse(stored);
if (!credentials.sshHost || !credentials.sshUser || !credentials.sshRemotePath) {
showToast.error(tr('app.uploadSiteNoCredentials'));
return;
}
await window.electronAPI?.publish.uploadSite(credentials);
} catch (error) {
console.error('Site upload failed:', error);
showToast.error(tr('app.uploadSiteFailed'));
}
}) || (() => {})
);
unsubscribers.push(
window.electronAPI?.on('menu:openDocumentation', () => {
openSingletonToolTab(openTab, 'documentation');

View File

@@ -32,6 +32,8 @@
"app.textReindexFailed": "Text-Neuindizierung fehlgeschlagen",
"app.sitemapGenerationFailed": "Sitemap-Erstellung fehlgeschlagen",
"app.calendarRegenerationFailed": "Kalender-Neuerstellung fehlgeschlagen",
"app.uploadSiteFailed": "Website-Upload fehlgeschlagen",
"app.uploadSiteNoCredentials": "Bitte konfigurieren Sie zuerst die SSH-Zugangsdaten in den Einstellungen.",
"app.previewOpenFailed": "Ausgewählte Beitragsvorschau konnte nicht geöffnet werden",
"app.metadataDiff": "Metadaten-Diff",
"app.importComplete": "Import abgeschlossen: {posts} Beiträge, {media} Mediendateien",

View File

@@ -32,6 +32,8 @@
"app.textReindexFailed": "Text reindex failed",
"app.sitemapGenerationFailed": "Sitemap generation failed",
"app.calendarRegenerationFailed": "Calendar regeneration failed",
"app.uploadSiteFailed": "Site upload failed",
"app.uploadSiteNoCredentials": "Please configure SSH publishing credentials in Settings first.",
"app.previewOpenFailed": "Failed to open selected post preview",
"app.metadataDiff": "Metadata Diff",
"app.importComplete": "Import complete: {posts} posts, {media} media files",

View File

@@ -32,6 +32,8 @@
"app.textReindexFailed": "La reindexación de texto falló",
"app.sitemapGenerationFailed": "La generación del sitemap falló",
"app.calendarRegenerationFailed": "La regeneración del calendario falló",
"app.uploadSiteFailed": "Error al subir el sitio",
"app.uploadSiteNoCredentials": "Configure primero las credenciales SSH en Configuración.",
"app.previewOpenFailed": "No se pudo abrir la vista previa de la entrada seleccionada",
"app.metadataDiff": "Diferencia de Metadatos",
"app.importComplete": "Importación completada: {posts} entradas, {media} archivos multimedia",

View File

@@ -31,8 +31,8 @@
"app.databaseRebuildFailed": "Échec de la reconstruction de la base de données",
"app.textReindexFailed": "Échec de la réindexation du texte",
"app.sitemapGenerationFailed": "Échec de la génération du sitemap",
"app.calendarRegenerationFailed": "Échec de la régénération du calendrier",
"app.previewOpenFailed": "Impossible douvrir laperçu de larticle sélectionné",
"app.calendarRegenerationFailed": "Échec de la régénération du calendrier", "app.uploadSiteFailed": "Échec de la publication du site",
"app.uploadSiteNoCredentials": "Veuillez d'abord configurer les identifiants SSH dans les paramètres.", "app.previewOpenFailed": "Impossible douvrir laperçu de larticle sélectionné",
"app.metadataDiff": "Diff Métadonnées",
"app.importComplete": "Import terminé : {posts} articles, {media} fichiers média",
"siteValidation.tabTitle": "Validation du site",

View File

@@ -31,8 +31,8 @@
"app.databaseRebuildFailed": "Ricostruzione database non riuscita",
"app.textReindexFailed": "Reindicizzazione testo non riuscita",
"app.sitemapGenerationFailed": "Generazione sitemap non riuscita",
"app.calendarRegenerationFailed": "Rigenerazione del calendario non riuscita",
"app.previewOpenFailed": "Impossibile aprire lanteprima del post selezionato",
"app.calendarRegenerationFailed": "Rigenerazione del calendario non riuscita", "app.uploadSiteFailed": "Caricamento del sito non riuscito",
"app.uploadSiteNoCredentials": "Configurare prima le credenziali SSH nelle impostazioni.", "app.previewOpenFailed": "Impossibile aprire lanteprima del post selezionato",
"app.metadataDiff": "Diff Metadati",
"app.importComplete": "Import completato: {posts} post, {media} file multimediali",
"siteValidation.tabTitle": "Validazione sito",