From 3b215f5752fa66c5e0726d7d3e33c775b4c470a4 Mon Sep 17 00:00:00 2001 From: hugo Date: Fri, 13 Feb 2026 18:48:46 +0100 Subject: [PATCH] fix: photo_archive fixed --- src/main/engine/MediaEngine.ts | 22 +- src/renderer/components/Editor/Editor.tsx | 208 ++++++++++++++---- .../LinkedMediaPanel/LinkedMediaPanel.tsx | 25 +++ .../MilkdownEditor/MilkdownEditor.tsx | 16 +- src/renderer/utils/index.ts | 1 + src/renderer/utils/markdownEscape.ts | 39 ++++ tests/renderer/utils/markdownEscape.test.ts | 99 +++++++++ 7 files changed, 353 insertions(+), 57 deletions(-) create mode 100644 src/renderer/utils/markdownEscape.ts create mode 100644 tests/renderer/utils/markdownEscape.test.ts diff --git a/src/main/engine/MediaEngine.ts b/src/main/engine/MediaEngine.ts index 893934a..0dc7ffc 100644 --- a/src/main/engine/MediaEngine.ts +++ b/src/main/engine/MediaEngine.ts @@ -3,7 +3,7 @@ import { v4 as uuidv4 } from 'uuid'; import * as fs from 'fs/promises'; import * as path from 'path'; import * as crypto from 'crypto'; -import { eq, and, gte, lte, desc } from 'drizzle-orm'; +import { eq, and, gte, lte, lt, desc } from 'drizzle-orm'; import { app } from 'electron'; import { getDatabase } from '../database'; import { media, Media, NewMedia } from '../database/schema'; @@ -592,6 +592,8 @@ export class MediaEngine extends EventEmitter { const db = getDatabase().getLocal(); const conditions = [eq(media.projectId, this.currentProjectId)]; + console.log(`[MediaEngine] getMediaFiltered called with filter:`, JSON.stringify(filter)); + if (filter.startDate) { conditions.push(gte(media.createdAt, filter.startDate)); } @@ -601,17 +603,21 @@ export class MediaEngine extends EventEmitter { } if (filter.year !== undefined) { - const startOfYear = new Date(filter.year, 0, 1); - const endOfYear = new Date(filter.year + 1, 0, 1); + // Use UTC dates to avoid timezone issues + const startOfYear = new Date(Date.UTC(filter.year, 0, 1)); + const endOfYear = new Date(Date.UTC(filter.year + 1, 0, 1)); + console.log(`[MediaEngine] Year filter: ${startOfYear.toISOString()} to ${endOfYear.toISOString()}`); conditions.push(gte(media.createdAt, startOfYear)); - conditions.push(lte(media.createdAt, endOfYear)); + conditions.push(lt(media.createdAt, endOfYear)); } if (filter.month !== undefined && filter.year !== undefined) { - const startOfMonth = new Date(filter.year, filter.month, 1); - const endOfMonth = new Date(filter.year, filter.month + 1, 1); + // Use UTC dates to avoid timezone issues + const startOfMonth = new Date(Date.UTC(filter.year, filter.month, 1)); + const endOfMonth = new Date(Date.UTC(filter.year, filter.month + 1, 1)); + console.log(`[MediaEngine] Month filter: ${startOfMonth.toISOString()} to ${endOfMonth.toISOString()}`); conditions.push(gte(media.createdAt, startOfMonth)); - conditions.push(lte(media.createdAt, endOfMonth)); + conditions.push(lt(media.createdAt, endOfMonth)); } const dbMediaList = await db @@ -621,6 +627,8 @@ export class MediaEngine extends EventEmitter { .orderBy(desc(media.createdAt)) .all(); + console.log(`[MediaEngine] Query returned ${dbMediaList.length} media items`); + let result: MediaData[] = []; for (const dbMedia of dbMediaList) { diff --git a/src/renderer/components/Editor/Editor.tsx b/src/renderer/components/Editor/Editor.tsx index 6796d17..7128230 100644 --- a/src/renderer/components/Editor/Editor.tsx +++ b/src/renderer/components/Editor/Editor.tsx @@ -242,9 +242,46 @@ const FULL_MONTH_NAMES = [ 'July', 'August', 'September', 'October', 'November', 'December' ]; +// Track photo_archive hydration state to prevent duplicate runs +const photoArchiveHydratingCache = new Map(); + +/** + * 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 { + 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): 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. - * Also links the discovered media to the post. + * Also manages linking/unlinking of media based on what the macros cover. */ const hydratePhotoArchive = async ( container: HTMLElement, @@ -253,6 +290,58 @@ const hydratePhotoArchive = async ( ) => { const archives = container.querySelectorAll('.macro-photo-archive[data-year]'); + 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, unlinking ${previouslyLinked.size} previously linked media`); + for (const mediaId of previouslyLinked) { + await window.electronAPI?.postMedia.unlink(postId, mediaId); + } + localStorage.removeItem(getPhotoArchiveLinkedKey(postId)); + } + return; + } + + // Check if we're already hydrating (prevent duplicate runs) + if (photoArchiveHydratingCache.get(postId)) { + console.log(`[photo_archive] Skipping duplicate hydration for ${postId}`); + return; + } + photoArchiveHydratingCache.set(postId, true); + + try { + await doHydratePhotoArchive(container, postId, onImageClick, archives); + } finally { + // Clear the hydrating flag after a delay to allow for content changes + setTimeout(() => photoArchiveHydratingCache.delete(postId), 500); + } +}; + +/** + * Internal implementation of photo_archive hydration + */ +const doHydratePhotoArchive = async ( + _container: HTMLElement, + postId: string, + onImageClick: (index: number, images: { src: string; alt: string }[]) => void, + archives: NodeListOf +) => { + // Load previously linked IDs to detect what needs unlinking + const previouslyLinkedIds = loadPreviouslyLinkedIds(postId); + + // Phase 1: Collect all media IDs that should be linked based on current macros + const shouldBeLinkedIds = new Set(); + const archiveData: Array<{ + element: Element; + year: number; + month?: number; + images?: Array<{ id: string; originalName: string; alt?: string; mimeType: string }>; + monthlyImages?: Map>; + }> = []; + + console.log(`[photo_archive] Processing ${archives.length} archive macro(s), previously linked: ${previouslyLinkedIds.size} IDs`); + for (const archive of archives) { const year = parseInt(archive.getAttribute('data-year') || '0', 10); const monthStr = archive.getAttribute('data-month'); @@ -260,26 +349,73 @@ const hydratePhotoArchive = async ( if (!year) continue; - const archiveContainer = archive.querySelector('.photo-archive-container'); + if (month !== undefined) { + // Single month view + const mediaItems = await window.electronAPI?.media.filter({ + year, + month: month - 1, // API uses 0-based month + }); + 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, year, month, images }); + console.log(`[photo_archive] Year ${year} month ${month}: ${images.length} images`); + } else { + // Full year view - collect all months, tracking which month each image belongs to + const monthlyImages = new Map>(); + + for (let m = 0; m < 12; m++) { + const mediaItems = await window.electronAPI?.media.filter({ + year, + month: m, + }); + const images = (mediaItems || []).filter(item => item.mimeType?.startsWith('image/')); + + if (images.length > 0) { + 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); + } + } + } + + const totalImages = Array.from(monthlyImages.values()).reduce((sum, imgs) => sum + imgs.length, 0); + archiveData.push({ element: archive, year, month: undefined, monthlyImages }); + console.log(`[photo_archive] Year ${year}: ${totalImages} images across ${monthlyImages.size} months`); + } + } + + 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 + 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); + } + } + + // Save current linked IDs for next hydration + saveLinkedIds(postId, shouldBeLinkedIds); + + // Phase 3: Link new media and render + for (const { element, year, month, images, monthlyImages } of archiveData) { + const archiveContainer = element.querySelector('.photo-archive-container'); if (!archiveContainer) continue; try { + // Render the gallery let html = ''; - if (month !== undefined) { + if (month !== undefined && images) { // Single month view - const mediaItems = await window.electronAPI?.media.filter({ - year, - month: month - 1, // API uses 0-based month - }); - - const images = (mediaItems || []).filter(m => m.mimeType?.startsWith('image/')); - - if (images.length === 0) { - archiveContainer.innerHTML = `
No photos found for ${FULL_MONTH_NAMES[month - 1]} ${year}
`; - continue; - } - // Link images to the post for (const img of images) { const isLinked = await window.electronAPI?.postMedia.isLinked(postId, img.id); @@ -288,38 +424,32 @@ const hydratePhotoArchive = async ( } } + if (images.length === 0) { + archiveContainer.innerHTML = `
No photos found for ${FULL_MONTH_NAMES[month - 1]} ${year}
`; + continue; + } html = buildMonthGallery(month, year, images, onImageClick); - } else { - // Full year view - show each month that has images - const monthsWithMedia: { month: number; images: { id: string; originalName: string; alt?: string; mimeType: string }[] }[] = []; - - for (let m = 0; m < 12; m++) { - const mediaItems = await window.electronAPI?.media.filter({ - year, - month: m, // API uses 0-based month - }); - - const images = (mediaItems || []).filter(m => m.mimeType?.startsWith('image/')); - if (images.length > 0) { - monthsWithMedia.push({ month: m + 1, images }); // Store as 1-based month - - // 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); - } + } else if (monthlyImages) { + // Full year view - already grouped by month + // 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 (monthsWithMedia.length === 0) { + if (monthlyImages.size === 0) { archiveContainer.innerHTML = `
No photos found for ${year}
`; continue; } - html = monthsWithMedia.map(({ month, images }) => - `
${buildMonthGallery(month, year, images, onImageClick)}
` + // Sort months and build gallery + const sortedMonths = Array.from(monthlyImages.entries()).sort((a, b) => a[0] - b[0]); + html = sortedMonths.map(([m, imgs]) => + `
${buildMonthGallery(m, year, imgs, onImageClick)}
` ).join(''); } @@ -333,6 +463,8 @@ const hydratePhotoArchive = async ( archiveContainer.innerHTML = '
Failed to load photo archive
'; } } + + console.log(`[photo_archive] Hydration complete. ${shouldBeLinkedIds.size} images should be linked.`); }; /** diff --git a/src/renderer/components/LinkedMediaPanel/LinkedMediaPanel.tsx b/src/renderer/components/LinkedMediaPanel/LinkedMediaPanel.tsx index 12d04ec..5a7c1f9 100644 --- a/src/renderer/components/LinkedMediaPanel/LinkedMediaPanel.tsx +++ b/src/renderer/components/LinkedMediaPanel/LinkedMediaPanel.tsx @@ -52,6 +52,31 @@ export const LinkedMediaPanel: React.FC = ({ loadLinkedMedia(); }, [loadLinkedMedia]); + // Listen for media link/unlink events to refresh the panel + useEffect(() => { + const handleLinked = (...args: unknown[]) => { + const data = args[0] as { postId: string } | undefined; + if (data?.postId === postId) { + loadLinkedMedia(); + } + }; + + const handleUnlinked = (...args: unknown[]) => { + const data = args[0] as { postId: string } | undefined; + if (data?.postId === postId) { + loadLinkedMedia(); + } + }; + + const unsubLinked = window.electronAPI?.on('postMedia:linked', handleLinked); + const unsubUnlinked = window.electronAPI?.on('postMedia:unlinked', handleUnlinked); + + return () => { + unsubLinked?.(); + unsubUnlinked?.(); + }; + }, [postId, loadLinkedMedia]); + // Handle importing new media with auto-link const handleImportMedia = async () => { try { diff --git a/src/renderer/components/MilkdownEditor/MilkdownEditor.tsx b/src/renderer/components/MilkdownEditor/MilkdownEditor.tsx index 64de4eb..27b88c4 100644 --- a/src/renderer/components/MilkdownEditor/MilkdownEditor.tsx +++ b/src/renderer/components/MilkdownEditor/MilkdownEditor.tsx @@ -21,15 +21,7 @@ import { macroPlugin } from '../../plugins/macroPlugin'; import '../../macros'; import './MilkdownEditor.css'; import { PostSearchModal } from '../PostSearchModal'; - -/** - * Unescape brackets that Milkdown/remark escapes. - * This preserves macro syntax like [[gallery]] instead of \[\[gallery\]\] - */ -const unescapeBrackets = (markdown: string): string => { - // Unescape \[ and \] back to [ and ] - return markdown.replace(/\\\[/g, '[').replace(/\\\]/g, ']'); -}; +import { unescapeMacroSyntax } from '../../utils/markdownEscape'; // Remark plugin to force tight lists (no blank lines between list items) const remarkTightListsPlugin: Plugin<[Record], Root> = () => { @@ -276,9 +268,9 @@ const MilkdownProviderInner: React.FC = ({ // Add custom remark plugin to force tight lists ctx.set(remarkPluginsCtx, [remarkTightLists]); ctx.get(listenerCtx).markdownUpdated((_ctx: Ctx, markdown: string, prevMarkdown: string) => { - // Unescape brackets to preserve macro syntax like [[gallery]] - const unescaped = unescapeBrackets(markdown); - const prevUnescaped = unescapeBrackets(prevMarkdown); + // Unescape brackets and underscores to preserve macro syntax like [[photo_gallery]] + const unescaped = unescapeMacroSyntax(markdown); + const prevUnescaped = unescapeMacroSyntax(prevMarkdown); if (unescaped !== prevUnescaped) { // On first update after load, store the normalized baseline diff --git a/src/renderer/utils/index.ts b/src/renderer/utils/index.ts index 43dd111..745599a 100644 --- a/src/renderer/utils/index.ts +++ b/src/renderer/utils/index.ts @@ -1 +1,2 @@ export { AutoSaveManager, type AutoSaveConfig } from './autoSave'; +export { unescapeMacroSyntax } from './markdownEscape'; diff --git a/src/renderer/utils/markdownEscape.ts b/src/renderer/utils/markdownEscape.ts new file mode 100644 index 0000000..253785f --- /dev/null +++ b/src/renderer/utils/markdownEscape.ts @@ -0,0 +1,39 @@ +/** + * Markdown escape utilities for Milkdown editor + * + * Handles unescaping of special characters that Milkdown/remark escapes + * but should be preserved in macro syntax. + */ + +/** + * Unescape special characters in macro syntax that Milkdown escapes. + * + * Milkdown/remark-stringify escapes: + * - Brackets [ and ] to prevent unwanted link syntax + * - Underscores _ to prevent unwanted emphasis + * + * For macros like [[photo_gallery]], we want to preserve the original syntax. + * + * Strategy: + * 1. First unescape all brackets (they're always safe to unescape) + * 2. Then unescape underscores only inside [[...]] macro syntax + * + * @param markdown - The markdown string with escaped characters + * @returns The markdown with macro syntax unescaped + */ +export function unescapeMacroSyntax(markdown: string): string { + if (!markdown) return markdown; + + // Step 1: Unescape all brackets \[ and \] back to [ and ] + let result = markdown.replace(/\\\[/g, '[').replace(/\\\]/g, ']'); + + // Step 2: Unescape underscores only inside macro brackets [[...]] + // Match [[...]] patterns and unescape underscores within them + result = result.replace(/\[\[([^\]]*)\]\]/g, (_match, content) => { + // Unescape underscores within the macro content + const unescapedContent = content.replace(/\\_/g, '_'); + return `[[${unescapedContent}]]`; + }); + + return result; +} diff --git a/tests/renderer/utils/markdownEscape.test.ts b/tests/renderer/utils/markdownEscape.test.ts new file mode 100644 index 0000000..1e520aa --- /dev/null +++ b/tests/renderer/utils/markdownEscape.test.ts @@ -0,0 +1,99 @@ +/** + * Tests for markdown escape utility functions + * Validates that macro syntax is properly unescaped after Milkdown serialization + */ + +import { describe, it, expect } from 'vitest'; +import { unescapeMacroSyntax } from '../../../src/renderer/utils/markdownEscape'; + +describe('unescapeMacroSyntax', () => { + describe('bracket unescaping', () => { + it('should unescape brackets in macro syntax', () => { + const input = '\\[\\[gallery\\]\\]'; + const expected = '[[gallery]]'; + expect(unescapeMacroSyntax(input)).toBe(expected); + }); + + it('should unescape brackets with parameters', () => { + const input = '\\[\\[gallery id="123"\\]\\]'; + const expected = '[[gallery id="123"]]'; + expect(unescapeMacroSyntax(input)).toBe(expected); + }); + + it('should handle single escaped brackets that are not part of macros', () => { + const input = 'Array access: arr\\[0\\]'; + const expected = 'Array access: arr[0]'; + expect(unescapeMacroSyntax(input)).toBe(expected); + }); + }); + + describe('underscore unescaping inside macros', () => { + it('should unescape underscores in macro names', () => { + const input = '[[photo\\_gallery]]'; + const expected = '[[photo_gallery]]'; + expect(unescapeMacroSyntax(input)).toBe(expected); + }); + + it('should unescape underscores in macro parameters', () => { + const input = '[[gallery archive\\_id="photo\\_collection"]]'; + const expected = '[[gallery archive_id="photo_collection"]]'; + expect(unescapeMacroSyntax(input)).toBe(expected); + }); + + it('should handle multiple underscores in macro', () => { + const input = '[[my\\_custom\\_macro\\_name param\\_one="value\\_one"]]'; + const expected = '[[my_custom_macro_name param_one="value_one"]]'; + expect(unescapeMacroSyntax(input)).toBe(expected); + }); + + it('should NOT unescape underscores outside of macros', () => { + // Outside macros, escaped underscores should remain escaped + // as they may be intentional escaping for emphasis prevention + const input = 'some\\_text with [[photo\\_gallery]] more\\_text'; + const expected = 'some\\_text with [[photo_gallery]] more\\_text'; + expect(unescapeMacroSyntax(input)).toBe(expected); + }); + }); + + describe('combined escaping', () => { + it('should handle both escaped brackets and underscores', () => { + const input = '\\[\\[photo\\_gallery archive\\_id="123"\\]\\]'; + const expected = '[[photo_gallery archive_id="123"]]'; + expect(unescapeMacroSyntax(input)).toBe(expected); + }); + + it('should handle macro in middle of text', () => { + const input = 'Check out this \\[\\[photo\\_gallery\\]\\] for more photos.'; + const expected = 'Check out this [[photo_gallery]] for more photos.'; + expect(unescapeMacroSyntax(input)).toBe(expected); + }); + + it('should handle multiple macros in text', () => { + const input = '\\[\\[photo\\_gallery\\]\\] and \\[\\[video\\_player id="1"\\]\\]'; + const expected = '[[photo_gallery]] and [[video_player id="1"]]'; + expect(unescapeMacroSyntax(input)).toBe(expected); + }); + }); + + describe('edge cases', () => { + it('should handle empty string', () => { + expect(unescapeMacroSyntax('')).toBe(''); + }); + + it('should handle string with no escaping', () => { + const input = '[[gallery]] plain text'; + expect(unescapeMacroSyntax(input)).toBe(input); + }); + + it('should handle macros that are already properly formatted', () => { + const input = '[[photo_gallery id="123"]]'; + expect(unescapeMacroSyntax(input)).toBe(input); + }); + + it('should handle incomplete macro syntax', () => { + const input = '\\[\\[incomplete'; + const expected = '[[incomplete'; + expect(unescapeMacroSyntax(input)).toBe(expected); + }); + }); +});