feat: more feature implementations

This commit is contained in:
2026-02-10 13:40:44 +01:00
parent 867b22add0
commit 9f35e74d0f
33 changed files with 4560 additions and 130 deletions

View File

@@ -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)

View File

@@ -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;

View File

@@ -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);

View File

@@ -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

View File

@@ -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) => {

View File

@@ -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>;