fix: removed unneeded linkage for media from photo album pages
This commit is contained in:
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -446,33 +382,7 @@ 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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)', () => {
|
||||||
|
|||||||
@@ -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"');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user