initial commit
This commit is contained in:
442
src/main/engine/MediaEngine.ts
Normal file
442
src/main/engine/MediaEngine.ts
Normal file
@@ -0,0 +1,442 @@
|
||||
import { EventEmitter } from 'events';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import * as crypto from 'crypto';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { getDatabase } from '../database';
|
||||
import { media, Media, NewMedia } from '../database/schema';
|
||||
import { taskManager, Task } from './TaskManager';
|
||||
|
||||
export interface MediaData {
|
||||
id: string;
|
||||
filename: string;
|
||||
originalName: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
alt?: string;
|
||||
caption?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export interface MediaMetadata {
|
||||
id: string;
|
||||
originalName: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
alt?: string;
|
||||
caption?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export class MediaEngine extends EventEmitter {
|
||||
private mediaDir: string;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.mediaDir = getDatabase().getDataPaths().media;
|
||||
}
|
||||
|
||||
private calculateChecksum(buffer: Buffer): string {
|
||||
return crypto.createHash('md5').update(buffer).digest('hex');
|
||||
}
|
||||
|
||||
private async writeSidecarFile(mediaData: MediaData, mediaPath: string): Promise<string> {
|
||||
const sidecarPath = `${mediaPath}.meta`;
|
||||
|
||||
const metadata: MediaMetadata = {
|
||||
id: mediaData.id,
|
||||
originalName: mediaData.originalName,
|
||||
mimeType: mediaData.mimeType,
|
||||
size: mediaData.size,
|
||||
width: mediaData.width,
|
||||
height: mediaData.height,
|
||||
alt: mediaData.alt,
|
||||
caption: mediaData.caption,
|
||||
createdAt: mediaData.createdAt.toISOString(),
|
||||
updatedAt: mediaData.updatedAt.toISOString(),
|
||||
tags: mediaData.tags,
|
||||
};
|
||||
|
||||
// Write YAML-like format consistent with posts
|
||||
const lines = [
|
||||
'---',
|
||||
`id: ${metadata.id}`,
|
||||
`originalName: "${metadata.originalName}"`,
|
||||
`mimeType: ${metadata.mimeType}`,
|
||||
`size: ${metadata.size}`,
|
||||
];
|
||||
|
||||
if (metadata.width) lines.push(`width: ${metadata.width}`);
|
||||
if (metadata.height) lines.push(`height: ${metadata.height}`);
|
||||
if (metadata.alt) lines.push(`alt: "${metadata.alt}"`);
|
||||
if (metadata.caption) lines.push(`caption: "${metadata.caption}"`);
|
||||
|
||||
lines.push(`createdAt: ${metadata.createdAt}`);
|
||||
lines.push(`updatedAt: ${metadata.updatedAt}`);
|
||||
lines.push(`tags: [${metadata.tags.map(t => `"${t}"`).join(', ')}]`);
|
||||
lines.push('---');
|
||||
|
||||
await fs.writeFile(sidecarPath, lines.join('\n'), 'utf-8');
|
||||
return sidecarPath;
|
||||
}
|
||||
|
||||
private async readSidecarFile(sidecarPath: string): Promise<MediaMetadata | null> {
|
||||
try {
|
||||
const content = await fs.readFile(sidecarPath, 'utf-8');
|
||||
const lines = content.split('\n');
|
||||
|
||||
const metadata: Partial<MediaMetadata> = {
|
||||
tags: [],
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
if (line === '---') continue;
|
||||
|
||||
const colonIndex = line.indexOf(':');
|
||||
if (colonIndex === -1) continue;
|
||||
|
||||
const key = line.substring(0, colonIndex).trim();
|
||||
let value = line.substring(colonIndex + 1).trim();
|
||||
|
||||
// Remove quotes
|
||||
if (value.startsWith('"') && value.endsWith('"')) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
|
||||
switch (key) {
|
||||
case 'id':
|
||||
metadata.id = value;
|
||||
break;
|
||||
case 'originalName':
|
||||
metadata.originalName = value;
|
||||
break;
|
||||
case 'mimeType':
|
||||
metadata.mimeType = value;
|
||||
break;
|
||||
case 'size':
|
||||
metadata.size = parseInt(value, 10);
|
||||
break;
|
||||
case 'width':
|
||||
metadata.width = parseInt(value, 10);
|
||||
break;
|
||||
case 'height':
|
||||
metadata.height = parseInt(value, 10);
|
||||
break;
|
||||
case 'alt':
|
||||
metadata.alt = value;
|
||||
break;
|
||||
case 'caption':
|
||||
metadata.caption = value;
|
||||
break;
|
||||
case 'createdAt':
|
||||
metadata.createdAt = value;
|
||||
break;
|
||||
case 'updatedAt':
|
||||
metadata.updatedAt = value;
|
||||
break;
|
||||
case 'tags':
|
||||
// Parse array format: ["tag1", "tag2"]
|
||||
const match = value.match(/\[(.*)\]/);
|
||||
if (match) {
|
||||
metadata.tags = match[1]
|
||||
.split(',')
|
||||
.map(t => t.trim().replace(/"/g, ''))
|
||||
.filter(t => t.length > 0);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!metadata.id || !metadata.originalName || !metadata.mimeType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return metadata as MediaMetadata;
|
||||
} catch (error) {
|
||||
console.error(`Failed to read sidecar file: ${sidecarPath}`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private getMimeType(filename: string): string {
|
||||
const ext = path.extname(filename).toLowerCase();
|
||||
const mimeTypes: Record<string, string> = {
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.png': 'image/png',
|
||||
'.gif': 'image/gif',
|
||||
'.webp': 'image/webp',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.bmp': 'image/bmp',
|
||||
'.ico': 'image/x-icon',
|
||||
};
|
||||
return mimeTypes[ext] || 'application/octet-stream';
|
||||
}
|
||||
|
||||
async importMedia(sourcePath: string, metadata?: Partial<MediaData>): Promise<MediaData> {
|
||||
const db = getDatabase().getLocal();
|
||||
const id = uuidv4();
|
||||
const now = new Date();
|
||||
|
||||
const sourceBuffer = await fs.readFile(sourcePath);
|
||||
const originalName = path.basename(sourcePath);
|
||||
const ext = path.extname(originalName);
|
||||
const filename = `${id}${ext}`;
|
||||
const destPath = path.join(this.mediaDir, filename);
|
||||
|
||||
// Copy file to media directory
|
||||
await fs.writeFile(destPath, sourceBuffer);
|
||||
|
||||
const mediaData: MediaData = {
|
||||
id,
|
||||
filename,
|
||||
originalName,
|
||||
mimeType: metadata?.mimeType || this.getMimeType(originalName),
|
||||
size: sourceBuffer.length,
|
||||
width: metadata?.width,
|
||||
height: metadata?.height,
|
||||
alt: metadata?.alt,
|
||||
caption: metadata?.caption,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
tags: metadata?.tags || [],
|
||||
};
|
||||
|
||||
const sidecarPath = await this.writeSidecarFile(mediaData, destPath);
|
||||
const checksum = this.calculateChecksum(sourceBuffer);
|
||||
|
||||
const dbMedia: NewMedia = {
|
||||
id: mediaData.id,
|
||||
filename: mediaData.filename,
|
||||
originalName: mediaData.originalName,
|
||||
mimeType: mediaData.mimeType,
|
||||
size: mediaData.size,
|
||||
width: mediaData.width,
|
||||
height: mediaData.height,
|
||||
alt: mediaData.alt,
|
||||
caption: mediaData.caption,
|
||||
filePath: destPath,
|
||||
sidecarPath,
|
||||
createdAt: mediaData.createdAt,
|
||||
updatedAt: mediaData.updatedAt,
|
||||
syncStatus: 'pending',
|
||||
checksum,
|
||||
tags: JSON.stringify(mediaData.tags),
|
||||
};
|
||||
|
||||
await db.insert(media).values(dbMedia);
|
||||
|
||||
this.emit('mediaImported', mediaData);
|
||||
return mediaData;
|
||||
}
|
||||
|
||||
async updateMedia(id: string, data: Partial<MediaData>): Promise<MediaData | null> {
|
||||
const db = getDatabase().getLocal();
|
||||
const existing = await this.getMedia(id);
|
||||
|
||||
if (!existing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const updated: MediaData = {
|
||||
...existing,
|
||||
...data,
|
||||
id, // Ensure ID doesn't change
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const dbMedia = await db.select().from(media).where(eq(media.id, id)).get();
|
||||
if (!dbMedia) return null;
|
||||
|
||||
await this.writeSidecarFile(updated, dbMedia.filePath);
|
||||
|
||||
await db.update(media)
|
||||
.set({
|
||||
alt: updated.alt,
|
||||
caption: updated.caption,
|
||||
updatedAt: updated.updatedAt,
|
||||
syncStatus: 'pending',
|
||||
tags: JSON.stringify(updated.tags),
|
||||
})
|
||||
.where(eq(media.id, id));
|
||||
|
||||
this.emit('mediaUpdated', updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
async deleteMedia(id: string): Promise<boolean> {
|
||||
const db = getDatabase().getLocal();
|
||||
const existing = await db.select().from(media).where(eq(media.id, id)).get();
|
||||
|
||||
if (!existing) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Delete media file
|
||||
try {
|
||||
await fs.unlink(existing.filePath);
|
||||
} catch {
|
||||
// File might not exist
|
||||
}
|
||||
|
||||
// Delete sidecar file
|
||||
try {
|
||||
await fs.unlink(existing.sidecarPath);
|
||||
} catch {
|
||||
// File might not exist
|
||||
}
|
||||
|
||||
await db.delete(media).where(eq(media.id, id));
|
||||
|
||||
this.emit('mediaDeleted', id);
|
||||
return true;
|
||||
}
|
||||
|
||||
async getMedia(id: string): Promise<MediaData | null> {
|
||||
const db = getDatabase().getLocal();
|
||||
const dbMedia = await db.select().from(media).where(eq(media.id, id)).get();
|
||||
|
||||
if (!dbMedia) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: dbMedia.id,
|
||||
filename: dbMedia.filename,
|
||||
originalName: dbMedia.originalName,
|
||||
mimeType: dbMedia.mimeType,
|
||||
size: dbMedia.size,
|
||||
width: dbMedia.width || undefined,
|
||||
height: dbMedia.height || undefined,
|
||||
alt: dbMedia.alt || undefined,
|
||||
caption: dbMedia.caption || undefined,
|
||||
createdAt: dbMedia.createdAt,
|
||||
updatedAt: dbMedia.updatedAt,
|
||||
tags: JSON.parse(dbMedia.tags || '[]'),
|
||||
};
|
||||
}
|
||||
|
||||
async getAllMedia(): Promise<MediaData[]> {
|
||||
const db = getDatabase().getLocal();
|
||||
const dbMediaList = await db.select().from(media).all();
|
||||
|
||||
return dbMediaList.map(dbMedia => ({
|
||||
id: dbMedia.id,
|
||||
filename: dbMedia.filename,
|
||||
originalName: dbMedia.originalName,
|
||||
mimeType: dbMedia.mimeType,
|
||||
size: dbMedia.size,
|
||||
width: dbMedia.width || undefined,
|
||||
height: dbMedia.height || undefined,
|
||||
alt: dbMedia.alt || undefined,
|
||||
caption: dbMedia.caption || undefined,
|
||||
createdAt: dbMedia.createdAt,
|
||||
updatedAt: dbMedia.updatedAt,
|
||||
tags: JSON.parse(dbMedia.tags || '[]'),
|
||||
}));
|
||||
}
|
||||
|
||||
getMediaPath(id: string): string {
|
||||
return path.join(this.mediaDir, id);
|
||||
}
|
||||
|
||||
async rebuildDatabaseFromFiles(): Promise<void> {
|
||||
const task: Task<void> = {
|
||||
id: uuidv4(),
|
||||
name: 'Rebuild database from media files',
|
||||
execute: async (onProgress) => {
|
||||
const db = getDatabase().getLocal();
|
||||
|
||||
onProgress(0, 'Scanning media directory...');
|
||||
|
||||
const files = await fs.readdir(this.mediaDir);
|
||||
const metaFiles = files.filter(f => f.endsWith('.meta'));
|
||||
|
||||
onProgress(10, `Found ${metaFiles.length} media sidecar files`);
|
||||
|
||||
for (let i = 0; i < metaFiles.length; i++) {
|
||||
const metaFile = metaFiles[i];
|
||||
const sidecarPath = path.join(this.mediaDir, metaFile);
|
||||
const mediaFilePath = sidecarPath.replace('.meta', '');
|
||||
|
||||
onProgress(10 + (80 * (i / metaFiles.length)), `Processing ${metaFile}...`);
|
||||
|
||||
const metadata = await this.readSidecarFile(sidecarPath);
|
||||
|
||||
if (metadata) {
|
||||
try {
|
||||
const stats = await fs.stat(mediaFilePath);
|
||||
const buffer = await fs.readFile(mediaFilePath);
|
||||
const checksum = this.calculateChecksum(buffer);
|
||||
const filename = path.basename(mediaFilePath);
|
||||
|
||||
const existing = await db.select().from(media).where(eq(media.id, metadata.id)).get();
|
||||
|
||||
if (existing) {
|
||||
await db.update(media)
|
||||
.set({
|
||||
originalName: metadata.originalName,
|
||||
mimeType: metadata.mimeType,
|
||||
size: stats.size,
|
||||
width: metadata.width,
|
||||
height: metadata.height,
|
||||
alt: metadata.alt,
|
||||
caption: metadata.caption,
|
||||
updatedAt: new Date(metadata.updatedAt),
|
||||
checksum,
|
||||
tags: JSON.stringify(metadata.tags),
|
||||
})
|
||||
.where(eq(media.id, metadata.id));
|
||||
} else {
|
||||
await db.insert(media).values({
|
||||
id: metadata.id,
|
||||
filename,
|
||||
originalName: metadata.originalName,
|
||||
mimeType: metadata.mimeType,
|
||||
size: stats.size,
|
||||
width: metadata.width,
|
||||
height: metadata.height,
|
||||
alt: metadata.alt,
|
||||
caption: metadata.caption,
|
||||
filePath: mediaFilePath,
|
||||
sidecarPath,
|
||||
createdAt: new Date(metadata.createdAt),
|
||||
updatedAt: new Date(metadata.updatedAt),
|
||||
syncStatus: 'pending',
|
||||
checksum,
|
||||
tags: JSON.stringify(metadata.tags),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Media file not found for sidecar: ${sidecarPath}`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onProgress(100, 'Database rebuild complete');
|
||||
this.emit('databaseRebuilt');
|
||||
},
|
||||
};
|
||||
|
||||
await taskManager.runTask(task);
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let mediaEngine: MediaEngine | null = null;
|
||||
|
||||
export function getMediaEngine(): MediaEngine {
|
||||
if (!mediaEngine) {
|
||||
mediaEngine = new MediaEngine();
|
||||
}
|
||||
return mediaEngine;
|
||||
}
|
||||
386
src/main/engine/PostEngine.ts
Normal file
386
src/main/engine/PostEngine.ts
Normal file
@@ -0,0 +1,386 @@
|
||||
import { EventEmitter } from 'events';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import * as crypto from 'crypto';
|
||||
import matter from 'gray-matter';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { getDatabase } from '../database';
|
||||
import { posts, Post, NewPost } from '../database/schema';
|
||||
import { taskManager, Task } from './TaskManager';
|
||||
|
||||
export interface PostData {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
excerpt?: string;
|
||||
content: string;
|
||||
status: 'draft' | 'published' | 'archived';
|
||||
author?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
publishedAt?: Date;
|
||||
tags: string[];
|
||||
categories: string[];
|
||||
}
|
||||
|
||||
export interface PostMetadata {
|
||||
title: string;
|
||||
slug: string;
|
||||
excerpt?: string;
|
||||
status: 'draft' | 'published' | 'archived';
|
||||
author?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
publishedAt?: string;
|
||||
tags: string[];
|
||||
categories: string[];
|
||||
id: string;
|
||||
}
|
||||
|
||||
export class PostEngine extends EventEmitter {
|
||||
private postsDir: string;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.postsDir = getDatabase().getDataPaths().posts;
|
||||
}
|
||||
|
||||
private generateSlug(title: string): string {
|
||||
return title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
}
|
||||
|
||||
private calculateChecksum(content: string): string {
|
||||
return crypto.createHash('md5').update(content).digest('hex');
|
||||
}
|
||||
|
||||
private async writePostFile(post: PostData): Promise<string> {
|
||||
const metadata: PostMetadata = {
|
||||
id: post.id,
|
||||
title: post.title,
|
||||
slug: post.slug,
|
||||
excerpt: post.excerpt,
|
||||
status: post.status,
|
||||
author: post.author,
|
||||
createdAt: post.createdAt.toISOString(),
|
||||
updatedAt: post.updatedAt.toISOString(),
|
||||
publishedAt: post.publishedAt?.toISOString(),
|
||||
tags: post.tags,
|
||||
categories: post.categories,
|
||||
};
|
||||
|
||||
const fileContent = matter.stringify(post.content, metadata);
|
||||
const filePath = path.join(this.postsDir, `${post.slug}.md`);
|
||||
|
||||
await fs.writeFile(filePath, fileContent, 'utf-8');
|
||||
return filePath;
|
||||
}
|
||||
|
||||
private async readPostFile(filePath: string): Promise<PostData | null> {
|
||||
try {
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
const { data, content: body } = matter(content);
|
||||
const metadata = data as PostMetadata;
|
||||
|
||||
return {
|
||||
id: metadata.id,
|
||||
title: metadata.title,
|
||||
slug: metadata.slug,
|
||||
excerpt: metadata.excerpt,
|
||||
content: body,
|
||||
status: metadata.status,
|
||||
author: metadata.author,
|
||||
createdAt: new Date(metadata.createdAt),
|
||||
updatedAt: new Date(metadata.updatedAt),
|
||||
publishedAt: metadata.publishedAt ? new Date(metadata.publishedAt) : undefined,
|
||||
tags: metadata.tags || [],
|
||||
categories: metadata.categories || [],
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Failed to read post file: ${filePath}`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async createPost(data: Partial<PostData>): Promise<PostData> {
|
||||
const db = getDatabase().getLocal();
|
||||
const now = new Date();
|
||||
const id = uuidv4();
|
||||
const slug = data.slug || this.generateSlug(data.title || 'untitled');
|
||||
|
||||
const post: PostData = {
|
||||
id,
|
||||
title: data.title || 'Untitled',
|
||||
slug,
|
||||
excerpt: data.excerpt,
|
||||
content: data.content || '',
|
||||
status: data.status || 'draft',
|
||||
author: data.author,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
publishedAt: data.publishedAt,
|
||||
tags: data.tags || [],
|
||||
categories: data.categories || [],
|
||||
};
|
||||
|
||||
// Write to filesystem first
|
||||
const filePath = await this.writePostFile(post);
|
||||
const checksum = this.calculateChecksum(post.content);
|
||||
|
||||
// Then update database
|
||||
const dbPost: NewPost = {
|
||||
id: post.id,
|
||||
title: post.title,
|
||||
slug: post.slug,
|
||||
excerpt: post.excerpt,
|
||||
status: post.status,
|
||||
author: post.author,
|
||||
createdAt: post.createdAt,
|
||||
updatedAt: post.updatedAt,
|
||||
publishedAt: post.publishedAt,
|
||||
filePath,
|
||||
syncStatus: 'pending',
|
||||
checksum,
|
||||
tags: JSON.stringify(post.tags),
|
||||
categories: JSON.stringify(post.categories),
|
||||
};
|
||||
|
||||
await db.insert(posts).values(dbPost);
|
||||
|
||||
this.emit('postCreated', post);
|
||||
return post;
|
||||
}
|
||||
|
||||
async updatePost(id: string, data: Partial<PostData>): Promise<PostData | null> {
|
||||
const db = getDatabase().getLocal();
|
||||
const existing = await this.getPost(id);
|
||||
|
||||
if (!existing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const updated: PostData = {
|
||||
...existing,
|
||||
...data,
|
||||
id, // Ensure ID doesn't change
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
// Handle slug change - need to rename file
|
||||
if (data.slug && data.slug !== existing.slug) {
|
||||
const oldPath = path.join(this.postsDir, `${existing.slug}.md`);
|
||||
try {
|
||||
await fs.unlink(oldPath);
|
||||
} catch {
|
||||
// Old file might not exist
|
||||
}
|
||||
}
|
||||
|
||||
const filePath = await this.writePostFile(updated);
|
||||
const checksum = this.calculateChecksum(updated.content);
|
||||
|
||||
await db.update(posts)
|
||||
.set({
|
||||
title: updated.title,
|
||||
slug: updated.slug,
|
||||
excerpt: updated.excerpt,
|
||||
status: updated.status,
|
||||
author: updated.author,
|
||||
updatedAt: updated.updatedAt,
|
||||
publishedAt: updated.publishedAt,
|
||||
filePath,
|
||||
syncStatus: 'pending',
|
||||
checksum,
|
||||
tags: JSON.stringify(updated.tags),
|
||||
categories: JSON.stringify(updated.categories),
|
||||
})
|
||||
.where(eq(posts.id, id));
|
||||
|
||||
this.emit('postUpdated', updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
async deletePost(id: string): Promise<boolean> {
|
||||
const db = getDatabase().getLocal();
|
||||
const existing = await db.select().from(posts).where(eq(posts.id, id)).get();
|
||||
|
||||
if (!existing) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Delete file
|
||||
try {
|
||||
await fs.unlink(existing.filePath);
|
||||
} catch {
|
||||
// File might not exist
|
||||
}
|
||||
|
||||
// Delete from database
|
||||
await db.delete(posts).where(eq(posts.id, id));
|
||||
|
||||
this.emit('postDeleted', id);
|
||||
return true;
|
||||
}
|
||||
|
||||
async getPost(id: string): Promise<PostData | null> {
|
||||
const db = getDatabase().getLocal();
|
||||
const dbPost = await db.select().from(posts).where(eq(posts.id, id)).get();
|
||||
|
||||
if (!dbPost) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Read content from file
|
||||
const postData = await this.readPostFile(dbPost.filePath);
|
||||
|
||||
if (!postData) {
|
||||
// File doesn't exist, reconstruct from database
|
||||
return {
|
||||
id: dbPost.id,
|
||||
title: dbPost.title,
|
||||
slug: dbPost.slug,
|
||||
excerpt: dbPost.excerpt || undefined,
|
||||
content: '',
|
||||
status: dbPost.status as 'draft' | 'published' | 'archived',
|
||||
author: dbPost.author || undefined,
|
||||
createdAt: dbPost.createdAt,
|
||||
updatedAt: dbPost.updatedAt,
|
||||
publishedAt: dbPost.publishedAt || undefined,
|
||||
tags: JSON.parse(dbPost.tags || '[]'),
|
||||
categories: JSON.parse(dbPost.categories || '[]'),
|
||||
};
|
||||
}
|
||||
|
||||
return postData;
|
||||
}
|
||||
|
||||
async getAllPosts(): Promise<PostData[]> {
|
||||
const db = getDatabase().getLocal();
|
||||
const dbPosts = await db.select().from(posts).all();
|
||||
|
||||
const result: PostData[] = [];
|
||||
|
||||
for (const dbPost of dbPosts) {
|
||||
const postData = await this.getPost(dbPost.id);
|
||||
if (postData) {
|
||||
result.push(postData);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async getPostsByStatus(status: 'draft' | 'published' | 'archived'): Promise<PostData[]> {
|
||||
const db = getDatabase().getLocal();
|
||||
const dbPosts = await db.select().from(posts).where(eq(posts.status, status)).all();
|
||||
|
||||
const result: PostData[] = [];
|
||||
|
||||
for (const dbPost of dbPosts) {
|
||||
const postData = await this.getPost(dbPost.id);
|
||||
if (postData) {
|
||||
result.push(postData);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async publishPost(id: string): Promise<PostData | null> {
|
||||
return this.updatePost(id, {
|
||||
status: 'published',
|
||||
publishedAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
async unpublishPost(id: string): Promise<PostData | null> {
|
||||
return this.updatePost(id, {
|
||||
status: 'draft',
|
||||
publishedAt: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async rebuildDatabaseFromFiles(): Promise<void> {
|
||||
const task: Task<void> = {
|
||||
id: uuidv4(),
|
||||
name: 'Rebuild database from post files',
|
||||
execute: async (onProgress) => {
|
||||
const db = getDatabase().getLocal();
|
||||
|
||||
onProgress(0, 'Scanning posts directory...');
|
||||
|
||||
const files = await fs.readdir(this.postsDir);
|
||||
const mdFiles = files.filter(f => f.endsWith('.md'));
|
||||
|
||||
onProgress(10, `Found ${mdFiles.length} post files`);
|
||||
|
||||
for (let i = 0; i < mdFiles.length; i++) {
|
||||
const file = mdFiles[i];
|
||||
const filePath = path.join(this.postsDir, file);
|
||||
|
||||
onProgress(10 + (80 * (i / mdFiles.length)), `Processing ${file}...`);
|
||||
|
||||
const postData = await this.readPostFile(filePath);
|
||||
|
||||
if (postData) {
|
||||
const existing = await db.select().from(posts).where(eq(posts.id, postData.id)).get();
|
||||
const checksum = this.calculateChecksum(postData.content);
|
||||
|
||||
if (existing) {
|
||||
await db.update(posts)
|
||||
.set({
|
||||
title: postData.title,
|
||||
slug: postData.slug,
|
||||
excerpt: postData.excerpt,
|
||||
status: postData.status,
|
||||
author: postData.author,
|
||||
updatedAt: postData.updatedAt,
|
||||
publishedAt: postData.publishedAt,
|
||||
filePath,
|
||||
checksum,
|
||||
tags: JSON.stringify(postData.tags),
|
||||
categories: JSON.stringify(postData.categories),
|
||||
})
|
||||
.where(eq(posts.id, postData.id));
|
||||
} else {
|
||||
await db.insert(posts).values({
|
||||
id: postData.id,
|
||||
title: postData.title,
|
||||
slug: postData.slug,
|
||||
excerpt: postData.excerpt,
|
||||
status: postData.status,
|
||||
author: postData.author,
|
||||
createdAt: postData.createdAt,
|
||||
updatedAt: postData.updatedAt,
|
||||
publishedAt: postData.publishedAt,
|
||||
filePath,
|
||||
syncStatus: 'pending',
|
||||
checksum,
|
||||
tags: JSON.stringify(postData.tags),
|
||||
categories: JSON.stringify(postData.categories),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onProgress(100, 'Database rebuild complete');
|
||||
this.emit('databaseRebuilt');
|
||||
},
|
||||
};
|
||||
|
||||
await taskManager.runTask(task);
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let postEngine: PostEngine | null = null;
|
||||
|
||||
export function getPostEngine(): PostEngine {
|
||||
if (!postEngine) {
|
||||
postEngine = new PostEngine();
|
||||
}
|
||||
return postEngine;
|
||||
}
|
||||
324
src/main/engine/SyncEngine.ts
Normal file
324
src/main/engine/SyncEngine.ts
Normal file
@@ -0,0 +1,324 @@
|
||||
import { EventEmitter } from 'events';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { getDatabase } from '../database';
|
||||
import { syncLog, posts, media, NewSyncLogEntry } from '../database/schema';
|
||||
import { taskManager, Task } from './TaskManager';
|
||||
import { getPostEngine } from './PostEngine';
|
||||
import { getMediaEngine } from './MediaEngine';
|
||||
|
||||
export type SyncDirection = 'push' | 'pull' | 'bidirectional';
|
||||
export type SyncStatus = 'idle' | 'syncing' | 'error';
|
||||
|
||||
export interface SyncConfig {
|
||||
tursoUrl: string;
|
||||
tursoAuthToken: string;
|
||||
autoSync: boolean;
|
||||
syncInterval: number; // in minutes
|
||||
}
|
||||
|
||||
export interface SyncResult {
|
||||
success: boolean;
|
||||
pushed: number;
|
||||
pulled: number;
|
||||
conflicts: number;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export class SyncEngine extends EventEmitter {
|
||||
private syncStatus: SyncStatus = 'idle';
|
||||
private syncConfig: SyncConfig | null = null;
|
||||
private syncIntervalId: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
getSyncStatus(): SyncStatus {
|
||||
return this.syncStatus;
|
||||
}
|
||||
|
||||
isConfigured(): boolean {
|
||||
return this.syncConfig !== null &&
|
||||
!!this.syncConfig.tursoUrl &&
|
||||
!!this.syncConfig.tursoAuthToken;
|
||||
}
|
||||
|
||||
async configure(config: SyncConfig): Promise<void> {
|
||||
this.syncConfig = config;
|
||||
|
||||
// Stop existing auto-sync
|
||||
if (this.syncIntervalId) {
|
||||
clearInterval(this.syncIntervalId);
|
||||
this.syncIntervalId = null;
|
||||
}
|
||||
|
||||
// Start auto-sync if enabled
|
||||
if (config.autoSync && config.syncInterval > 0) {
|
||||
this.syncIntervalId = setInterval(
|
||||
() => this.sync('bidirectional'),
|
||||
config.syncInterval * 60 * 1000
|
||||
);
|
||||
}
|
||||
|
||||
// Initialize remote database connection
|
||||
const db = getDatabase();
|
||||
await db.initializeRemote();
|
||||
|
||||
this.emit('configured', config);
|
||||
}
|
||||
|
||||
async sync(direction: SyncDirection = 'bidirectional'): Promise<SyncResult> {
|
||||
if (!this.isConfigured()) {
|
||||
return {
|
||||
success: false,
|
||||
pushed: 0,
|
||||
pulled: 0,
|
||||
conflicts: 0,
|
||||
errors: ['Sync not configured'],
|
||||
};
|
||||
}
|
||||
|
||||
if (this.syncStatus === 'syncing') {
|
||||
return {
|
||||
success: false,
|
||||
pushed: 0,
|
||||
pulled: 0,
|
||||
conflicts: 0,
|
||||
errors: ['Sync already in progress'],
|
||||
};
|
||||
}
|
||||
|
||||
const task: Task<SyncResult> = {
|
||||
id: uuidv4(),
|
||||
name: `Sync (${direction})`,
|
||||
execute: async (onProgress) => {
|
||||
this.syncStatus = 'syncing';
|
||||
this.emit('syncStarted', direction);
|
||||
|
||||
const result: SyncResult = {
|
||||
success: true,
|
||||
pushed: 0,
|
||||
pulled: 0,
|
||||
conflicts: 0,
|
||||
errors: [],
|
||||
};
|
||||
|
||||
try {
|
||||
const db = getDatabase();
|
||||
const localDb = db.getLocal();
|
||||
const remoteDb = db.getRemote();
|
||||
|
||||
if (!remoteDb) {
|
||||
throw new Error('Remote database not initialized');
|
||||
}
|
||||
|
||||
onProgress(10, 'Fetching pending changes...');
|
||||
|
||||
if (direction === 'push' || direction === 'bidirectional') {
|
||||
// Get pending posts
|
||||
const pendingPosts = await localDb
|
||||
.select()
|
||||
.from(posts)
|
||||
.where(eq(posts.syncStatus, 'pending'))
|
||||
.all();
|
||||
|
||||
onProgress(20, `Pushing ${pendingPosts.length} posts...`);
|
||||
|
||||
for (const post of pendingPosts) {
|
||||
try {
|
||||
// Push to remote (simplified - in production would handle conflicts)
|
||||
await remoteDb.insert(posts).values(post).onConflictDoUpdate({
|
||||
target: posts.id,
|
||||
set: {
|
||||
title: post.title,
|
||||
slug: post.slug,
|
||||
excerpt: post.excerpt,
|
||||
status: post.status,
|
||||
author: post.author,
|
||||
updatedAt: post.updatedAt,
|
||||
publishedAt: post.publishedAt,
|
||||
checksum: post.checksum,
|
||||
tags: post.tags,
|
||||
categories: post.categories,
|
||||
},
|
||||
});
|
||||
|
||||
// Mark as synced locally
|
||||
await localDb
|
||||
.update(posts)
|
||||
.set({ syncStatus: 'synced', syncedAt: new Date() })
|
||||
.where(eq(posts.id, post.id));
|
||||
|
||||
result.pushed++;
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
|
||||
result.errors.push(`Failed to push post ${post.id}: ${errorMsg}`);
|
||||
|
||||
// Log the error
|
||||
await this.logSyncOperation(post.id, 'post', 'update', 'failed', errorMsg);
|
||||
}
|
||||
}
|
||||
|
||||
// Get pending media
|
||||
const pendingMedia = await localDb
|
||||
.select()
|
||||
.from(media)
|
||||
.where(eq(media.syncStatus, 'pending'))
|
||||
.all();
|
||||
|
||||
onProgress(50, `Pushing ${pendingMedia.length} media items...`);
|
||||
|
||||
for (const item of pendingMedia) {
|
||||
try {
|
||||
await remoteDb.insert(media).values(item).onConflictDoUpdate({
|
||||
target: media.id,
|
||||
set: {
|
||||
alt: item.alt,
|
||||
caption: item.caption,
|
||||
updatedAt: item.updatedAt,
|
||||
checksum: item.checksum,
|
||||
tags: item.tags,
|
||||
},
|
||||
});
|
||||
|
||||
await localDb
|
||||
.update(media)
|
||||
.set({ syncStatus: 'synced', syncedAt: new Date() })
|
||||
.where(eq(media.id, item.id));
|
||||
|
||||
result.pushed++;
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
|
||||
result.errors.push(`Failed to push media ${item.id}: ${errorMsg}`);
|
||||
|
||||
await this.logSyncOperation(item.id, 'media', 'update', 'failed', errorMsg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (direction === 'pull' || direction === 'bidirectional') {
|
||||
onProgress(70, 'Pulling remote changes...');
|
||||
|
||||
// In a real implementation, we would:
|
||||
// 1. Fetch all remote records with syncedAt > local last sync
|
||||
// 2. Compare checksums to detect conflicts
|
||||
// 3. Apply or merge changes
|
||||
|
||||
// For now, this is a placeholder
|
||||
onProgress(90, 'Pull complete');
|
||||
}
|
||||
|
||||
onProgress(100, 'Sync complete');
|
||||
|
||||
this.syncStatus = 'idle';
|
||||
this.emit('syncCompleted', result);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
|
||||
result.success = false;
|
||||
result.errors.push(errorMsg);
|
||||
|
||||
this.syncStatus = 'error';
|
||||
this.emit('syncFailed', errorMsg);
|
||||
|
||||
return result;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
return taskManager.runTask(task);
|
||||
}
|
||||
|
||||
private async logSyncOperation(
|
||||
entityId: string,
|
||||
entityType: 'post' | 'media',
|
||||
operation: 'create' | 'update' | 'delete',
|
||||
status: 'pending' | 'completed' | 'failed',
|
||||
errorMessage?: string
|
||||
): Promise<void> {
|
||||
const db = getDatabase().getLocal();
|
||||
|
||||
const logEntry: NewSyncLogEntry = {
|
||||
id: uuidv4(),
|
||||
entityType,
|
||||
entityId,
|
||||
operation,
|
||||
status,
|
||||
timestamp: new Date(),
|
||||
errorMessage,
|
||||
retryCount: 0,
|
||||
};
|
||||
|
||||
await db.insert(syncLog).values(logEntry);
|
||||
}
|
||||
|
||||
async getPendingChangesCount(): Promise<{ posts: number; media: number }> {
|
||||
const db = getDatabase().getLocal();
|
||||
|
||||
const pendingPosts = await db
|
||||
.select()
|
||||
.from(posts)
|
||||
.where(eq(posts.syncStatus, 'pending'))
|
||||
.all();
|
||||
|
||||
const pendingMedia = await db
|
||||
.select()
|
||||
.from(media)
|
||||
.where(eq(media.syncStatus, 'pending'))
|
||||
.all();
|
||||
|
||||
return {
|
||||
posts: pendingPosts.length,
|
||||
media: pendingMedia.length,
|
||||
};
|
||||
}
|
||||
|
||||
async getSyncLog(limit = 50): Promise<Array<{
|
||||
id: string;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
operation: string;
|
||||
status: string;
|
||||
timestamp: Date;
|
||||
errorMessage?: string;
|
||||
}>> {
|
||||
const db = getDatabase().getLocal();
|
||||
|
||||
const logs = await db
|
||||
.select()
|
||||
.from(syncLog)
|
||||
.orderBy(syncLog.timestamp)
|
||||
.limit(limit)
|
||||
.all();
|
||||
|
||||
return logs.map(log => ({
|
||||
id: log.id,
|
||||
entityType: log.entityType,
|
||||
entityId: log.entityId,
|
||||
operation: log.operation,
|
||||
status: log.status,
|
||||
timestamp: log.timestamp,
|
||||
errorMessage: log.errorMessage || undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
stopAutoSync(): void {
|
||||
if (this.syncIntervalId) {
|
||||
clearInterval(this.syncIntervalId);
|
||||
this.syncIntervalId = null;
|
||||
}
|
||||
this.emit('autoSyncStopped');
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let syncEngine: SyncEngine | null = null;
|
||||
|
||||
export function getSyncEngine(): SyncEngine {
|
||||
if (!syncEngine) {
|
||||
syncEngine = new SyncEngine();
|
||||
}
|
||||
return syncEngine;
|
||||
}
|
||||
166
src/main/engine/TaskManager.ts
Normal file
166
src/main/engine/TaskManager.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
export type TaskStatus = 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
|
||||
|
||||
export interface TaskProgress {
|
||||
taskId: string;
|
||||
status: TaskStatus;
|
||||
progress: number; // 0-100
|
||||
message: string;
|
||||
startTime: Date;
|
||||
endTime?: Date;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface Task<T = unknown> {
|
||||
id: string;
|
||||
name: string;
|
||||
execute: (onProgress: (progress: number, message: string) => void) => Promise<T>;
|
||||
cancel?: () => void;
|
||||
}
|
||||
|
||||
export class TaskManager extends EventEmitter {
|
||||
private tasks: Map<string, TaskProgress> = new Map();
|
||||
private runningTasks: Map<string, AbortController> = new Map();
|
||||
private maxConcurrentTasks = 3;
|
||||
private taskQueue: Task[] = [];
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
getTaskStatus(taskId: string): TaskProgress | undefined {
|
||||
return this.tasks.get(taskId);
|
||||
}
|
||||
|
||||
getAllTasks(): TaskProgress[] {
|
||||
return Array.from(this.tasks.values());
|
||||
}
|
||||
|
||||
getRunningTasks(): TaskProgress[] {
|
||||
return Array.from(this.tasks.values()).filter(t => t.status === 'running');
|
||||
}
|
||||
|
||||
async runTask<T>(task: Task<T>): Promise<T> {
|
||||
const progress: TaskProgress = {
|
||||
taskId: task.id,
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
message: 'Waiting to start...',
|
||||
startTime: new Date(),
|
||||
};
|
||||
|
||||
this.tasks.set(task.id, progress);
|
||||
this.emit('taskCreated', progress);
|
||||
|
||||
// Check if we can run immediately or need to queue
|
||||
if (this.runningTasks.size >= this.maxConcurrentTasks) {
|
||||
this.taskQueue.push(task as Task);
|
||||
this.emit('taskQueued', progress);
|
||||
|
||||
// Wait until we can run
|
||||
await new Promise<void>(resolve => {
|
||||
const checkQueue = () => {
|
||||
if (this.runningTasks.size < this.maxConcurrentTasks) {
|
||||
const queueIndex = this.taskQueue.findIndex(t => t.id === task.id);
|
||||
if (queueIndex !== -1) {
|
||||
this.taskQueue.splice(queueIndex, 1);
|
||||
}
|
||||
resolve();
|
||||
} else {
|
||||
setTimeout(checkQueue, 100);
|
||||
}
|
||||
};
|
||||
checkQueue();
|
||||
});
|
||||
}
|
||||
|
||||
const abortController = new AbortController();
|
||||
this.runningTasks.set(task.id, abortController);
|
||||
|
||||
progress.status = 'running';
|
||||
progress.message = 'Starting...';
|
||||
this.emit('taskStarted', progress);
|
||||
|
||||
try {
|
||||
const result = await task.execute((progressValue, message) => {
|
||||
if (abortController.signal.aborted) {
|
||||
throw new Error('Task cancelled');
|
||||
}
|
||||
progress.progress = progressValue;
|
||||
progress.message = message;
|
||||
this.emit('taskProgress', progress);
|
||||
});
|
||||
|
||||
progress.status = 'completed';
|
||||
progress.progress = 100;
|
||||
progress.message = 'Completed';
|
||||
progress.endTime = new Date();
|
||||
this.emit('taskCompleted', progress);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
if (errorMessage === 'Task cancelled') {
|
||||
progress.status = 'cancelled';
|
||||
progress.message = 'Cancelled';
|
||||
} else {
|
||||
progress.status = 'failed';
|
||||
progress.error = errorMessage;
|
||||
progress.message = `Failed: ${errorMessage}`;
|
||||
}
|
||||
|
||||
progress.endTime = new Date();
|
||||
this.emit('taskFailed', progress);
|
||||
|
||||
throw error;
|
||||
} finally {
|
||||
this.runningTasks.delete(task.id);
|
||||
this.processQueue();
|
||||
}
|
||||
}
|
||||
|
||||
cancelTask(taskId: string): boolean {
|
||||
const controller = this.runningTasks.get(taskId);
|
||||
if (controller) {
|
||||
controller.abort();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if in queue
|
||||
const queueIndex = this.taskQueue.findIndex(t => t.id === taskId);
|
||||
if (queueIndex !== -1) {
|
||||
this.taskQueue.splice(queueIndex, 1);
|
||||
const progress = this.tasks.get(taskId);
|
||||
if (progress) {
|
||||
progress.status = 'cancelled';
|
||||
progress.message = 'Cancelled (was queued)';
|
||||
progress.endTime = new Date();
|
||||
this.emit('taskCancelled', progress);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private processQueue(): void {
|
||||
if (this.taskQueue.length > 0 && this.runningTasks.size < this.maxConcurrentTasks) {
|
||||
// Queue processing happens automatically via the waiting promises
|
||||
this.emit('queueProcessing');
|
||||
}
|
||||
}
|
||||
|
||||
clearCompletedTasks(): void {
|
||||
for (const [id, task] of this.tasks) {
|
||||
if (task.status === 'completed' || task.status === 'failed' || task.status === 'cancelled') {
|
||||
this.tasks.delete(id);
|
||||
}
|
||||
}
|
||||
this.emit('tasksCleared');
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const taskManager = new TaskManager();
|
||||
4
src/main/engine/index.ts
Normal file
4
src/main/engine/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { TaskManager, taskManager, type Task, type TaskProgress, type TaskStatus } from './TaskManager';
|
||||
export { PostEngine, getPostEngine, type PostData } from './PostEngine';
|
||||
export { MediaEngine, getMediaEngine, type MediaData } from './MediaEngine';
|
||||
export { SyncEngine, getSyncEngine, type SyncConfig, type SyncResult, type SyncDirection, type SyncStatus } from './SyncEngine';
|
||||
Reference in New Issue
Block a user