feat: gallery macro

This commit is contained in:
2026-02-12 16:52:44 +01:00
parent 5c6fcb46ef
commit 924a165fb3
16 changed files with 1846 additions and 46 deletions

View File

@@ -182,6 +182,15 @@ export class DatabaseConnection {
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS post_media (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
post_id TEXT NOT NULL,
media_id TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
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);
@@ -190,6 +199,9 @@ export class DatabaseConnection {
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);
CREATE INDEX IF NOT EXISTS idx_post_media_post ON post_media(post_id);
CREATE INDEX IF NOT EXISTS idx_post_media_media ON post_media(media_id);
CREATE UNIQUE INDEX IF NOT EXISTS post_media_post_media_idx ON post_media(post_id, media_id);
CREATE UNIQUE INDEX IF NOT EXISTS posts_project_slug_idx ON posts(project_id, slug);
CREATE TABLE IF NOT EXISTS tags (

View File

@@ -96,6 +96,19 @@ export const postLinks = sqliteTable('post_links', {
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
});
// Post-Media links - tracks which media files are linked to which posts
export const postMedia = sqliteTable('post_media', {
id: text('id').primaryKey(),
projectId: text('project_id').notNull(),
postId: text('post_id').notNull(),
mediaId: text('media_id').notNull(),
sortOrder: integer('sort_order').notNull().default(0), // For ordering media within a post
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
}, (table) => ({
// Composite unique index: a media can only be linked once to a post
postMediaIdx: uniqueIndex('post_media_post_media_idx').on(table.postId, table.mediaId),
}));
// Tags table - stores tag metadata with optional colors
export const tags = sqliteTable('tags', {
id: text('id').primaryKey(),
@@ -143,6 +156,8 @@ export type Setting = typeof settings.$inferSelect;
export type NewSetting = typeof settings.$inferInsert;
export type PostLink = typeof postLinks.$inferSelect;
export type NewPostLink = typeof postLinks.$inferInsert;
export type PostMediaLink = typeof postMedia.$inferSelect;
export type NewPostMediaLink = typeof postMedia.$inferInsert;
export type Tag = typeof tags.$inferSelect;
export type NewTag = typeof tags.$inferInsert;
export type ChatConversation = typeof chatConversations.$inferSelect;

View File

@@ -31,6 +31,7 @@ export interface MediaData {
createdAt: Date;
updatedAt: Date;
tags: string[];
linkedPostIds?: string[]; // Posts this media is linked to
}
export interface MediaMetadata {
@@ -45,6 +46,7 @@ export interface MediaMetadata {
createdAt: string;
updatedAt: string;
tags: string[];
linkedPostIds?: string[]; // Posts this media is linked to (persisted in sidecar)
}
export class MediaEngine extends EventEmitter {
@@ -227,6 +229,7 @@ export class MediaEngine extends EventEmitter {
createdAt: mediaData.createdAt.toISOString(),
updatedAt: mediaData.updatedAt.toISOString(),
tags: mediaData.tags,
linkedPostIds: mediaData.linkedPostIds,
};
// Write YAML-like format consistent with posts
@@ -246,6 +249,9 @@ export class MediaEngine extends EventEmitter {
lines.push(`createdAt: ${metadata.createdAt}`);
lines.push(`updatedAt: ${metadata.updatedAt}`);
lines.push(`tags: [${metadata.tags.map(t => `"${t}"`).join(', ')}]`);
if (metadata.linkedPostIds && metadata.linkedPostIds.length > 0) {
lines.push(`linkedPostIds: [${metadata.linkedPostIds.map(id => `"${id}"`).join(', ')}]`);
}
lines.push('---');
await fs.writeFile(sidecarPath, lines.join('\n'), 'utf-8');
@@ -267,6 +273,7 @@ export class MediaEngine extends EventEmitter {
const metadata: Partial<MediaMetadata> = {
tags: [],
linkedPostIds: [],
};
for (const line of lines) {
@@ -316,14 +323,24 @@ export class MediaEngine extends EventEmitter {
break;
case 'tags':
// Parse array format: ["tag1", "tag2"]
const match = value.match(/\[(.*)\]/);
if (match) {
metadata.tags = match[1]
const tagsMatch = value.match(/\[(.*)\]/);
if (tagsMatch) {
metadata.tags = tagsMatch[1]
.split(',')
.map(t => t.trim().replace(/"/g, ''))
.filter(t => t.length > 0);
}
break;
case 'linkedPostIds':
// Parse array format: ["postId1", "postId2"]
const postIdsMatch = value.match(/\[(.*)\]/);
if (postIdsMatch) {
metadata.linkedPostIds = postIdsMatch[1]
.split(',')
.map(id => id.trim().replace(/"/g, ''))
.filter(id => id.length > 0);
}
break;
}
}

View File

@@ -0,0 +1,295 @@
/**
* PostMediaEngine
*
* Manages the relationship between posts and media files.
* Handles linking, unlinking, ordering, and querying post-media associations.
*
* Data is persisted in two places:
* 1. postMedia junction table in the database (for querying)
* 2. linkedPostIds field in media sidecar files (source of truth for rebuild)
*/
import { EventEmitter } from 'events';
import { v4 as uuidv4 } from 'uuid';
import { eq, and, asc } from 'drizzle-orm';
import { getDatabase } from '../database';
import { postMedia, PostMediaLink, NewPostMediaLink } from '../database/schema';
import { getMediaEngine, MediaData } from './MediaEngine';
export interface PostMediaLinkData {
id: string;
projectId: string;
postId: string;
mediaId: string;
sortOrder: number;
createdAt: Date;
}
// Singleton instance
let postMediaEngineInstance: PostMediaEngine | null = null;
export class PostMediaEngine extends EventEmitter {
private currentProjectId: string = 'default';
constructor() {
super();
}
/**
* Set the current project context
*/
setProjectContext(projectId: string): void {
this.currentProjectId = projectId;
console.log(`[PostMediaEngine] setProjectContext: projectId=${projectId}`);
}
/**
* Link a media file to a post
*/
async linkMediaToPost(postId: string, mediaId: string): Promise<PostMediaLinkData> {
const db = getDatabase().getLocal();
// Get current highest sortOrder for this post
const existingLinks = await this.getLinkedMediaForPost(postId);
const maxSortOrder = existingLinks.length > 0
? Math.max(...existingLinks.map(l => l.sortOrder))
: -1;
const now = new Date();
const link: NewPostMediaLink = {
id: uuidv4(),
projectId: this.currentProjectId,
postId,
mediaId,
sortOrder: maxSortOrder + 1,
createdAt: now,
};
await db.insert(postMedia).values(link);
// Update the media sidecar to include this post
const media = await getMediaEngine().getMedia(mediaId);
if (media) {
const linkedPostIds = media.linkedPostIds || [];
if (!linkedPostIds.includes(postId)) {
await getMediaEngine().updateMedia(mediaId, {
linkedPostIds: [...linkedPostIds, postId],
});
}
}
const linkData: PostMediaLinkData = {
id: link.id,
projectId: link.projectId,
postId: link.postId,
mediaId: link.mediaId,
sortOrder: link.sortOrder ?? 0,
createdAt: now,
};
this.emit('mediaLinked', linkData);
return linkData;
}
/**
* Unlink a media file from a post
*/
async unlinkMediaFromPost(postId: string, mediaId: string): Promise<void> {
const db = getDatabase().getLocal();
await db.delete(postMedia).where(
and(
eq(postMedia.postId, postId),
eq(postMedia.mediaId, mediaId)
)
);
// Update the media sidecar to remove this post
const media = await getMediaEngine().getMedia(mediaId);
if (media) {
const linkedPostIds = (media.linkedPostIds || []).filter(id => id !== postId);
await getMediaEngine().updateMedia(mediaId, { linkedPostIds });
}
this.emit('mediaUnlinked', { postId, mediaId });
}
/**
* Get all media linked to a post, ordered by sortOrder
*/
async getLinkedMediaForPost(postId: string): Promise<PostMediaLinkData[]> {
const db = getDatabase().getLocal();
const links = await db
.select()
.from(postMedia)
.where(
and(
eq(postMedia.projectId, this.currentProjectId),
eq(postMedia.postId, postId)
)
)
.orderBy(asc(postMedia.sortOrder));
return links.map(this.mapToLinkData);
}
/**
* Get all posts linked to a media file
*/
async getLinkedPostsForMedia(mediaId: string): Promise<PostMediaLinkData[]> {
const db = getDatabase().getLocal();
const links = await db
.select()
.from(postMedia)
.where(
and(
eq(postMedia.projectId, this.currentProjectId),
eq(postMedia.mediaId, mediaId)
)
);
return links.map(this.mapToLinkData);
}
/**
* Reorder media within a post
* @param postId The post ID
* @param mediaIds Array of media IDs in the new desired order
*/
async reorderMediaForPost(postId: string, mediaIds: string[]): Promise<void> {
const db = getDatabase().getLocal();
// Update each media's sortOrder based on its position in the array
for (let i = 0; i < mediaIds.length; i++) {
await db.update(postMedia)
.set({ sortOrder: i })
.where(
and(
eq(postMedia.postId, postId),
eq(postMedia.mediaId, mediaIds[i])
)
);
}
this.emit('mediaReordered', { postId, mediaIds });
}
/**
* Rebuild the junction table from media sidecar files.
* This is called during rebuild operations when the database needs to be
* reconstructed from filesystem source of truth.
*/
async rebuildFromSidecars(): Promise<void> {
const db = getDatabase().getLocal();
console.log('[PostMediaEngine] Rebuilding post-media links from sidecars...');
// Clear existing links for this project
await db.delete(postMedia).where(eq(postMedia.projectId, this.currentProjectId));
// Get all media with their linkedPostIds
const allMedia = await getMediaEngine().getAllMedia();
let linksCreated = 0;
for (const media of allMedia) {
const linkedPostIds = media.linkedPostIds || [];
for (let i = 0; i < linkedPostIds.length; i++) {
const postId = linkedPostIds[i];
const linkId = uuidv4();
await db.insert(postMedia).values({
id: linkId,
projectId: this.currentProjectId,
postId,
mediaId: media.id,
sortOrder: i, // Preserve order from sidecar
createdAt: new Date(),
});
linksCreated++;
}
}
console.log(`[PostMediaEngine] Rebuilt ${linksCreated} post-media links`);
this.emit('rebuilt', { linksCreated });
}
/**
* Import media from a file path and link it to a post.
* This is a convenience method that combines import + link.
*/
async importMediaForPost(postId: string, sourcePath: string): Promise<PostMediaLinkData> {
// Import the media file
const importedMedia = await getMediaEngine().importMedia(sourcePath);
// Link it to the post
return this.linkMediaToPost(postId, importedMedia.id);
}
/**
* Get linked media with full media data
*/
async getLinkedMediaDataForPost(postId: string): Promise<Array<PostMediaLinkData & { media: MediaData }>> {
const links = await this.getLinkedMediaForPost(postId);
const result: Array<PostMediaLinkData & { media: MediaData }> = [];
for (const link of links) {
const media = await getMediaEngine().getMedia(link.mediaId);
if (media) {
result.push({ ...link, media });
}
}
return result;
}
/**
* Check if a media is linked to a post
*/
async isMediaLinkedToPost(postId: string, mediaId: string): Promise<boolean> {
const db = getDatabase().getLocal();
const link = await db
.select()
.from(postMedia)
.where(
and(
eq(postMedia.postId, postId),
eq(postMedia.mediaId, mediaId)
)
)
.limit(1);
return link.length > 0;
}
/**
* Map database row to PostMediaLinkData
*/
private mapToLinkData(row: PostMediaLink): PostMediaLinkData {
return {
id: row.id,
projectId: row.projectId,
postId: row.postId,
mediaId: row.mediaId,
sortOrder: row.sortOrder,
createdAt: row.createdAt,
};
}
}
/**
* Get the singleton PostMediaEngine instance
*/
export function getPostMediaEngine(): PostMediaEngine {
if (!postMediaEngineInstance) {
postMediaEngineInstance = new PostMediaEngine();
}
return postMediaEngineInstance;
}
// Export singleton for convenience
export const postMediaEngine = getPostMediaEngine();

View File

@@ -1,6 +1,7 @@
export { TaskManager, taskManager, type Task, type TaskProgress, type TaskStatus } from './TaskManager';
export { PostEngine, getPostEngine, type PostData, type PostFilter, type SearchResult, type PaginatedResult, type PaginationOptions } from './PostEngine';
export { MediaEngine, getMediaEngine, type MediaData } from './MediaEngine';
export { PostMediaEngine, getPostMediaEngine, postMediaEngine, type PostMediaLinkData } from './PostMediaEngine';
export { SyncEngine, getSyncEngine, type SyncConfig, type SyncResult, type SyncDirection, type SyncStatus } from './SyncEngine';
export { ProjectEngine, getProjectEngine, type ProjectData } from './ProjectEngine';
export { MetaEngine, getMetaEngine, type ProjectMetadata, DEFAULT_CATEGORIES } from './MetaEngine';

View File

@@ -7,6 +7,7 @@ import { getDropboxSyncEngine, DropboxSyncConfig, ConflictResolution } from '../
import { getProjectEngine, ProjectData } from '../engine/ProjectEngine';
import { getMetaEngine } from '../engine/MetaEngine';
import { getTagEngine } from '../engine/TagEngine';
import { getPostMediaEngine } from '../engine/PostMediaEngine';
import { taskManager, TaskProgress } from '../engine/TaskManager';
import { getDatabase } from '../database';
import { media } from '../database/schema';
@@ -77,6 +78,8 @@ export function registerIpcHandlers(): void {
mediaEngine.setProjectContext(project.id, dataDir, internalDir);
metaEngine.setProjectContext(project.id);
tagEngine.setProjectContext(project.id);
const postMediaEngine = getPostMediaEngine();
postMediaEngine.setProjectContext(project.id);
// Sync meta on startup
await metaEngine.syncOnStartup();
@@ -101,6 +104,8 @@ export function registerIpcHandlers(): void {
mediaEngine.setProjectContext(project.id, dataDir, internalDir);
metaEngine.setProjectContext(project.id);
tagEngine.setProjectContext(project.id);
const postMediaEngine = getPostMediaEngine();
postMediaEngine.setProjectContext(project.id);
// Sync meta on project switch
await metaEngine.syncOnStartup();
@@ -668,6 +673,53 @@ export function registerIpcHandlers(): void {
return engine.syncTagsFromPosts();
});
// ============ Post-Media Link Handlers ============
safeHandle('postMedia:link', async (_, postId: string, mediaId: string) => {
const engine = getPostMediaEngine();
return engine.linkMediaToPost(postId, mediaId);
});
safeHandle('postMedia:unlink', async (_, postId: string, mediaId: string) => {
const engine = getPostMediaEngine();
return engine.unlinkMediaFromPost(postId, mediaId);
});
safeHandle('postMedia:getForPost', async (_, postId: string) => {
const engine = getPostMediaEngine();
return engine.getLinkedMediaForPost(postId);
});
safeHandle('postMedia:getForMedia', async (_, mediaId: string) => {
const engine = getPostMediaEngine();
return engine.getLinkedPostsForMedia(mediaId);
});
safeHandle('postMedia:getMediaDataForPost', async (_, postId: string) => {
const engine = getPostMediaEngine();
return engine.getLinkedMediaDataForPost(postId);
});
safeHandle('postMedia:reorder', async (_, postId: string, mediaIds: string[]) => {
const engine = getPostMediaEngine();
return engine.reorderMediaForPost(postId, mediaIds);
});
safeHandle('postMedia:isLinked', async (_, postId: string, mediaId: string) => {
const engine = getPostMediaEngine();
return engine.isMediaLinkedToPost(postId, mediaId);
});
safeHandle('postMedia:import', async (_, postId: string, filePath: string) => {
const engine = getPostMediaEngine();
return engine.importMediaForPost(postId, filePath);
});
safeHandle('postMedia:rebuild', async () => {
const engine = getPostMediaEngine();
return engine.rebuildFromSidecars();
});
// ============ Event Forwarding ============
// Forward engine events to renderer
@@ -677,6 +729,7 @@ export function registerIpcHandlers(): void {
const projectEngine = getProjectEngine();
const metaEngine = getMetaEngine();
const tagEngine = getTagEngine();
const postMediaEngine = getPostMediaEngine();
const forwardEvent = (eventName: string) => {
return (...args: unknown[]) => {
@@ -713,6 +766,11 @@ export function registerIpcHandlers(): void {
tagEngine.on('tagsMerged', forwardEvent('tags:merged'));
tagEngine.on('tagsSynced', forwardEvent('tags:synced'));
postMediaEngine.on('mediaLinked', forwardEvent('postMedia:linked'));
postMediaEngine.on('mediaUnlinked', forwardEvent('postMedia:unlinked'));
postMediaEngine.on('mediaReordered', forwardEvent('postMedia:reordered'));
postMediaEngine.on('rebuilt', forwardEvent('postMedia:rebuilt'));
syncEngine.on('syncStarted', forwardEvent('sync:started'));
syncEngine.on('syncCompleted', forwardEvent('sync:completed'));
syncEngine.on('syncFailed', forwardEvent('sync:failed'));

View File

@@ -59,6 +59,19 @@ contextBridge.exposeInMainWorld('electronAPI', {
regenerateMissingThumbnails: () => ipcRenderer.invoke('media:regenerateMissingThumbnails'),
},
// Post-Media Links
postMedia: {
link: (postId: string, mediaId: string) => ipcRenderer.invoke('postMedia:link', postId, mediaId),
unlink: (postId: string, mediaId: string) => ipcRenderer.invoke('postMedia:unlink', postId, mediaId),
getForPost: (postId: string) => ipcRenderer.invoke('postMedia:getForPost', postId),
getForMedia: (mediaId: string) => ipcRenderer.invoke('postMedia:getForMedia', mediaId),
getMediaDataForPost: (postId: string) => ipcRenderer.invoke('postMedia:getMediaDataForPost', postId),
reorder: (postId: string, mediaIds: string[]) => ipcRenderer.invoke('postMedia:reorder', postId, mediaIds),
isLinked: (postId: string, mediaId: string) => ipcRenderer.invoke('postMedia:isLinked', postId, mediaId),
import: (postId: string, filePath: string) => ipcRenderer.invoke('postMedia:import', postId, filePath),
rebuild: () => ipcRenderer.invoke('postMedia:rebuild'),
},
// Sync
sync: {
configure: (config: unknown) => ipcRenderer.invoke('sync:configure', config),