feat(notes): export notes with custom template, closes #1734 (#2989)

This commit is contained in:
Huang Xin
2026-01-18 16:56:32 +01:00
committed by GitHub
parent 36b7386e30
commit fd299a61a7
31 changed files with 1781 additions and 55 deletions
@@ -38,6 +38,7 @@ import WikipediaPopup from './WikipediaPopup';
import TranslatorPopup from './TranslatorPopup';
import useShortcuts from '@/hooks/useShortcuts';
import ProofreadPopup from './ProofreadPopup';
import ExportMarkdownDialog from './ExportMarkdownDialog';
const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const _ = useTranslation();
@@ -76,6 +77,11 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const [showAnnotationNotes, setShowAnnotationNotes] = useState(false);
const [annotationNotes, setAnnotationNotes] = useState<BookNote[]>([]);
const [editingAnnotation, setEditingAnnotation] = useState<BookNote | null>(null);
const [showExportDialog, setShowExportDialog] = useState(false);
const [exportData, setExportData] = useState<{
booknotes: BookNote[];
booknoteGroups: { [href: string]: BooknoteGroup };
} | null>(null);
const [selectedStyle, setSelectedStyle] = useState<HighlightStyle>(
settings.globalReadSettings.highlightStyle,
@@ -765,6 +771,8 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
});
return;
}
// Organize booknotes into groups by chapter
const booknoteGroups: { [href: string]: BooknoteGroup } = {};
for (const booknote of booknotes) {
const tocItem = findTocItemBS(bookDoc.toc ?? [], booknote.cfi);
@@ -783,36 +791,14 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
});
});
const sortedGroups = Object.values(booknoteGroups).sort((a, b) => {
return a.id - b.id;
});
setExportData({ booknotes, booknoteGroups });
setShowExportDialog(true);
};
const lines: string[] = [];
lines.push(`# ${book.title}`);
lines.push(`**${_('Author')}**: ${book.author || ''}`);
lines.push('');
lines.push(`**${_('Exported from Readest')}**: ${new Date().toISOString().slice(0, 10)}`);
lines.push('');
lines.push('---');
lines.push('');
lines.push(`## ${_('Highlights & Annotations')}`);
lines.push('');
const handleConfirmExport = async (markdownContent: string) => {
const { book } = bookData;
if (!book) return;
for (const group of sortedGroups) {
const chapterTitle = group.label || _('Untitled');
lines.push(`### ${chapterTitle}`);
for (const note of group.booknotes) {
lines.push(`> "${note.text}"`);
if (note.note) {
lines.push(`**${_('Note')}**:: ${note.note}`);
}
lines.push('');
}
lines.push('---');
lines.push('');
}
const markdownContent = lines.join('\n');
setTimeout(() => {
// Delay to ensure it won't be overridden by system clipboard actions
navigator.clipboard?.writeText(markdownContent);
@@ -825,6 +811,14 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
message: saved ? _('Exported successfully') : _('Copied to clipboard'),
timeout: 2000,
});
setShowExportDialog(false);
setExportData(null);
};
const handleCancelExport = () => {
setShowExportDialog(false);
setExportData(null);
};
const selectionAnnotated = selection?.annotated;
@@ -953,6 +947,18 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
onStartEdit={handleStartEditAnnotation}
/>
)}
{showExportDialog && exportData && bookData.book && (
<ExportMarkdownDialog
bookKey={bookKey}
isOpen={showExportDialog}
bookTitle={bookData.book.title}
bookAuthor={bookData.book.author || ''}
booknotes={exportData.booknotes}
booknoteGroups={exportData.booknoteGroups}
onCancel={handleCancelExport}
onExport={handleConfirmExport}
/>
)}
</div>
);
};
@@ -0,0 +1,636 @@
import clsx from 'clsx';
import React, { useState, useMemo, useEffect } from 'react';
import { marked } from 'marked';
import { BookNote, BooknoteGroup, NoteExportConfig } from '@/types/book';
import { useTranslation } from '@/hooks/useTranslation';
import Dialog from '@/components/Dialog';
import { useReaderStore } from '@/store/readerStore';
import { DEFAULT_NOTE_EXPORT_CONFIG } from '@/services/constants';
import { saveViewSettings } from '@/helpers/settings';
import { useEnv } from '@/context/EnvContext';
interface ExportMarkdownDialogProps {
bookKey: string;
isOpen: boolean;
bookTitle: string;
bookAuthor: string;
booknotes: BookNote[];
booknoteGroups: { [href: string]: BooknoteGroup };
onCancel: () => void;
onExport: (markdown: string) => void;
}
type TemplateData = {
title: string;
author: string;
exportDate: string;
chapters: {
title: string;
annotations: {
text: string;
note?: string;
timestamp?: number;
}[];
}[];
};
type TemplateVariable = { [key: string]: number | string | TemplateVariable | TemplateVariable[] };
const renderTemplate = (template: string, data: TemplateData): string => {
const applyFilter = (
value: TemplateVariable,
filter: string,
filterArg?: string,
): TemplateVariable | string => {
if (filter === 'date') {
if (typeof value === 'number') {
const date = new Date(value);
if (filterArg) {
const format = filterArg;
return format
.replace('%Y', date.getFullYear().toString())
.replace('%m', String(date.getMonth() + 1).padStart(2, '0'))
.replace('%d', String(date.getDate()).padStart(2, '0'))
.replace('%H', String(date.getHours()).padStart(2, '0'))
.replace('%M', String(date.getMinutes()).padStart(2, '0'))
.replace('%S', String(date.getSeconds()).padStart(2, '0'));
}
return date.toLocaleString();
}
return value;
}
return value;
};
const getValue = (path: string, context: TemplateData) => {
const keys = path.trim().split('.');
let value = context as TemplateVariable;
for (const key of keys) {
value = value?.[key] as TemplateVariable;
}
return value;
};
let result = template;
// Process {% for %} loops - find first occurrence and recurse
const forMatch = result.match(/\{%\s*for\s+(\w+)\s+in\s+(\w+(?:\.\w+)*)\s*%\}/);
if (forMatch) {
const itemName = forMatch[1]!;
const collectionPath = forMatch[2]!;
// Find matching {% endfor %} by counting nesting
let depth = 0;
let endPos = -1;
const startPos = forMatch.index! + forMatch[0].length;
for (let i = startPos; i < result.length; i++) {
const remaining = result.slice(i);
if (remaining.startsWith('{% for ')) {
depth++;
i += 6; // skip past "{% for"
} else if (remaining.startsWith('{% endfor %}')) {
if (depth === 0) {
endPos = i;
break;
}
depth--;
i += 11; // skip past "{% endfor %}"
}
}
if (endPos !== -1) {
const beforeLoop = result.slice(0, forMatch.index);
const loopContent = result.slice(startPos, endPos);
const afterLoop = result.slice(endPos + 13); // 13 = length of "{% endfor %}"
const collection = getValue(collectionPath, data);
if (Array.isArray(collection)) {
const renderedItems = collection
.map((item) => {
const loopData = { ...data, [itemName]: item };
return renderTemplate(loopContent, loopData);
})
.join('');
result = beforeLoop + renderedItems + afterLoop;
return renderTemplate(result, data);
} else {
// Collection not found or not an array, remove the loop
result = beforeLoop + afterLoop;
return renderTemplate(result, data);
}
}
}
// Process {% if condition %}...{% endif %}
result = result.replace(
/\{%\s*if\s+([^%]+?)\s*%\}([\s\S]*?)\{%\s*endif\s*%\}/g,
(_match, condition, content) => {
const value = getValue(condition, data);
return value ? renderTemplate(content, data) : '';
},
);
// Replace {{ variable }}, {{ variable | filter }}, and {{ variable | filter('arg') }}
result = result.replace(
/\{\{\s*([^}|]+?)(?:\s*\|\s*(\w+)(?:\s*\((['"]?)([^)'"]*)\3\))?)?\s*\}\}/g,
(_match, path, filter, _quote, filterArg) => {
const value = getValue(path, data);
if (value === undefined || value === null) return '';
if (filter) {
return applyFilter(value, filter, filterArg) as string;
}
return String(value);
},
);
return result;
};
const ExportMarkdownDialog: React.FC<ExportMarkdownDialogProps> = ({
bookKey,
isOpen,
bookTitle,
bookAuthor,
booknotes,
booknoteGroups,
onCancel,
onExport,
}) => {
const _ = useTranslation();
const { envConfig } = useEnv();
const { getViewSettings } = useReaderStore();
const viewSettings = getViewSettings(bookKey);
const defaultTemplate = `## {{ title }}
**${_('Author')}**: {{ author }}
**${_('Exported from Readest')}**: {{ exportDate }}
---
### ${_('Highlights & Annotations')}
{% for chapter in chapters %}
#### {{ chapter.title }}
{% for annotation in chapter.annotations %}
> {{ annotation.text }}
{% if annotation.note %}
**${_('Note:')}** {{ annotation.note }}
{% endif %}
*${_('Time:')} {{ annotation.timestamp | date('%Y-%m-%d %H:%M') }}*
{% endfor %}
---
{% endfor %}`;
const [exportConfig, setExportConfig] = useState<NoteExportConfig>(() => {
const noteExportConfig = viewSettings?.noteExportConfig || DEFAULT_NOTE_EXPORT_CONFIG;
if (!noteExportConfig.customTemplate) {
return {
...noteExportConfig,
customTemplate: defaultTemplate,
};
}
return noteExportConfig;
});
const [showSource, setShowSource] = useState(false);
const [showAdvanced, setShowAdvanced] = useState(false);
useEffect(() => {
const customTemplate = exportConfig.customTemplate;
const newExportConfig = {
...exportConfig,
customTemplate: customTemplate === defaultTemplate ? '' : customTemplate,
};
saveViewSettings(envConfig, bookKey, 'noteExportConfig', newExportConfig, false, false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [exportConfig, envConfig, bookKey]);
// Generate markdown preview based on current format settings
const markdownPreview = useMemo(() => {
if (exportConfig.useCustomTemplate) {
// Prepare data for template rendering
const sortedGroups = Object.values(booknoteGroups).sort((a, b) => a.id - b.id);
const templateData = {
title: bookTitle,
author: bookAuthor,
exportDate: new Date().toISOString().slice(0, 10),
chapters: sortedGroups.map((group) => ({
title: group.label || _('Untitled'),
annotations: group.booknotes.map((note) => ({
text: note.text || '',
note: note.note || '',
style: note.style,
color: note.color,
timestamp: note.updatedAt,
})),
})),
};
return renderTemplate(exportConfig.customTemplate, templateData);
}
// Default formatting (non-template mode)
const sortedGroups = Object.values(booknoteGroups).sort((a, b) => a.id - b.id);
const lines: string[] = [];
// Add title
if (exportConfig.includeTitle) {
lines.push(`# ${bookTitle}`);
}
// Add author
if (exportConfig.includeAuthor && bookAuthor) {
lines.push(`**${_('Author')}**: ${bookAuthor}`);
lines.push('');
}
// Add export date
if (exportConfig.includeDate) {
lines.push(`**${_('Exported from Readest')}**: ${new Date().toISOString().slice(0, 10)}`);
lines.push('');
}
if (exportConfig.includeTitle || exportConfig.includeAuthor || exportConfig.includeDate) {
lines.push('---');
lines.push('');
}
lines.push(`## ${_('Highlights & Annotations')}`);
lines.push('');
for (const group of sortedGroups) {
// Add chapter title
if (exportConfig.includeChapterTitles) {
const chapterTitle = group.label || _('Untitled');
lines.push(`### ${chapterTitle}`);
}
for (const note of group.booknotes) {
// Add quote
if (exportConfig.includeQuotes && note.text) {
lines.push(`> ${note.text}`);
}
// Add note
if (exportConfig.includeNotes && note.note) {
lines.push('');
lines.push(`**${_('Note')}**:: ${note.note}`);
}
// Add timestamp
if (exportConfig.includeTimestamp && note.updatedAt) {
const timestamp = new Date(note.updatedAt).toLocaleString();
lines.push('');
lines.push(`*${_('Time:')} ${timestamp}*`);
}
lines.push(exportConfig.noteSeparator);
}
if (exportConfig.includeChapterSeparator) {
lines.push('---');
lines.push('');
}
}
return lines.join('\n');
}, [exportConfig, booknoteGroups, bookTitle, bookAuthor, _]);
// Convert markdown to HTML for preview
const htmlPreview = useMemo(() => {
if (!markdownPreview) return '';
return marked.parse(markdownPreview);
}, [markdownPreview]);
const handleToggle = (field: keyof NoteExportConfig) => {
setExportConfig((prev) => ({
...prev,
[field]: !prev[field],
}));
};
const handleExport = () => {
onExport(markdownPreview);
};
return (
<Dialog
isOpen={isOpen}
title={_('Export Annotations')}
onClose={onCancel}
boxClassName='sm:!w-[75%] sm:h-auto sm:!max-h-[90vh] sm:!max-w-5xl'
contentClassName='sm:!px-8 sm:!py-2'
>
<div className='flex flex-col gap-4'>
{/* Format Options */}
<div className='space-y-3'>
<h3 className='font-bold'>{_('Format Options')}</h3>
<div
className={clsx(
'grid grid-cols-2 gap-x-6 gap-y-3 sm:grid-cols-3',
exportConfig.useCustomTemplate && 'pointer-events-none opacity-50',
)}
>
<label className='flex cursor-pointer items-center gap-2'>
<input
type='checkbox'
checked={exportConfig.includeTitle}
onChange={() => handleToggle('includeTitle')}
className='checkbox checkbox-sm'
disabled={exportConfig.useCustomTemplate}
/>
<span className='text-sm'>{_('Title')}</span>
</label>
<label className='flex cursor-pointer items-center gap-2'>
<input
type='checkbox'
checked={exportConfig.includeAuthor}
onChange={() => handleToggle('includeAuthor')}
className='checkbox checkbox-sm'
disabled={exportConfig.useCustomTemplate}
/>
<span className='text-sm'>{_('Author')}</span>
</label>
<label className='flex cursor-pointer items-center gap-2'>
<input
type='checkbox'
checked={exportConfig.includeDate}
onChange={() => handleToggle('includeDate')}
className='checkbox checkbox-sm'
disabled={exportConfig.useCustomTemplate}
/>
<span className='text-sm'>{_('Export Date')}</span>
</label>
<label className='flex cursor-pointer items-center gap-2'>
<input
type='checkbox'
checked={exportConfig.includeChapterTitles}
onChange={() => handleToggle('includeChapterTitles')}
className='checkbox checkbox-sm'
disabled={exportConfig.useCustomTemplate}
/>
<span className='text-sm'>{_('Chapter Titles')}</span>
</label>
<label className='flex cursor-pointer items-center gap-2'>
<input
type='checkbox'
checked={exportConfig.includeChapterSeparator}
onChange={() => handleToggle('includeChapterSeparator')}
className='checkbox checkbox-sm'
disabled={exportConfig.useCustomTemplate}
/>
<span className='text-sm'>{_('Chapter Separator')}</span>
</label>
<label className='flex cursor-pointer items-center gap-2'>
<input
type='checkbox'
checked={exportConfig.includeQuotes}
onChange={() => handleToggle('includeQuotes')}
className='checkbox checkbox-sm'
disabled={exportConfig.useCustomTemplate}
/>
<span className='text-sm'>{_('Highlights')}</span>
</label>
<label className='flex cursor-pointer items-center gap-2'>
<input
type='checkbox'
checked={exportConfig.includeNotes}
onChange={() => handleToggle('includeNotes')}
className='checkbox checkbox-sm'
disabled={exportConfig.useCustomTemplate}
/>
<span className='text-sm'>{_('Notes')}</span>
</label>
<label className='flex cursor-pointer items-center gap-2'>
<input
type='checkbox'
checked={exportConfig.includeTimestamp}
onChange={() => handleToggle('includeTimestamp')}
className='checkbox checkbox-sm'
disabled={exportConfig.useCustomTemplate}
/>
<span className='text-sm'>{_('Note Date')}</span>
</label>
</div>
</div>
{/* Advanced Options */}
<div className='space-y-3'>
<div className='flex items-center justify-between'>
<h3 className='font-bold'>{_('Advanced')}</h3>
<button
onClick={() => setShowAdvanced(!showAdvanced)}
className='text-sm text-blue-500 hover:underline'
>
{showAdvanced ? _('Hide') : _('Show')}
</button>
</div>
{showAdvanced && (
<div className='space-y-3'>
<label className='flex cursor-pointer items-center gap-2'>
<input
type='checkbox'
checked={exportConfig.useCustomTemplate}
onChange={() => handleToggle('useCustomTemplate')}
className='checkbox checkbox-sm'
/>
<span className='text-sm font-medium'>{_('Use Custom Template')}</span>
</label>
{exportConfig.useCustomTemplate && (
<>
<div className='space-y-2'>
<label className='text-sm font-medium'>{_('Export Template')}</label>
<textarea
value={exportConfig.customTemplate}
onChange={(e) =>
setExportConfig({ ...exportConfig, customTemplate: e.target.value })
}
className='textarea textarea-bordered w-full font-mono text-xs'
rows={12}
placeholder={defaultTemplate}
/>
</div>
<div className='bg-base-200 space-y-3 rounded-lg p-3 text-xs'>
<div>
<p className='mb-2 font-bold'>{_('Template Syntax:')}</p>
<ul className='space-y-1 font-mono'>
<li>
<code className='bg-base-300 rounded px-1'>{'{{ variable }}'}</code> -{' '}
{_('Insert value')}
</li>
<li>
<code className='bg-base-300 rounded px-1'>
{'{{ variable | date }}'}
</code>{' '}
- {_('Format date (locale)')}
</li>
<li>
<code className='bg-base-300 rounded px-1'>
{"{{ variable | date('%Y-%m-%d') }}"}
</code>{' '}
- {_('Format date (custom)')}
</li>
<li>
<code className='bg-base-300 rounded px-1'>
{'{% if variable %}...{% endif %}'}
</code>{' '}
- {_('Conditional')}
</li>
<li>
<code className='bg-base-300 rounded px-1'>
{'{% for item in list %}...{% endfor %}'}
</code>{' '}
- {_('Loop')}
</li>
</ul>
</div>
<div>
<p className='mb-2 font-bold'>{_('Available Variables:')}</p>
<ul className='space-y-1'>
<li>
<code className='bg-base-300 rounded px-1'>title</code> -{' '}
{_('Book title')}
</li>
<li>
<code className='bg-base-300 rounded px-1'>author</code> -{' '}
{_('Book author')}
</li>
<li>
<code className='bg-base-300 rounded px-1'>exportDate</code> -{' '}
{_('Export date')}
</li>
<li>
<code className='bg-base-300 rounded px-1'>chapters</code> -{' '}
{_('Array of chapters')}
</li>
<li className='ml-4'>
<code className='bg-base-300 rounded px-1'>chapter.title</code> -{' '}
{_('Chapter title')}
</li>
<li className='ml-4'>
<code className='bg-base-300 rounded px-1'>chapter.annotations</code> -{' '}
{_('Array of annotations')}
</li>
<li className='ml-8'>
<code className='bg-base-300 rounded px-1'>annotation.text</code> -{' '}
{_('Highlighted text')}
</li>
<li className='ml-8'>
<code className='bg-base-300 rounded px-1'>annotation.note</code> -{' '}
{_('Annotation note')}
</li>
<li className='ml-8'>
<code className='bg-base-300 rounded px-1'>annotation.timestamp</code> -{' '}
{_('Update time')}
</li>
</ul>
</div>
<div>
<p className='mb-2 font-bold'>{_('Date Format Tokens:')}</p>
<ul className='space-y-1 font-mono'>
<li>
<code className='bg-base-300 rounded px-1'>%Y</code> -{' '}
{_('Year (4 digits)')}
</li>
<li>
<code className='bg-base-300 rounded px-1'>%m</code> -{' '}
{_('Month (01-12)')}
</li>
<li>
<code className='bg-base-300 rounded px-1'>%d</code> - {_('Day (01-31)')}
</li>
<li>
<code className='bg-base-300 rounded px-1'>%H</code> - {_('Hour (00-23)')}
</li>
<li>
<code className='bg-base-300 rounded px-1'>%M</code> -{' '}
{_('Minute (00-59)')}
</li>
<li>
<code className='bg-base-300 rounded px-1'>%S</code> -{' '}
{_('Second (00-59)')}
</li>
</ul>
</div>
</div>
</>
)}
</div>
)}
</div>
{/* Preview */}
<div className='space-y-2'>
<div className='flex items-center justify-between'>
<h3 className='font-bold'>{_('Preview')}</h3>
<label className='flex cursor-pointer items-center gap-2'>
<input
type='checkbox'
checked={showSource}
onChange={() => setShowSource(!showSource)}
className='checkbox checkbox-sm'
/>
<span className='text-sm'>{_('Show Source')}</span>
</label>
</div>
{showSource ? (
<div
className={clsx(
'bg-base-200 max-h-[40vh] overflow-y-auto rounded-lg p-4 font-mono text-xs',
'select-text whitespace-pre-wrap break-words',
)}
>
{markdownPreview || _('No content to preview')}
</div>
) : (
<div
className={clsx(
'bg-base-200 prose prose-sm max-w-none overflow-y-auto rounded-lg p-4',
'max-h-[40vh] select-text break-words',
)}
dangerouslySetInnerHTML={{
__html:
htmlPreview ||
`<p class="text-base-content/50">${_('No content to preview')}</p>`,
}}
/>
)}
</div>
{/* Footer Actions */}
<div className='mt-4 flex justify-end gap-4'>
<button onClick={onCancel} className='btn btn-ghost btn-sm'>
{_('Cancel')}
</button>
<button
onClick={handleExport}
className='btn btn-primary btn-sm'
disabled={booknotes.length === 0}
>
{_('Export')}
</button>
</div>
</div>
</Dialog>
);
};
export default ExportMarkdownDialog;