feat: more feature implementations
This commit is contained in:
@@ -190,12 +190,22 @@ export class DatabaseConnection {
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS post_links (
|
||||
id TEXT PRIMARY KEY,
|
||||
source_post_id TEXT NOT NULL,
|
||||
target_post_id TEXT NOT NULL,
|
||||
link_text TEXT,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_posts_slug ON posts(slug);
|
||||
CREATE INDEX IF NOT EXISTS idx_posts_status ON posts(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_posts_sync_status ON posts(sync_status);
|
||||
CREATE INDEX IF NOT EXISTS idx_posts_created_at ON posts(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_media_sync_status ON media(sync_status);
|
||||
CREATE INDEX IF NOT EXISTS idx_sync_log_status ON sync_log(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_post_links_source ON post_links(source_post_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_post_links_target ON post_links(target_post_id);
|
||||
`);
|
||||
|
||||
// Check if project_id column exists in posts table, add if missing (migration)
|
||||
|
||||
@@ -72,6 +72,15 @@ export const settings = sqliteTable('settings', {
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||
});
|
||||
|
||||
// Post links - tracks internal links between posts
|
||||
export const postLinks = sqliteTable('post_links', {
|
||||
id: text('id').primaryKey(),
|
||||
sourcePostId: text('source_post_id').notNull(), // Post containing the link
|
||||
targetPostId: text('target_post_id').notNull(), // Post being linked to
|
||||
linkText: text('link_text'), // The text of the link
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||
});
|
||||
|
||||
// Types for TypeScript
|
||||
export type Project = typeof projects.$inferSelect;
|
||||
export type NewProject = typeof projects.$inferInsert;
|
||||
@@ -83,3 +92,5 @@ export type SyncLogEntry = typeof syncLog.$inferSelect;
|
||||
export type NewSyncLogEntry = typeof syncLog.$inferInsert;
|
||||
export type Setting = typeof settings.$inferSelect;
|
||||
export type NewSetting = typeof settings.$inferInsert;
|
||||
export type PostLink = typeof postLinks.$inferSelect;
|
||||
export type NewPostLink = typeof postLinks.$inferInsert;
|
||||
|
||||
@@ -7,6 +7,15 @@ import { eq } from 'drizzle-orm';
|
||||
import { app } from 'electron';
|
||||
import { getDatabase } from '../database';
|
||||
import { media, Media, NewMedia } from '../database/schema';
|
||||
|
||||
// Thumbnail sizes
|
||||
const THUMBNAIL_SIZES = {
|
||||
small: { width: 150, height: 150 },
|
||||
medium: { width: 400, height: 400 },
|
||||
large: { width: 800, height: 800 },
|
||||
} as const;
|
||||
|
||||
type ThumbnailSize = keyof typeof THUMBNAIL_SIZES;
|
||||
import { taskManager, Task } from './TaskManager';
|
||||
|
||||
export interface MediaData {
|
||||
@@ -88,6 +97,105 @@ export class MediaEngine extends EventEmitter {
|
||||
return crypto.createHash('md5').update(buffer).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the thumbnails directory for the current project
|
||||
*/
|
||||
private getThumbnailsDir(): string {
|
||||
const userDataPath = app.getPath('userData');
|
||||
return path.join(userDataPath, 'projects', this.currentProjectId, 'thumbnails');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate thumbnails for an image file
|
||||
*/
|
||||
async generateThumbnails(mediaId: string, sourcePath: string): Promise<Record<ThumbnailSize, string>> {
|
||||
const thumbnailsDir = this.getThumbnailsDir();
|
||||
await fs.mkdir(thumbnailsDir, { recursive: true });
|
||||
|
||||
const thumbnails: Record<ThumbnailSize, string> = {} as Record<ThumbnailSize, string>;
|
||||
|
||||
try {
|
||||
// Dynamic import of sharp (it's a native module)
|
||||
const sharp = (await import('sharp')).default;
|
||||
|
||||
for (const [size, dimensions] of Object.entries(THUMBNAIL_SIZES) as [ThumbnailSize, { width: number; height: number }][]) {
|
||||
const thumbnailPath = path.join(thumbnailsDir, `${mediaId}-${size}.webp`);
|
||||
|
||||
await sharp(sourcePath)
|
||||
.resize(dimensions.width, dimensions.height, {
|
||||
fit: 'inside',
|
||||
withoutEnlargement: true,
|
||||
})
|
||||
.webp({ quality: 80 })
|
||||
.toFile(thumbnailPath);
|
||||
|
||||
thumbnails[size] = thumbnailPath;
|
||||
}
|
||||
|
||||
this.emit('thumbnailsGenerated', { mediaId, thumbnails });
|
||||
} catch (error) {
|
||||
console.error('Failed to generate thumbnails:', error);
|
||||
// Return empty thumbnails on error - non-critical failure
|
||||
}
|
||||
|
||||
return thumbnails;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get existing thumbnail paths for a media item
|
||||
*/
|
||||
async getThumbnailPaths(mediaId: string): Promise<Record<ThumbnailSize, string | null>> {
|
||||
const thumbnailsDir = this.getThumbnailsDir();
|
||||
const result: Record<ThumbnailSize, string | null> = {
|
||||
small: null,
|
||||
medium: null,
|
||||
large: null,
|
||||
};
|
||||
|
||||
for (const size of Object.keys(THUMBNAIL_SIZES) as ThumbnailSize[]) {
|
||||
const thumbnailPath = path.join(thumbnailsDir, `${mediaId}-${size}.webp`);
|
||||
try {
|
||||
await fs.access(thumbnailPath);
|
||||
result[size] = thumbnailPath;
|
||||
} catch {
|
||||
// Thumbnail doesn't exist
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get thumbnail as base64 data URL for renderer
|
||||
*/
|
||||
async getThumbnailDataUrl(mediaId: string, size: ThumbnailSize = 'small'): Promise<string | null> {
|
||||
const thumbnailsDir = this.getThumbnailsDir();
|
||||
const thumbnailPath = path.join(thumbnailsDir, `${mediaId}-${size}.webp`);
|
||||
|
||||
try {
|
||||
const data = await fs.readFile(thumbnailPath);
|
||||
return `data:image/webp;base64,${data.toString('base64')}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete thumbnails for a media item
|
||||
*/
|
||||
private async deleteThumbnails(mediaId: string): Promise<void> {
|
||||
const thumbnailsDir = this.getThumbnailsDir();
|
||||
|
||||
for (const size of Object.keys(THUMBNAIL_SIZES) as ThumbnailSize[]) {
|
||||
const thumbnailPath = path.join(thumbnailsDir, `${mediaId}-${size}.webp`);
|
||||
try {
|
||||
await fs.unlink(thumbnailPath);
|
||||
} catch {
|
||||
// Thumbnail doesn't exist, ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async writeSidecarFile(mediaData: MediaData, mediaPath: string): Promise<string> {
|
||||
const sidecarPath = `${mediaPath}.meta`;
|
||||
|
||||
@@ -239,14 +347,30 @@ export class MediaEngine extends EventEmitter {
|
||||
// Copy file to media directory
|
||||
await fs.copyFile(sourcePath, destPath);
|
||||
|
||||
const mimeType = metadata?.mimeType || this.getMimeType(originalName);
|
||||
let width = metadata?.width;
|
||||
let height = metadata?.height;
|
||||
|
||||
// Get image dimensions using sharp if it's an image
|
||||
if (mimeType.startsWith('image/') && !mimeType.includes('svg')) {
|
||||
try {
|
||||
const sharp = (await import('sharp')).default;
|
||||
const imageMetadata = await sharp(destPath).metadata();
|
||||
width = imageMetadata.width;
|
||||
height = imageMetadata.height;
|
||||
} catch (error) {
|
||||
console.error('Failed to get image dimensions:', error);
|
||||
}
|
||||
}
|
||||
|
||||
const mediaData: MediaData = {
|
||||
id,
|
||||
filename,
|
||||
originalName,
|
||||
mimeType: metadata?.mimeType || this.getMimeType(originalName),
|
||||
mimeType,
|
||||
size: sourceBuffer.length,
|
||||
width: metadata?.width,
|
||||
height: metadata?.height,
|
||||
width,
|
||||
height,
|
||||
alt: metadata?.alt,
|
||||
caption: metadata?.caption,
|
||||
createdAt: now,
|
||||
@@ -257,6 +381,13 @@ export class MediaEngine extends EventEmitter {
|
||||
const sidecarPath = await this.writeSidecarFile(mediaData, destPath);
|
||||
const checksum = this.calculateChecksum(sourceBuffer);
|
||||
|
||||
// Generate thumbnails for images (async, non-blocking)
|
||||
if (mimeType.startsWith('image/') && !mimeType.includes('svg')) {
|
||||
this.generateThumbnails(id, destPath).catch(err => {
|
||||
console.error('Failed to generate thumbnails:', err);
|
||||
});
|
||||
}
|
||||
|
||||
const dbMedia: NewMedia = {
|
||||
id: mediaData.id,
|
||||
projectId: this.currentProjectId,
|
||||
@@ -339,6 +470,9 @@ export class MediaEngine extends EventEmitter {
|
||||
// File might not exist
|
||||
}
|
||||
|
||||
// Delete thumbnails
|
||||
await this.deleteThumbnails(id);
|
||||
|
||||
await db.delete(media).where(eq(media.id, id));
|
||||
|
||||
this.emit('mediaDeleted', id);
|
||||
|
||||
@@ -7,7 +7,7 @@ import matter from 'gray-matter';
|
||||
import { eq, and, desc, gte, lte, like } from 'drizzle-orm';
|
||||
import { app } from 'electron';
|
||||
import { getDatabase } from '../database';
|
||||
import { posts, Post, NewPost } from '../database/schema';
|
||||
import { posts, Post, NewPost, postLinks } from '../database/schema';
|
||||
import { taskManager, Task } from './TaskManager';
|
||||
|
||||
export interface PostData {
|
||||
@@ -289,6 +289,11 @@ export class PostEngine extends EventEmitter {
|
||||
});
|
||||
}
|
||||
|
||||
// Update post links if content changed
|
||||
if (data.content) {
|
||||
await this.updatePostLinks(id, updated.content);
|
||||
}
|
||||
|
||||
this.emit('postUpdated', updated);
|
||||
return updated;
|
||||
}
|
||||
@@ -635,6 +640,140 @@ export class PostEngine extends EventEmitter {
|
||||
|
||||
await taskManager.runTask(task);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract internal post links from content (links to other posts in the blog)
|
||||
*/
|
||||
extractInternalLinks(content: string): { slug: string; text: string }[] {
|
||||
const links: { slug: string; text: string }[] = [];
|
||||
|
||||
// Match markdown links: [text](/posts/slug) or [text](/year/month/slug)
|
||||
const markdownLinkRegex = /\[([^\]]+)\]\(\/(?:posts\/)?(?:\d{4}\/\d{2}\/)?([a-z0-9-]+)(?:\.html?)?\)/gi;
|
||||
let match;
|
||||
while ((match = markdownLinkRegex.exec(content)) !== null) {
|
||||
links.push({ text: match[1], slug: match[2] });
|
||||
}
|
||||
|
||||
// Match HTML links: <a href="/posts/slug">text</a>
|
||||
const htmlLinkRegex = /<a[^>]+href=["']\/(?:posts\/)?(?:\d{4}\/\d{2}\/)?([a-z0-9-]+)(?:\.html?)?["'][^>]*>([^<]+)<\/a>/gi;
|
||||
while ((match = htmlLinkRegex.exec(content)) !== null) {
|
||||
links.push({ text: match[2], slug: match[1] });
|
||||
}
|
||||
|
||||
return links;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update post links in the database based on content analysis
|
||||
*/
|
||||
async updatePostLinks(postId: string, content: string): Promise<void> {
|
||||
const db = getDatabase().getLocal();
|
||||
const extractedLinks = this.extractInternalLinks(content);
|
||||
|
||||
// Delete existing links from this post
|
||||
await db.delete(postLinks).where(eq(postLinks.sourcePostId, postId));
|
||||
|
||||
if (extractedLinks.length === 0) return;
|
||||
|
||||
// Get all posts to resolve slugs to IDs
|
||||
const allPosts = await db.select({ id: posts.id, slug: posts.slug })
|
||||
.from(posts)
|
||||
.where(eq(posts.projectId, this.currentProjectId));
|
||||
|
||||
const slugToId = new Map(allPosts.map(p => [p.slug, p.id]));
|
||||
|
||||
// Insert new links
|
||||
for (const link of extractedLinks) {
|
||||
const targetId = slugToId.get(link.slug);
|
||||
if (targetId && targetId !== postId) {
|
||||
await db.insert(postLinks).values({
|
||||
id: uuidv4(),
|
||||
sourcePostId: postId,
|
||||
targetPostId: targetId,
|
||||
linkText: link.text,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get posts that link TO the specified post ("linked by")
|
||||
*/
|
||||
async getLinkedBy(postId: string): Promise<{ id: string; title: string; slug: string }[]> {
|
||||
const db = getDatabase().getLocal();
|
||||
|
||||
const links = await db
|
||||
.select({
|
||||
sourcePostId: postLinks.sourcePostId,
|
||||
linkText: postLinks.linkText,
|
||||
})
|
||||
.from(postLinks)
|
||||
.where(eq(postLinks.targetPostId, postId));
|
||||
|
||||
if (links.length === 0) return [];
|
||||
|
||||
const sourceIds = links.map(l => l.sourcePostId);
|
||||
const sourcePosts = await db
|
||||
.select({ id: posts.id, title: posts.title, slug: posts.slug })
|
||||
.from(posts)
|
||||
.where(eq(posts.projectId, this.currentProjectId));
|
||||
|
||||
return sourcePosts.filter(p => sourceIds.includes(p.id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get posts that the specified post links TO ("links to")
|
||||
*/
|
||||
async getLinksTo(postId: string): Promise<{ id: string; title: string; slug: string }[]> {
|
||||
const db = getDatabase().getLocal();
|
||||
|
||||
const links = await db
|
||||
.select({
|
||||
targetPostId: postLinks.targetPostId,
|
||||
linkText: postLinks.linkText,
|
||||
})
|
||||
.from(postLinks)
|
||||
.where(eq(postLinks.sourcePostId, postId));
|
||||
|
||||
if (links.length === 0) return [];
|
||||
|
||||
const targetIds = links.map(l => l.targetPostId);
|
||||
const targetPosts = await db
|
||||
.select({ id: posts.id, title: posts.title, slug: posts.slug })
|
||||
.from(posts)
|
||||
.where(eq(posts.projectId, this.currentProjectId));
|
||||
|
||||
return targetPosts.filter(p => targetIds.includes(p.id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild all post links from content analysis
|
||||
*/
|
||||
async rebuildAllPostLinks(): Promise<void> {
|
||||
const db = getDatabase().getLocal();
|
||||
|
||||
// Clear all existing links
|
||||
await db.delete(postLinks);
|
||||
|
||||
// Get all posts
|
||||
const allPosts = await db
|
||||
.select({ id: posts.id, filePath: posts.filePath })
|
||||
.from(posts)
|
||||
.where(eq(posts.projectId, this.currentProjectId));
|
||||
|
||||
for (const post of allPosts) {
|
||||
try {
|
||||
const fileContent = await fs.readFile(post.filePath, 'utf-8');
|
||||
const { content } = matter(fileContent);
|
||||
await this.updatePostLinks(post.id, content);
|
||||
} catch (error) {
|
||||
console.error(`Failed to update links for post ${post.id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
this.emit('postLinksRebuilt');
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { ipcMain, dialog, shell } from 'electron';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { getPostEngine, PostData, PostFilter } from '../engine/PostEngine';
|
||||
import { getMediaEngine, MediaData } from '../engine/MediaEngine';
|
||||
import { getSyncEngine, SyncConfig, SyncDirection } from '../engine/SyncEngine';
|
||||
import { getProjectEngine, ProjectData } from '../engine/ProjectEngine';
|
||||
import { taskManager, TaskProgress } from '../engine/TaskManager';
|
||||
import { getDatabase } from '../database';
|
||||
import { media } from '../database/schema';
|
||||
|
||||
export function registerIpcHandlers(): void {
|
||||
// ============ Project Handlers ============
|
||||
@@ -126,6 +128,21 @@ export function registerIpcHandlers(): void {
|
||||
return engine.getPostsByYearMonth();
|
||||
});
|
||||
|
||||
ipcMain.handle('posts:getLinksTo', async (_, id: string) => {
|
||||
const engine = getPostEngine();
|
||||
return engine.getLinksTo(id);
|
||||
});
|
||||
|
||||
ipcMain.handle('posts:getLinkedBy', async (_, id: string) => {
|
||||
const engine = getPostEngine();
|
||||
return engine.getLinkedBy(id);
|
||||
});
|
||||
|
||||
ipcMain.handle('posts:rebuildLinks', async () => {
|
||||
const engine = getPostEngine();
|
||||
return engine.rebuildAllPostLinks();
|
||||
});
|
||||
|
||||
// ============ Media Handlers ============
|
||||
|
||||
ipcMain.handle('media:import', async (_, sourcePath: string, metadata?: Partial<MediaData>) => {
|
||||
@@ -187,6 +204,24 @@ export function registerIpcHandlers(): void {
|
||||
return engine.rebuildDatabaseFromFiles();
|
||||
});
|
||||
|
||||
ipcMain.handle('media:getThumbnail', async (_, id: string, size?: 'small' | 'medium' | 'large') => {
|
||||
const engine = getMediaEngine();
|
||||
return engine.getThumbnailDataUrl(id, size || 'small');
|
||||
});
|
||||
|
||||
ipcMain.handle('media:regenerateThumbnails', async (_, id: string) => {
|
||||
const engine = getMediaEngine();
|
||||
const mediaItem = await engine.getMedia(id);
|
||||
if (mediaItem && mediaItem.mimeType.startsWith('image/')) {
|
||||
const db = getDatabase().getLocal();
|
||||
const dbMedia = await db.select().from(media).where(eq(media.id, id)).get();
|
||||
if (dbMedia) {
|
||||
return engine.generateThumbnails(id, dbMedia.filePath);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
// ============ Sync Handlers ============
|
||||
|
||||
ipcMain.handle('sync:configure', async (_, config: SyncConfig) => {
|
||||
|
||||
@@ -30,6 +30,9 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
getTags: () => ipcRenderer.invoke('posts:getTags'),
|
||||
getCategories: () => ipcRenderer.invoke('posts:getCategories'),
|
||||
getByYearMonth: () => ipcRenderer.invoke('posts:getByYearMonth'),
|
||||
getLinksTo: (id: string) => ipcRenderer.invoke('posts:getLinksTo', id),
|
||||
getLinkedBy: (id: string) => ipcRenderer.invoke('posts:getLinkedBy', id),
|
||||
rebuildLinks: () => ipcRenderer.invoke('posts:rebuildLinks'),
|
||||
},
|
||||
|
||||
// Media
|
||||
@@ -41,6 +44,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
get: (id: string) => ipcRenderer.invoke('media:get', id),
|
||||
getAll: () => ipcRenderer.invoke('media:getAll'),
|
||||
rebuildFromFiles: () => ipcRenderer.invoke('media:rebuildFromFiles'),
|
||||
getThumbnail: (id: string, size?: 'small' | 'medium' | 'large') => ipcRenderer.invoke('media:getThumbnail', id, size),
|
||||
regenerateThumbnails: (id: string) => ipcRenderer.invoke('media:regenerateThumbnails', id),
|
||||
},
|
||||
|
||||
// Sync
|
||||
@@ -107,6 +112,9 @@ export interface ElectronAPI {
|
||||
getTags: () => Promise<string[]>;
|
||||
getCategories: () => Promise<string[]>;
|
||||
getByYearMonth: () => Promise<{ year: number; month: number; count: number }[]>;
|
||||
getLinksTo: (id: string) => Promise<{ id: string; title: string; slug: string }[]>;
|
||||
getLinkedBy: (id: string) => Promise<{ id: string; title: string; slug: string }[]>;
|
||||
rebuildLinks: () => Promise<void>;
|
||||
};
|
||||
media: {
|
||||
import: (sourcePath: string, metadata?: unknown) => Promise<unknown>;
|
||||
|
||||
Reference in New Issue
Block a user