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

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