feat: gallery macro
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
295
src/main/engine/PostMediaEngine.ts
Normal file
295
src/main/engine/PostMediaEngine.ts
Normal 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();
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user