Files
bDS/src/main/engine/PostMediaEngine.ts
2026-02-12 16:52:44 +01:00

296 lines
7.9 KiB
TypeScript

/**
* 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();