fix: photo_archive fixed
This commit is contained in:
@@ -3,7 +3,7 @@ import { v4 as uuidv4 } from 'uuid';
|
|||||||
import * as fs from 'fs/promises';
|
import * as fs from 'fs/promises';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as crypto from 'crypto';
|
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 { app } from 'electron';
|
||||||
import { getDatabase } from '../database';
|
import { getDatabase } from '../database';
|
||||||
import { media, Media, NewMedia } from '../database/schema';
|
import { media, Media, NewMedia } from '../database/schema';
|
||||||
@@ -592,6 +592,8 @@ export class MediaEngine extends EventEmitter {
|
|||||||
const db = getDatabase().getLocal();
|
const db = getDatabase().getLocal();
|
||||||
const conditions = [eq(media.projectId, this.currentProjectId)];
|
const conditions = [eq(media.projectId, this.currentProjectId)];
|
||||||
|
|
||||||
|
console.log(`[MediaEngine] getMediaFiltered called with filter:`, JSON.stringify(filter));
|
||||||
|
|
||||||
if (filter.startDate) {
|
if (filter.startDate) {
|
||||||
conditions.push(gte(media.createdAt, filter.startDate));
|
conditions.push(gte(media.createdAt, filter.startDate));
|
||||||
}
|
}
|
||||||
@@ -601,17 +603,21 @@ export class MediaEngine extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (filter.year !== undefined) {
|
if (filter.year !== undefined) {
|
||||||
const startOfYear = new Date(filter.year, 0, 1);
|
// Use UTC dates to avoid timezone issues
|
||||||
const endOfYear = new Date(filter.year + 1, 0, 1);
|
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(gte(media.createdAt, startOfYear));
|
||||||
conditions.push(lte(media.createdAt, endOfYear));
|
conditions.push(lt(media.createdAt, endOfYear));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filter.month !== undefined && filter.year !== undefined) {
|
if (filter.month !== undefined && filter.year !== undefined) {
|
||||||
const startOfMonth = new Date(filter.year, filter.month, 1);
|
// Use UTC dates to avoid timezone issues
|
||||||
const endOfMonth = new Date(filter.year, filter.month + 1, 1);
|
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(gte(media.createdAt, startOfMonth));
|
||||||
conditions.push(lte(media.createdAt, endOfMonth));
|
conditions.push(lt(media.createdAt, endOfMonth));
|
||||||
}
|
}
|
||||||
|
|
||||||
const dbMediaList = await db
|
const dbMediaList = await db
|
||||||
@@ -621,6 +627,8 @@ export class MediaEngine extends EventEmitter {
|
|||||||
.orderBy(desc(media.createdAt))
|
.orderBy(desc(media.createdAt))
|
||||||
.all();
|
.all();
|
||||||
|
|
||||||
|
console.log(`[MediaEngine] Query returned ${dbMediaList.length} media items`);
|
||||||
|
|
||||||
let result: MediaData[] = [];
|
let result: MediaData[] = [];
|
||||||
|
|
||||||
for (const dbMedia of dbMediaList) {
|
for (const dbMedia of dbMediaList) {
|
||||||
|
|||||||
@@ -242,9 +242,46 @@ const FULL_MONTH_NAMES = [
|
|||||||
'July', 'August', 'September', 'October', 'November', 'December'
|
'July', 'August', 'September', 'October', 'November', 'December'
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Track photo_archive hydration state to prevent duplicate runs
|
||||||
|
const photoArchiveHydratingCache = new Map<string, boolean>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 links the discovered media to the post.
|
* Also manages linking/unlinking of media based on what the macros cover.
|
||||||
*/
|
*/
|
||||||
const hydratePhotoArchive = async (
|
const hydratePhotoArchive = async (
|
||||||
container: HTMLElement,
|
container: HTMLElement,
|
||||||
@@ -253,6 +290,58 @@ const hydratePhotoArchive = async (
|
|||||||
) => {
|
) => {
|
||||||
const archives = container.querySelectorAll('.macro-photo-archive[data-year]');
|
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<Element>
|
||||||
|
) => {
|
||||||
|
// 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<string>();
|
||||||
|
const archiveData: Array<{
|
||||||
|
element: Element;
|
||||||
|
year: number;
|
||||||
|
month?: number;
|
||||||
|
images?: Array<{ id: string; originalName: string; alt?: string; mimeType: string }>;
|
||||||
|
monthlyImages?: Map<number, Array<{ id: string; originalName: string; alt?: string; mimeType: string }>>;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
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 year = parseInt(archive.getAttribute('data-year') || '0', 10);
|
||||||
const monthStr = archive.getAttribute('data-month');
|
const monthStr = archive.getAttribute('data-month');
|
||||||
@@ -260,66 +349,107 @@ const hydratePhotoArchive = async (
|
|||||||
|
|
||||||
if (!year) continue;
|
if (!year) continue;
|
||||||
|
|
||||||
const archiveContainer = archive.querySelector('.photo-archive-container');
|
|
||||||
if (!archiveContainer) continue;
|
|
||||||
|
|
||||||
try {
|
|
||||||
let html = '';
|
|
||||||
|
|
||||||
if (month !== undefined) {
|
if (month !== undefined) {
|
||||||
// Single month view
|
// Single month view
|
||||||
const mediaItems = await window.electronAPI?.media.filter({
|
const mediaItems = await window.electronAPI?.media.filter({
|
||||||
year,
|
year,
|
||||||
month: month - 1, // API uses 0-based month
|
month: month - 1, // API uses 0-based month
|
||||||
});
|
});
|
||||||
|
|
||||||
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, 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<number, Array<{ 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,
|
||||||
|
});
|
||||||
|
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 && images) {
|
||||||
|
// 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) {
|
if (images.length === 0) {
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
html = buildMonthGallery(month, year, images, onImageClick);
|
html = buildMonthGallery(month, year, images, onImageClick);
|
||||||
} else {
|
} else if (monthlyImages) {
|
||||||
// Full year view - show each month that has images
|
// Full year view - already grouped by month
|
||||||
const monthsWithMedia: { month: number; images: { id: string; originalName: string; alt?: string; mimeType: string }[] }[] = [];
|
// Link all images to the post
|
||||||
|
for (const imgs of monthlyImages.values()) {
|
||||||
for (let m = 0; m < 12; m++) {
|
for (const img of imgs) {
|
||||||
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);
|
const isLinked = await window.electronAPI?.postMedia.isLinked(postId, img.id);
|
||||||
if (!isLinked) {
|
if (!isLinked) {
|
||||||
await window.electronAPI?.postMedia.link(postId, img.id);
|
await window.electronAPI?.postMedia.link(postId, img.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (monthsWithMedia.length === 0) {
|
if (monthlyImages.size === 0) {
|
||||||
archiveContainer.innerHTML = `<div class="photo-archive-empty">No photos found for ${year}</div>`;
|
archiveContainer.innerHTML = `<div class="photo-archive-empty">No photos found for ${year}</div>`;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
html = monthsWithMedia.map(({ month, images }) =>
|
// Sort months and build gallery
|
||||||
`<div class="photo-archive-month-wrapper">${buildMonthGallery(month, year, images, onImageClick)}</div>`
|
const sortedMonths = Array.from(monthlyImages.entries()).sort((a, b) => a[0] - b[0]);
|
||||||
|
html = sortedMonths.map(([m, imgs]) =>
|
||||||
|
`<div class="photo-archive-month-wrapper">${buildMonthGallery(m, year, imgs, onImageClick)}</div>`
|
||||||
).join('');
|
).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -333,6 +463,8 @@ const hydratePhotoArchive = async (
|
|||||||
archiveContainer.innerHTML = '<div class="photo-archive-error">Failed to load photo archive</div>';
|
archiveContainer.innerHTML = '<div class="photo-archive-error">Failed to load photo archive</div>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log(`[photo_archive] Hydration complete. ${shouldBeLinkedIds.size} images should be linked.`);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -52,6 +52,31 @@ export const LinkedMediaPanel: React.FC<LinkedMediaPanelProps> = ({
|
|||||||
loadLinkedMedia();
|
loadLinkedMedia();
|
||||||
}, [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
|
// Handle importing new media with auto-link
|
||||||
const handleImportMedia = async () => {
|
const handleImportMedia = async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -21,15 +21,7 @@ import { macroPlugin } from '../../plugins/macroPlugin';
|
|||||||
import '../../macros';
|
import '../../macros';
|
||||||
import './MilkdownEditor.css';
|
import './MilkdownEditor.css';
|
||||||
import { PostSearchModal } from '../PostSearchModal';
|
import { PostSearchModal } from '../PostSearchModal';
|
||||||
|
import { unescapeMacroSyntax } from '../../utils/markdownEscape';
|
||||||
/**
|
|
||||||
* 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, ']');
|
|
||||||
};
|
|
||||||
|
|
||||||
// Remark plugin to force tight lists (no blank lines between list items)
|
// Remark plugin to force tight lists (no blank lines between list items)
|
||||||
const remarkTightListsPlugin: Plugin<[Record<string, unknown>], Root> = () => {
|
const remarkTightListsPlugin: Plugin<[Record<string, unknown>], Root> = () => {
|
||||||
@@ -276,9 +268,9 @@ const MilkdownProviderInner: React.FC<MilkdownEditorProps> = ({
|
|||||||
// Add custom remark plugin to force tight lists
|
// Add custom remark plugin to force tight lists
|
||||||
ctx.set(remarkPluginsCtx, [remarkTightLists]);
|
ctx.set(remarkPluginsCtx, [remarkTightLists]);
|
||||||
ctx.get(listenerCtx).markdownUpdated((_ctx: Ctx, markdown: string, prevMarkdown: string) => {
|
ctx.get(listenerCtx).markdownUpdated((_ctx: Ctx, markdown: string, prevMarkdown: string) => {
|
||||||
// Unescape brackets to preserve macro syntax like [[gallery]]
|
// Unescape brackets and underscores to preserve macro syntax like [[photo_gallery]]
|
||||||
const unescaped = unescapeBrackets(markdown);
|
const unescaped = unescapeMacroSyntax(markdown);
|
||||||
const prevUnescaped = unescapeBrackets(prevMarkdown);
|
const prevUnescaped = unescapeMacroSyntax(prevMarkdown);
|
||||||
|
|
||||||
if (unescaped !== prevUnescaped) {
|
if (unescaped !== prevUnescaped) {
|
||||||
// On first update after load, store the normalized baseline
|
// On first update after load, store the normalized baseline
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
export { AutoSaveManager, type AutoSaveConfig } from './autoSave';
|
export { AutoSaveManager, type AutoSaveConfig } from './autoSave';
|
||||||
|
export { unescapeMacroSyntax } from './markdownEscape';
|
||||||
|
|||||||
39
src/renderer/utils/markdownEscape.ts
Normal file
39
src/renderer/utils/markdownEscape.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
99
tests/renderer/utils/markdownEscape.test.ts
Normal file
99
tests/renderer/utils/markdownEscape.test.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user