feat: parameterless photo_archive for recent photos

This commit is contained in:
2026-02-14 18:40:00 +01:00
parent b0e9e39020
commit 5a097283c9
3 changed files with 240 additions and 70 deletions

View File

@@ -279,7 +279,8 @@ const hydratePhotoArchive = async (
postId: string, postId: string,
onImageClick: (index: number, images: { src: string; alt: string }[]) => void onImageClick: (index: number, images: { src: string; alt: string }[]) => void
) => { ) => {
const archives = container.querySelectorAll('.macro-photo-archive[data-year]'); // Match both year-based and recent-based archives
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 // No photo_archive macros - unlink any previously linked and clear state
@@ -323,19 +324,75 @@ const doHydratePhotoArchive = async (
// Phase 1: Collect all media IDs that should be linked based on current macros // Phase 1: Collect all media IDs that should be linked based on current macros
const shouldBeLinkedIds = new Set<string>(); const shouldBeLinkedIds = new Set<string>();
type ImageData = { id: string; originalName: string; alt?: string; mimeType: string; createdAt?: Date };
const archiveData: Array<{ const archiveData: Array<{
element: Element; element: Element;
year: number; mode: 'single-month' | 'full-year' | 'recent';
year?: number;
month?: number; month?: number;
images?: Array<{ id: string; originalName: string; alt?: string; mimeType: string }>; images?: ImageData[];
monthlyImages?: Map<number, Array<{ id: string; originalName: string; alt?: string; mimeType: string }>>; // Map key is "YYYY-MM" for recent mode, or month number (1-12) for year mode
monthlyImages?: Map<string | number, ImageData[]>;
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), previously linked: ${previouslyLinkedIds.size} IDs`);
for (const archive of archives) { for (const archive of archives) {
const year = parseInt(archive.getAttribute('data-year') || '0', 10); const recentStr = archive.getAttribute('data-recent');
const yearStr = archive.getAttribute('data-year');
const monthStr = archive.getAttribute('data-month'); const monthStr = archive.getAttribute('data-month');
if (recentStr) {
// Recent mode: get last N months with images
const recentCount = parseInt(recentStr, 10) || 10;
console.log(`[photo_archive] Recent mode: fetching last ${recentCount} months with images`);
// Fetch all images (no filter)
const allMedia = await window.electronAPI?.media.filter({});
const allImages = (allMedia || []).filter(m => m.mimeType?.startsWith('image/'));
// Group by year-month and sort by most recent
const monthlyMap = new Map<string, ImageData[]>();
for (const img of allImages) {
if (!img.createdAt) continue;
const date = new Date(img.createdAt);
const year = date.getFullYear();
const month = date.getMonth() + 1; // 1-based
const key = `${year}-${String(month).padStart(2, '0')}`; // e.g. "2024-06"
if (!monthlyMap.has(key)) {
monthlyMap.set(key, []);
}
monthlyMap.get(key)!.push(img);
shouldBeLinkedIds.add(img.id);
}
// Sort by key descending (newest first) and take top N
const sortedKeys = Array.from(monthlyMap.keys()).sort().reverse().slice(0, recentCount);
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) {
const images = monthlyMap.get(key)!;
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);
archiveData.push({
element: archive,
mode: 'recent',
monthlyImages: recentMonthlyImages,
showYearInLabel: true
});
console.log(`[photo_archive] Recent: ${totalImages} images across ${recentMonthlyImages.size} months`);
} else if (yearStr) {
const year = parseInt(yearStr, 10);
const month = monthStr ? parseInt(monthStr, 10) : undefined; const month = monthStr ? parseInt(monthStr, 10) : undefined;
if (!year) continue; if (!year) continue;
@@ -353,11 +410,11 @@ const doHydratePhotoArchive = async (
shouldBeLinkedIds.add(img.id); shouldBeLinkedIds.add(img.id);
} }
archiveData.push({ element: archive, 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 {
// Full year view - collect all months, tracking which month each image belongs to // Full year view - collect all months, tracking which month each image belongs to
const monthlyImages = new Map<number, Array<{ id: string; originalName: string; alt?: string; mimeType: string }>>(); const monthlyImages = new Map<number, ImageData[]>();
for (let m = 0; m < 12; m++) { for (let m = 0; m < 12; m++) {
const mediaItems = await window.electronAPI?.media.filter({ const mediaItems = await window.electronAPI?.media.filter({
@@ -377,10 +434,11 @@ const doHydratePhotoArchive = async (
} }
const totalImages = Array.from(monthlyImages.values()).reduce((sum, imgs) => sum + imgs.length, 0); const totalImages = Array.from(monthlyImages.values()).reduce((sum, imgs) => sum + imgs.length, 0);
archiveData.push({ element: archive, year, month: undefined, monthlyImages }); archiveData.push({ element: archive, mode: 'full-year', year, month: undefined, monthlyImages });
console.log(`[photo_archive] Year ${year}: ${totalImages} images across ${monthlyImages.size} months`); console.log(`[photo_archive] Year ${year}: ${totalImages} images across ${monthlyImages.size} months`);
} }
} }
}
console.log(`[photo_archive] Should link ${shouldBeLinkedIds.size} media IDs`); console.log(`[photo_archive] Should link ${shouldBeLinkedIds.size} media IDs`);
@@ -397,7 +455,7 @@ const doHydratePhotoArchive = async (
saveLinkedIds(postId, shouldBeLinkedIds); saveLinkedIds(postId, shouldBeLinkedIds);
// Phase 3: Link new media and render // Phase 3: Link new media and render
for (const { element, year, month, images, monthlyImages } 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;
@@ -405,7 +463,7 @@ const doHydratePhotoArchive = async (
// Render the gallery // Render the gallery
let html = ''; let html = '';
if (month !== undefined && images) { if (mode === 'single-month' && month !== undefined && images && year) {
// Single month view // Single month view
// Link images to the post // Link images to the post
for (const img of images) { for (const img of images) {
@@ -419,9 +477,37 @@ const doHydratePhotoArchive = async (
archiveContainer.innerHTML = `<div class="photo-archive-empty">No photos found for ${FULL_MONTH_NAMES[month - 1]} ${year}</div>`; archiveContainer.innerHTML = `<div class="photo-archive-empty">No photos found for ${FULL_MONTH_NAMES[month - 1]} ${year}</div>`;
continue; continue;
} }
html = buildMonthGallery(month, year, images, onImageClick); html = buildMonthGallery(month, year, images, onImageClick, false);
} else if (monthlyImages) { } else if (mode === 'recent' && monthlyImages) {
// Full year view - already grouped by month // 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;
}
// Sort by key descending (newest first) - keys are "YYYY-MM" strings
const sortedEntries = Array.from(monthlyImages.entries())
.sort((a, b) => (b[0] as string).localeCompare(a[0] as string));
html = sortedEntries.map(([key, imgs]) => {
// Parse "YYYY-MM" to get year and month
const [yearStr, monthStr] = (key as string).split('-');
const entryYear = parseInt(yearStr, 10);
const entryMonth = parseInt(monthStr, 10);
return `<div class="photo-archive-month-wrapper">${buildMonthGallery(entryMonth, entryYear, imgs, onImageClick, true)}</div>`;
}).join('');
} else if (mode === 'full-year' && monthlyImages && year) {
// Full year view - keys are month numbers
// Link all images to the post // Link all images to the post
for (const imgs of monthlyImages.values()) { for (const imgs of monthlyImages.values()) {
for (const img of imgs) { for (const img of imgs) {
@@ -437,10 +523,11 @@ const doHydratePhotoArchive = async (
continue; continue;
} }
// Sort months and build gallery // Sort months ascending (January first)
const sortedMonths = Array.from(monthlyImages.entries()).sort((a, b) => a[0] - b[0]); const sortedMonths = Array.from(monthlyImages.entries())
.sort((a, b) => (a[0] as number) - (b[0] as number));
html = sortedMonths.map(([m, imgs]) => html = sortedMonths.map(([m, imgs]) =>
`<div class="photo-archive-month-wrapper">${buildMonthGallery(m, year, imgs, onImageClick)}</div>` `<div class="photo-archive-month-wrapper">${buildMonthGallery(m as number, year, imgs, onImageClick, showYearInLabel || false)}</div>`
).join(''); ).join('');
} }
@@ -460,19 +547,26 @@ const doHydratePhotoArchive = async (
/** /**
* Build HTML for a single month's gallery with rotated month label * Build HTML for a single month's gallery with rotated month label
* @param month - 1-based month number (1 = January)
* @param year - The year
* @param images - Array of image data
* @param _onImageClick - Click handler (unused in template, set up separately)
* @param showYear - Whether to include the year in the label (e.g., "January 2024")
*/ */
function buildMonthGallery( function buildMonthGallery(
month: number, month: number,
year: number, year: number,
images: { id: string; originalName: string; alt?: string }[], images: { id: string; originalName: string; alt?: string }[],
_onImageClick: (index: number, images: { src: string; alt: string }[]) => void _onImageClick: (index: number, images: { src: string; alt: string }[]) => void,
showYear: boolean = false
): string { ): string {
const monthName = FULL_MONTH_NAMES[month - 1]; const monthName = FULL_MONTH_NAMES[month - 1];
const labelText = showYear ? `${monthName} ${year}` : monthName;
return ` return `
<div class="photo-archive-month" data-month="${month}" data-year="${year}"> <div class="photo-archive-month" data-month="${month}" data-year="${year}">
<div class="photo-archive-month-label"> <div class="photo-archive-month-label">
<span>${monthName}</span> <span>${labelText}</span>
</div> </div>
<div class="photo-archive-gallery gallery-lightbox"> <div class="photo-archive-gallery gallery-lightbox">
${images.map((img, index) => ` ${images.map((img, index) => `

View File

@@ -6,15 +6,17 @@
* When rendered, images are automatically linked to the post. * When rendered, images are automatically linked to the post.
* *
* Usage: * Usage:
* [[photo_archive]] - Newest 10 months with images (month + year label)
* [[photo_archive year="2024"]] - All months of 2024, each in its own lightbox * [[photo_archive year="2024"]] - All months of 2024, each in its own lightbox
* [[photo_archive year="2024" month="6"]] - Only June 2024 photos * [[photo_archive year="2024" month="6"]] - Only June 2024 photos
* *
* Parameters: * Parameters:
* - year (required): The year to display photos from (e.g., 2024) * - year (optional): The year to display photos from (e.g., 2024)
* - month (optional): Specific month (1-12). If omitted, shows all months with photos. * - month (optional): Specific month (1-12). Requires year. If omitted with year, shows all months.
* *
* Gallery Layout: * Gallery Layout:
* - Each month is in its own lightbox with the month name rotated 90° on the side * - Each month is in its own lightbox with the month name rotated 90° on the side
* - When no year specified, shows "Month Year" label (e.g., "January 2024")
* - Images are displayed in a grid and are clickable for lightbox viewing * - Images are displayed in a grid and are clickable for lightbox viewing
*/ */
@@ -38,18 +40,19 @@ const photoArchiveMacro: MacroDefinition = {
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, automatically linking discovered images to the post',
validate(params: MacroParams): string | undefined { validate(params: MacroParams): string | undefined {
// Year is required // Year is optional - if not provided, shows recent 10 months
if (!params.year) { if (params.year) {
return 'photo_archive macro requires a "year" parameter';
}
const year = parseInt(params.year, 10); const year = parseInt(params.year, 10);
if (isNaN(year) || year < 1000 || year > 9999) { if (isNaN(year) || year < 1000 || year > 9999) {
return 'Year must be a valid 4-digit year (e.g., 2024)'; return 'Year must be a valid 4-digit year (e.g., 2024)';
} }
}
// Month is optional but must be valid if provided // Month is optional but must be valid if provided, and requires year
if (params.month) { if (params.month) {
if (!params.year) {
return 'Month parameter requires a year parameter';
}
const month = parseInt(params.month, 10); const month = parseInt(params.month, 10);
if (isNaN(month) || month < 1 || month > 12) { if (isNaN(month) || month < 1 || month > 12) {
return 'Month must be a number between 1 and 12'; return 'Month must be a number between 1 and 12';
@@ -60,7 +63,11 @@ const photoArchiveMacro: MacroDefinition = {
}, },
editorPreview(params: MacroParams): string { editorPreview(params: MacroParams): string {
const year = params.year || '????'; // No year = recent mode
if (!params.year) {
return '📅 Photo Archive: Recent';
}
const year = params.year;
if (params.month) { if (params.month) {
const monthNum = parseInt(params.month, 10); const monthNum = parseInt(params.month, 10);
const monthName = getMonthName(monthNum); const monthName = getMonthName(monthNum);
@@ -73,13 +80,17 @@ const photoArchiveMacro: MacroDefinition = {
const { year, month } = params; const { year, month } = params;
// Build data attributes for hydration // Build data attributes for hydration
const dataAttrs = [ const dataAttrs: string[] = [];
`data-year="${year}"`,
];
// If no year, use recent mode (newest 10 months with images)
if (!year) {
dataAttrs.push('data-recent="10"');
} else {
dataAttrs.push(`data-year="${year}"`);
if (month) { if (month) {
dataAttrs.push(`data-month="${month}"`); dataAttrs.push(`data-month="${month}"`);
} }
}
if (context.postId) { if (context.postId) {
dataAttrs.push(`data-post-id="${context.postId}"`); dataAttrs.push(`data-post-id="${context.postId}"`);
@@ -87,7 +98,9 @@ const photoArchiveMacro: MacroDefinition = {
// CSS classes // CSS classes
const classes = ['macro-photo-archive']; const classes = ['macro-photo-archive'];
if (month) { if (!year) {
classes.push('photo-archive-recent-months');
} else if (month) {
classes.push('photo-archive-single-month'); classes.push('photo-archive-single-month');
} else { } else {
classes.push('photo-archive-full-year'); classes.push('photo-archive-full-year');
@@ -96,7 +109,17 @@ const photoArchiveMacro: MacroDefinition = {
// Generate placeholder HTML - actual content is hydrated by Editor.tsx // Generate placeholder HTML - actual content is hydrated by Editor.tsx
let html = `<div class="${classes.join(' ')}" ${dataAttrs.join(' ')}>`; let html = `<div class="${classes.join(' ')}" ${dataAttrs.join(' ')}>`;
html += `<div class="photo-archive-container">`; html += `<div class="photo-archive-container">`;
html += `<div class="photo-archive-loading">Loading photo archive for ${year}${month ? ` / ${getMonthName(parseInt(month, 10))}` : ''}...</div>`;
// Loading message based on mode
let loadingMsg: string;
if (!year) {
loadingMsg = 'Loading recent photos...';
} else if (month) {
loadingMsg = `Loading photo archive for ${year} / ${getMonthName(parseInt(month, 10))}...`;
} else {
loadingMsg = `Loading photo archive for ${year}...`;
}
html += `<div class="photo-archive-loading">${loadingMsg}</div>`;
html += `</div>`; html += `</div>`;
html += `</div>`; html += `</div>`;

View File

@@ -18,12 +18,12 @@ describe('photo_archive macro', () => {
}); });
describe('validation', () => { describe('validation', () => {
it('should require year parameter', () => { it('should accept no parameters (recent mode)', () => {
const macro = getMacro('photo_archive'); const macro = getMacro('photo_archive');
expect(macro).toBeDefined(); expect(macro).toBeDefined();
const error = macro!.validate?.({}); const error = macro!.validate?.({});
expect(error).toBe('photo_archive macro requires a "year" parameter'); expect(error).toBeUndefined();
}); });
it('should reject non-numeric year', () => { it('should reject non-numeric year', () => {
@@ -170,6 +170,59 @@ describe('photo_archive macro', () => {
}); });
}); });
describe('render - no parameters (recent mode)', () => {
it('should accept no parameters', () => {
const macro = getMacro('photo_archive');
const error = macro!.validate?.({});
expect(error).toBeUndefined();
});
it('should render with data-recent attribute when no params', () => {
const macro = getMacro('photo_archive');
const context: MacroRenderContext = { isPreview: true, postId: 'test-post-id' };
const html = macro!.render({}, context);
expect(html).toContain('data-recent="10"');
expect(html).not.toContain('data-year=');
});
it('should have recent-months class when no params', () => {
const macro = getMacro('photo_archive');
const context: MacroRenderContext = { isPreview: true, postId: 'test-post-id' };
const html = macro!.render({}, context);
expect(html).toContain('photo-archive-recent-months');
});
it('should show appropriate loading message for recent mode', () => {
const macro = getMacro('photo_archive');
const context: MacroRenderContext = { isPreview: true, postId: 'test-post-id' };
const html = macro!.render({}, context);
expect(html).toContain('Loading recent photos');
});
it('should show recent photos preview', () => {
const macro = getMacro('photo_archive');
const preview = macro!.editorPreview?.({});
expect(preview).toBe('📅 Photo Archive: Recent');
});
it('should include post-id data attribute in recent mode', () => {
const macro = getMacro('photo_archive');
const context: MacroRenderContext = { isPreview: true, postId: 'post-123' };
const html = macro!.render({}, context);
expect(html).toContain('data-post-id="post-123"');
});
});
describe('self-registration', () => { describe('self-registration', () => {
it('should self-register on import', () => { it('should self-register on import', () => {
const macro = getMacro('photo_archive'); const macro = getMacro('photo_archive');