fix: removed unneeded linkage for media from photo album pages

This commit is contained in:
2026-02-16 06:03:56 +01:00
parent 60a291bedb
commit 52aed4c420
4 changed files with 33 additions and 114 deletions

View File

@@ -242,77 +242,34 @@ const FULL_MONTH_NAMES = [
]; ];
// Track photo_archive hydration state to prevent duplicate runs // Track photo_archive hydration state to prevent duplicate runs
const photoArchiveHydratingCache = new Map<string, boolean>(); const photoArchiveHydratingCache = new WeakSet<HTMLElement>();
/**
* Get the storage key for photo_archive linked media IDs for a post
*/
function getPhotoArchiveLinkedKey(postId: string): string {
return `photoArchive:${postId}:linkedIds`;
}
/**
* Load previously linked media IDs from localStorage
*/
function loadPreviouslyLinkedIds(postId: string): Set<string> {
try {
const stored = localStorage.getItem(getPhotoArchiveLinkedKey(postId));
if (stored) {
const ids = JSON.parse(stored) as string[];
return new Set(ids);
}
} catch {
// Ignore parse errors
}
return new Set();
}
/**
* Save currently linked media IDs to localStorage
*/
function saveLinkedIds(postId: string, ids: Set<string>): void {
try {
localStorage.setItem(getPhotoArchiveLinkedKey(postId), JSON.stringify([...ids]));
} catch {
// Ignore storage errors
}
}
/** /**
* Hydrate photo_archive elements in the preview with actual media from the given year/month. * Hydrate photo_archive elements in the preview with actual media from the given year/month.
* Also manages linking/unlinking of media based on what the macros cover.
*/ */
const hydratePhotoArchive = async ( const hydratePhotoArchive = async (
container: HTMLElement, container: HTMLElement,
postId: string,
onImageClick: (index: number, images: { src: string; alt: string }[]) => void onImageClick: (index: number, images: { src: string; alt: string }[]) => void
) => { ) => {
// Match both year-based and recent-based archives // Match both year-based and recent-based archives
const archives = container.querySelectorAll('.macro-photo-archive[data-year], .macro-photo-archive[data-recent]'); const archives = container.querySelectorAll('.macro-photo-archive[data-year], .macro-photo-archive[data-recent]');
if (archives.length === 0) { if (archives.length === 0) {
// 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, batch unlinking ${previouslyLinked.size} previously linked media`);
await window.electronAPI?.postMedia.unlinkMany(postId, Array.from(previouslyLinked));
localStorage.removeItem(getPhotoArchiveLinkedKey(postId));
}
return; return;
} }
// Check if we're already hydrating (prevent duplicate runs) // Check if we're already hydrating (prevent duplicate runs)
if (photoArchiveHydratingCache.get(postId)) { if (photoArchiveHydratingCache.has(container)) {
console.log(`[photo_archive] Skipping duplicate hydration for ${postId}`); console.log('[photo_archive] Skipping duplicate hydration for container');
return; return;
} }
photoArchiveHydratingCache.set(postId, true); photoArchiveHydratingCache.add(container);
try { try {
await doHydratePhotoArchive(container, postId, onImageClick, archives); await doHydratePhotoArchive(container, onImageClick, archives);
} finally { } finally {
// Clear the hydrating flag after a delay to allow for content changes // Clear the hydrating flag after a delay to allow for content changes
setTimeout(() => photoArchiveHydratingCache.delete(postId), 500); setTimeout(() => photoArchiveHydratingCache.delete(container), 500);
} }
}; };
@@ -321,15 +278,10 @@ const hydratePhotoArchive = async (
*/ */
const doHydratePhotoArchive = async ( const doHydratePhotoArchive = async (
_container: HTMLElement, _container: HTMLElement,
postId: string,
onImageClick: (index: number, images: { src: string; alt: string }[]) => void, onImageClick: (index: number, images: { src: string; alt: string }[]) => void,
archives: NodeListOf<Element> archives: NodeListOf<Element>
) => { ) => {
// Load previously linked IDs to detect what needs unlinking // Collect media for archive rendering based on current macros.
const previouslyLinkedIds = loadPreviouslyLinkedIds(postId);
// Phase 1: Collect all media IDs that should be linked based on current macros
const shouldBeLinkedIds = new Set<string>();
type ImageData = { id: string; originalName: string; alt?: string; mimeType: string; createdAt?: Date }; type ImageData = { id: string; originalName: string; alt?: string; mimeType: string; createdAt?: Date };
const archiveData: Array<{ const archiveData: Array<{
element: Element; element: Element;
@@ -342,7 +294,7 @@ const doHydratePhotoArchive = async (
showYearInLabel?: boolean; showYearInLabel?: boolean;
}> = []; }> = [];
console.log(`[photo_archive] Processing ${archives.length} archive macro(s), previously linked: ${previouslyLinkedIds.size} IDs`); console.log(`[photo_archive] Processing ${archives.length} archive macro(s)`);
for (const archive of archives) { for (const archive of archives) {
const recentStr = archive.getAttribute('data-recent'); const recentStr = archive.getAttribute('data-recent');
@@ -371,21 +323,15 @@ const doHydratePhotoArchive = async (
monthlyMap.set(key, []); monthlyMap.set(key, []);
} }
monthlyMap.get(key)!.push(img); monthlyMap.get(key)!.push(img);
shouldBeLinkedIds.add(img.id);
} }
// Sort by key descending (newest first) and take top N // Sort by key descending (newest first) and take top N
const sortedKeys = Array.from(monthlyMap.keys()).sort().reverse().slice(0, recentCount); const sortedKeys = Array.from(monthlyMap.keys()).sort().reverse().slice(0, recentCount);
const recentMonthlyImages = new Map<string, ImageData[]>(); const recentMonthlyImages = new Map<string, ImageData[]>();
// Clear shouldBeLinkedIds and only add ones that are in top N months
shouldBeLinkedIds.clear();
for (const key of sortedKeys) { for (const key of sortedKeys) {
const images = monthlyMap.get(key)!; const images = monthlyMap.get(key)!;
recentMonthlyImages.set(key, images); recentMonthlyImages.set(key, images);
for (const img of images) {
shouldBeLinkedIds.add(img.id);
}
} }
const totalImages = Array.from(recentMonthlyImages.values()).reduce((sum, imgs) => sum + imgs.length, 0); const totalImages = Array.from(recentMonthlyImages.values()).reduce((sum, imgs) => sum + imgs.length, 0);
@@ -411,11 +357,6 @@ const doHydratePhotoArchive = async (
}); });
const images = (mediaItems || []).filter(m => m.mimeType?.startsWith('image/')); const images = (mediaItems || []).filter(m => m.mimeType?.startsWith('image/'));
// Add to the set of IDs that should be linked
for (const img of images) {
shouldBeLinkedIds.add(img.id);
}
archiveData.push({ element: archive, mode: 'single-month', year, month, images }); archiveData.push({ element: archive, mode: 'single-month', year, month, images });
console.log(`[photo_archive] Year ${year} month ${month}: ${images.length} images`); console.log(`[photo_archive] Year ${year} month ${month}: ${images.length} images`);
} else { } else {
@@ -431,11 +372,6 @@ const doHydratePhotoArchive = async (
if (images.length > 0) { if (images.length > 0) {
monthlyImages.set(m + 1, images); // Store with 1-based month key monthlyImages.set(m + 1, images); // Store with 1-based month key
// Add to the set of IDs that should be linked
for (const img of images) {
shouldBeLinkedIds.add(img.id);
}
} }
} }
@@ -445,34 +381,8 @@ const doHydratePhotoArchive = async (
} }
} }
} }
console.log(`[photo_archive] Should link ${shouldBeLinkedIds.size} media IDs`); // Render galleries
// 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)) {
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: 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) { for (const { element, mode, year, month, images, monthlyImages, showYearInLabel } of archiveData) {
const archiveContainer = element.querySelector('.photo-archive-container'); const archiveContainer = element.querySelector('.photo-archive-container');
if (!archiveContainer) continue; if (!archiveContainer) continue;
@@ -532,7 +442,7 @@ const doHydratePhotoArchive = async (
} }
} }
console.log(`[photo_archive] Hydration complete. ${shouldBeLinkedIds.size} images should be linked.`); console.log('[photo_archive] Hydration complete.');
}; };
/** /**
@@ -763,7 +673,7 @@ const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
try { try {
await hydrateGalleries(previewRef.current, postId, lightboxHandler); await hydrateGalleries(previewRef.current, postId, lightboxHandler);
if (!cancelled) { if (!cancelled) {
await hydratePhotoArchive(previewRef.current, postId, lightboxHandler); await hydratePhotoArchive(previewRef.current, lightboxHandler);
} }
} finally { } finally {
// Always reset hydration state when complete - the ref is global to the component // Always reset hydration state when complete - the ref is global to the component
@@ -1466,7 +1376,7 @@ const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
<div className="preview-hydrating-overlay" ref={hydrationOverlayRef} style={{ display: 'none' }}> <div className="preview-hydrating-overlay" ref={hydrationOverlayRef} style={{ display: 'none' }}>
<div className="preview-hydrating-content"> <div className="preview-hydrating-content">
<div className="preview-hydrating-spinner" /> <div className="preview-hydrating-spinner" />
<span>Linking images to post...</span> <span>Loading photo archive...</span>
</div> </div>
</div> </div>
<div <div

View File

@@ -3,7 +3,6 @@
* *
* Creates a photo gallery organized by year and month. * Creates a photo gallery organized by year and month.
* Images are discovered dynamically from the media library based on their creation date. * Images are discovered dynamically from the media library based on their creation date.
* When rendered, images are automatically linked to the post.
* *
* Usage: * Usage:
* [[photo_archive]] - Newest 10 months with images (month + year label) * [[photo_archive]] - Newest 10 months with images (month + year label)
@@ -37,7 +36,7 @@ function getMonthName(month: number): string {
const photoArchiveMacro: MacroDefinition = { const photoArchiveMacro: MacroDefinition = {
name: 'photo_archive', name: 'photo_archive',
description: 'Creates a photo archive gallery organized by year and month, automatically linking discovered images to the post', description: 'Creates a photo archive gallery organized by year and month',
validate(params: MacroParams): string | undefined { validate(params: MacroParams): string | undefined {
// Year is optional - if not provided, shows recent 10 months // Year is optional - if not provided, shows recent 10 months
@@ -76,7 +75,7 @@ const photoArchiveMacro: MacroDefinition = {
return `📅 Photo Archive: ${year}`; return `📅 Photo Archive: ${year}`;
}, },
render(params: MacroParams, context: MacroRenderContext): string { render(params: MacroParams, _context: MacroRenderContext): string {
const { year, month } = params; const { year, month } = params;
// Build data attributes for hydration // Build data attributes for hydration
@@ -92,10 +91,6 @@ const photoArchiveMacro: MacroDefinition = {
} }
} }
if (context.postId) {
dataAttrs.push(`data-post-id="${context.postId}"`);
}
// CSS classes // CSS classes
const classes = ['macro-photo-archive']; const classes = ['macro-photo-archive'];
if (!year) { if (!year) {

View File

@@ -261,6 +261,20 @@ describe('photo_archive hydration', () => {
expect(monthKeys[0]).toBe('2020-06'); expect(monthKeys[0]).toBe('2020-06');
expect(monthKeys[monthKeys.length - 1]).toBe('2019-09'); expect(monthKeys[monthKeys.length - 1]).toBe('2019-09');
}); });
it('should not require post-media linking side effects during preview hydration', async () => {
const linkMany = vi.fn();
const unlinkMany = vi.fn();
const result = await hydratePhotoArchive(
{ recent: '10' },
mockMediaFilter
);
expect(result.mode).toBe('recent');
expect(linkMany).not.toHaveBeenCalled();
expect(unlinkMany).not.toHaveBeenCalled();
});
}); });
describe('year mode (year parameter only)', () => { describe('year mode (year parameter only)', () => {

View File

@@ -129,13 +129,13 @@ describe('photo_archive macro', () => {
expect(html).not.toContain('data-month='); expect(html).not.toContain('data-month=');
}); });
it('should include post-id data attribute when available', () => { it('should not include post-id data attribute when available', () => {
const macro = getMacro('photo_archive'); const macro = getMacro('photo_archive');
const context: MacroRenderContext = { isPreview: true, postId: 'post-123' }; const context: MacroRenderContext = { isPreview: true, postId: 'post-123' };
const html = macro!.render({ year: '2024' }, context); const html = macro!.render({ year: '2024' }, context);
expect(html).toContain('data-post-id="post-123"'); expect(html).not.toContain('data-post-id="post-123"');
}); });
it('should include loading placeholder', () => { it('should include loading placeholder', () => {
@@ -213,13 +213,13 @@ describe('photo_archive macro', () => {
expect(preview).toBe('📅 Photo Archive: Recent'); expect(preview).toBe('📅 Photo Archive: Recent');
}); });
it('should include post-id data attribute in recent mode', () => { it('should not include post-id data attribute in recent mode', () => {
const macro = getMacro('photo_archive'); const macro = getMacro('photo_archive');
const context: MacroRenderContext = { isPreview: true, postId: 'post-123' }; const context: MacroRenderContext = { isPreview: true, postId: 'post-123' };
const html = macro!.render({}, context); const html = macro!.render({}, context);
expect(html).toContain('data-post-id="post-123"'); expect(html).not.toContain('data-post-id="post-123"');
}); });
}); });