fix: better updating of links from photo_album

This commit is contained in:
2026-02-14 22:23:41 +01:00
parent 7a1d15d256
commit 51f58d608d
8 changed files with 344 additions and 37 deletions

View File

@@ -114,6 +114,102 @@ export class PostMediaEngine extends EventEmitter {
this.emit('mediaUnlinked', { postId, mediaId });
}
/**
* Batch link multiple media files to a post.
* Only emits a single event at the end instead of per-item events.
* Skips media that are already linked.
*/
async linkManyToPost(postId: string, mediaIds: string[]): Promise<{ linked: string[]; skipped: string[] }> {
const db = getDatabase().getLocal();
const linked: string[] = [];
const skipped: string[] = [];
// Get all existing links for this post to check what's already linked
const existingLinks = await this.getLinkedMediaForPost(postId);
const existingMediaIds = new Set(existingLinks.map(l => l.mediaId));
let maxSortOrder = existingLinks.length > 0
? Math.max(...existingLinks.map(l => l.sortOrder))
: -1;
const now = new Date();
for (const mediaId of mediaIds) {
// Skip if already linked
if (existingMediaIds.has(mediaId)) {
skipped.push(mediaId);
continue;
}
maxSortOrder++;
const link: NewPostMediaLink = {
id: uuidv4(),
projectId: this.currentProjectId,
postId,
mediaId,
sortOrder: maxSortOrder,
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],
});
}
}
linked.push(mediaId);
existingMediaIds.add(mediaId); // Track to avoid duplicates within batch
}
// Emit a single batch event instead of per-item events
if (linked.length > 0) {
this.emit('mediaBatchLinked', { postId, mediaIds: linked });
}
return { linked, skipped };
}
/**
* Batch unlink multiple media files from a post.
* Only emits a single event at the end instead of per-item events.
*/
async unlinkManyFromPost(postId: string, mediaIds: string[]): Promise<{ unlinked: string[] }> {
const db = getDatabase().getLocal();
const unlinked: string[] = [];
for (const mediaId of mediaIds) {
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 });
}
unlinked.push(mediaId);
}
// Emit a single batch event instead of per-item events
if (unlinked.length > 0) {
this.emit('mediaBatchUnlinked', { postId, mediaIds: unlinked });
}
return { unlinked };
}
/**
* Get all media linked to a post, ordered by sortOrder
*/

View File

@@ -640,6 +640,16 @@ export function registerIpcHandlers(): void {
return engine.unlinkMediaFromPost(postId, mediaId);
});
safeHandle('postMedia:linkMany', async (_, postId: string, mediaIds: string[]) => {
const engine = getPostMediaEngine();
return engine.linkManyToPost(postId, mediaIds);
});
safeHandle('postMedia:unlinkMany', async (_, postId: string, mediaIds: string[]) => {
const engine = getPostMediaEngine();
return engine.unlinkManyFromPost(postId, mediaIds);
});
safeHandle('postMedia:getForPost', async (_, postId: string) => {
const engine = getPostMediaEngine();
return engine.getLinkedMediaForPost(postId);
@@ -982,6 +992,8 @@ export function registerIpcHandlers(): void {
postMediaEngine.on('mediaLinked', forwardEvent('postMedia:linked'));
postMediaEngine.on('mediaUnlinked', forwardEvent('postMedia:unlinked'));
postMediaEngine.on('mediaBatchLinked', forwardEvent('postMedia:batchLinked'));
postMediaEngine.on('mediaBatchUnlinked', forwardEvent('postMedia:batchUnlinked'));
postMediaEngine.on('mediaReordered', forwardEvent('postMedia:reordered'));
postMediaEngine.on('rebuilt', forwardEvent('postMedia:rebuilt'));

View File

@@ -69,6 +69,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
postMedia: {
link: (postId: string, mediaId: string) => ipcRenderer.invoke('postMedia:link', postId, mediaId),
unlink: (postId: string, mediaId: string) => ipcRenderer.invoke('postMedia:unlink', postId, mediaId),
linkMany: (postId: string, mediaIds: string[]) => ipcRenderer.invoke('postMedia:linkMany', postId, mediaIds),
unlinkMany: (postId: string, mediaIds: string[]) => ipcRenderer.invoke('postMedia:unlinkMany', postId, mediaIds),
getForPost: (postId: string) => ipcRenderer.invoke('postMedia:getForPost', postId),
getForMedia: (mediaId: string) => ipcRenderer.invoke('postMedia:getForMedia', mediaId),
getMediaDataForPost: (postId: string) => ipcRenderer.invoke('postMedia:getMediaDataForPost', postId),

View File

@@ -288,10 +288,8 @@ const hydratePhotoArchive = async (
// No photo_archive macros - unlink any previously linked and clear state
const previouslyLinked = loadPreviouslyLinkedIds(postId);
if (previouslyLinked.size > 0) {
console.log(`[photo_archive] No macros found, unlinking ${previouslyLinked.size} previously linked media`);
for (const mediaId of previouslyLinked) {
await window.electronAPI?.postMedia.unlink(postId, mediaId);
}
console.log(`[photo_archive] No macros found, batch unlinking ${previouslyLinked.size} previously linked media`);
await window.electronAPI?.postMedia.unlinkMany(postId, Array.from(previouslyLinked));
localStorage.removeItem(getPhotoArchiveLinkedKey(postId));
}
return;
@@ -444,19 +442,31 @@ const doHydratePhotoArchive = async (
console.log(`[photo_archive] Should link ${shouldBeLinkedIds.size} media IDs`);
// Phase 2: Unlink media that was previously linked but is no longer needed
// Simple set difference: previouslyLinkedIds - shouldBeLinkedIds
// Phase 2: Batch unlink media that was previously linked but is no longer needed
const idsToUnlink: string[] = [];
for (const mediaId of previouslyLinkedIds) {
if (!shouldBeLinkedIds.has(mediaId)) {
console.log(`[photo_archive] Unlinking ${mediaId} - no longer in range`);
await window.electronAPI?.postMedia.unlink(postId, mediaId);
idsToUnlink.push(mediaId);
}
}
if (idsToUnlink.length > 0) {
console.log(`[photo_archive] Batch unlinking ${idsToUnlink.length} media items`);
await window.electronAPI?.postMedia.unlinkMany(postId, idsToUnlink);
}
// Save current linked IDs for next hydration
saveLinkedIds(postId, shouldBeLinkedIds);
// Phase 3: Link new media and render
// Phase 3: Batch link all media that should be linked and render
// Use linkMany which internally skips already linked items
const idsToLink = Array.from(shouldBeLinkedIds);
if (idsToLink.length > 0) {
console.log(`[photo_archive] Batch linking ${idsToLink.length} media items`);
await window.electronAPI?.postMedia.linkMany(postId, idsToLink);
}
// Phase 4: Render galleries (no more link/unlink calls here)
for (const { element, mode, year, month, images, monthlyImages, showYearInLabel } of archiveData) {
const archiveContainer = element.querySelector('.photo-archive-container');
if (!archiveContainer) continue;
@@ -467,14 +477,6 @@ const doHydratePhotoArchive = async (
if (mode === 'single-month' && month !== undefined && images && year) {
// Single month view
// Link images to the post
for (const img of images) {
const isLinked = await window.electronAPI?.postMedia.isLinked(postId, img.id);
if (!isLinked) {
await window.electronAPI?.postMedia.link(postId, img.id);
}
}
if (images.length === 0) {
archiveContainer.innerHTML = `<div class="photo-archive-empty">No photos found for ${FULL_MONTH_NAMES[month - 1]} ${year}</div>`;
continue;
@@ -482,16 +484,6 @@ const doHydratePhotoArchive = async (
html = buildMonthGallery(month, year, images, onImageClick, false);
} else if (mode === 'recent' && monthlyImages) {
// Recent mode - keys are "YYYY-MM" strings
// Link all images to the post
for (const imgs of monthlyImages.values()) {
for (const img of imgs) {
const isLinked = await window.electronAPI?.postMedia.isLinked(postId, img.id);
if (!isLinked) {
await window.electronAPI?.postMedia.link(postId, img.id);
}
}
}
if (monthlyImages.size === 0) {
archiveContainer.innerHTML = `<div class="photo-archive-empty">No recent photos found</div>`;
continue;
@@ -510,16 +502,6 @@ const doHydratePhotoArchive = async (
}).join('');
} else if (mode === 'full-year' && monthlyImages && year) {
// Full year view - keys are month numbers
// Link all images to the post
for (const imgs of monthlyImages.values()) {
for (const img of imgs) {
const isLinked = await window.electronAPI?.postMedia.isLinked(postId, img.id);
if (!isLinked) {
await window.electronAPI?.postMedia.link(postId, img.id);
}
}
}
if (monthlyImages.size === 0) {
archiveContainer.innerHTML = `<div class="photo-archive-empty">No photos found for ${year}</div>`;
continue;

View File

@@ -78,12 +78,31 @@ export const LinkedMediaPanel: React.FC<LinkedMediaPanelProps> = ({
}
};
// Also handle batch events (single refresh for multiple link/unlink operations)
const handleBatchLinked = (...args: unknown[]) => {
const data = args[0] as { postId: string; mediaIds: string[] } | undefined;
if (data?.postId === postId) {
loadLinkedMedia();
}
};
const handleBatchUnlinked = (...args: unknown[]) => {
const data = args[0] as { postId: string; mediaIds: string[] } | undefined;
if (data?.postId === postId) {
loadLinkedMedia();
}
};
const unsubLinked = window.electronAPI?.on('postMedia:linked', handleLinked);
const unsubUnlinked = window.electronAPI?.on('postMedia:unlinked', handleUnlinked);
const unsubBatchLinked = window.electronAPI?.on('postMedia:batchLinked', handleBatchLinked);
const unsubBatchUnlinked = window.electronAPI?.on('postMedia:batchUnlinked', handleBatchUnlinked);
return () => {
unsubLinked?.();
unsubUnlinked?.();
unsubBatchLinked?.();
unsubBatchUnlinked?.();
};
}, [postId, loadLinkedMedia]);

View File

@@ -319,6 +319,8 @@ export interface ElectronAPI {
postMedia: {
link: (postId: string, mediaId: string) => Promise<MediaLinkData>;
unlink: (postId: string, mediaId: string) => Promise<void>;
linkMany: (postId: string, mediaIds: string[]) => Promise<{ linked: string[]; skipped: string[] }>;
unlinkMany: (postId: string, mediaIds: string[]) => Promise<{ unlinked: string[] }>;
getForPost: (postId: string) => Promise<MediaLinkData[]>;
getForMedia: (mediaId: string) => Promise<MediaLinkData[]>;
getMediaDataForPost: (postId: string) => Promise<Array<MediaLinkData & { media: MediaData }>>;