From ebc23c4ce3873a58a17d7489d6669756573304ba Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Mon, 24 Mar 2025 02:33:05 +0800 Subject: [PATCH] feat: export annotations in markdown format, closes #152 (#689) --- .../public/locales/ar/translation.json | 9 +- .../public/locales/de/translation.json | 9 +- .../public/locales/el/translation.json | 9 +- .../public/locales/es/translation.json | 9 +- .../public/locales/fr/translation.json | 9 +- .../public/locales/hi/translation.json | 9 +- .../public/locales/id/translation.json | 9 +- .../public/locales/it/translation.json | 9 +- .../public/locales/ja/translation.json | 9 +- .../public/locales/ko/translation.json | 9 +- .../public/locales/pl/translation.json | 9 +- .../public/locales/pt/translation.json | 9 +- .../public/locales/ru/translation.json | 9 +- .../public/locales/tr/translation.json | 9 +- .../public/locales/uk/translation.json | 9 +- .../public/locales/vi/translation.json | 9 +- .../public/locales/zh-CN/translation.json | 9 +- .../public/locales/zh-TW/translation.json | 9 +- .../reader/components/annotator/Annotator.tsx | 89 ++++++++++++++++++- .../reader/components/sidebar/BookMenu.tsx | 10 +++ .../components/sidebar/BooknoteView.tsx | 9 +- apps/readest-app/src/types/book.ts | 7 ++ 22 files changed, 250 insertions(+), 27 deletions(-) diff --git a/apps/readest-app/public/locales/ar/translation.json b/apps/readest-app/public/locales/ar/translation.json index 4503a230..5782ed40 100644 --- a/apps/readest-app/public/locales/ar/translation.json +++ b/apps/readest-app/public/locales/ar/translation.json @@ -251,5 +251,12 @@ "Small": "صغير", "Large": "كبير", "Auto": "تلقائي", - "Language": "اللغة" + "Language": "اللغة", + "No annotations to export": "لا توجد تعليقات للتصدير", + "Author": "المؤلف", + "Exported from Readest": "تم التصدير من ريديست", + "Highlights & Annotations": "التمييزات والتعليقات", + "Note": "ملاحظة", + "Copied to clipboard": "تم النسخ إلى الحافظة", + "Export Annotations": "تصدير التعليقات" } diff --git a/apps/readest-app/public/locales/de/translation.json b/apps/readest-app/public/locales/de/translation.json index 8bd50287..a06e90fc 100644 --- a/apps/readest-app/public/locales/de/translation.json +++ b/apps/readest-app/public/locales/de/translation.json @@ -251,5 +251,12 @@ "Small": "Klein", "Large": "Groß", "Auto": "Automatisch", - "Language": "Sprache" + "Language": "Sprache", + "No annotations to export": "Keine Anmerkungen zum Exportieren", + "Author": "Autor", + "Exported from Readest": "Exportiert von Readest", + "Highlights & Annotations": "Hervorhebungen & Anmerkungen", + "Note": "Notiz", + "Copied to clipboard": "In die Zwischenablage kopiert", + "Export Annotations": "Anmerkungen exportieren" } diff --git a/apps/readest-app/public/locales/el/translation.json b/apps/readest-app/public/locales/el/translation.json index c481a09b..0d910979 100644 --- a/apps/readest-app/public/locales/el/translation.json +++ b/apps/readest-app/public/locales/el/translation.json @@ -251,5 +251,12 @@ "Small": "Μικρό", "Large": "Μεγάλο", "Auto": "Αυτόματο", - "Language": "Γλώσσα" + "Language": "Γλώσσα", + "No annotations to export": "Δεν υπάρχουν σχολιασμοί για εξαγωγή", + "Author": "Συγγραφέας", + "Exported from Readest": "Εξήχθη από το Readest", + "Highlights & Annotations": "Επισημάνσεις & Σχολιασμοί", + "Note": "Σημείωση", + "Copied to clipboard": "Αντιγράφηκε στο πρόχειρο", + "Export Annotations": "Εξαγωγή σχολιασμών" } diff --git a/apps/readest-app/public/locales/es/translation.json b/apps/readest-app/public/locales/es/translation.json index 7d11e022..44915a1c 100644 --- a/apps/readest-app/public/locales/es/translation.json +++ b/apps/readest-app/public/locales/es/translation.json @@ -251,5 +251,12 @@ "Small": "Pequeño", "Large": "Grande", "Auto": "Automático", - "Language": "Idioma" + "Language": "Idioma", + "No annotations to export": "No hay anotaciones para exportar", + "Author": "Autor", + "Exported from Readest": "Exportado desde Readest", + "Highlights & Annotations": "Resaltados y anotaciones", + "Note": "Nota", + "Copied to clipboard": "Copiado al portapapeles", + "Export Annotations": "Exportar anotaciones" } diff --git a/apps/readest-app/public/locales/fr/translation.json b/apps/readest-app/public/locales/fr/translation.json index 5f31c631..c22b77d3 100644 --- a/apps/readest-app/public/locales/fr/translation.json +++ b/apps/readest-app/public/locales/fr/translation.json @@ -251,5 +251,12 @@ "Small": "Petit", "Large": "Grand", "Auto": "Automatique", - "Language": "Langue" + "Language": "Langue", + "No annotations to export": "Aucune annotation à exporter", + "Author": "Auteur", + "Exported from Readest": "Exporté depuis Readest", + "Highlights & Annotations": "Surlignages et annotations", + "Note": "Note", + "Copied to clipboard": "Copié dans le presse-papiers", + "Export Annotations": "Exporter les annotations" } diff --git a/apps/readest-app/public/locales/hi/translation.json b/apps/readest-app/public/locales/hi/translation.json index 27f1e595..e292f38b 100644 --- a/apps/readest-app/public/locales/hi/translation.json +++ b/apps/readest-app/public/locales/hi/translation.json @@ -251,5 +251,12 @@ "Small": "छोटा", "Large": "बड़ा", "Auto": "स्वचालित", - "Language": "भाषा" + "Language": "भाषा", + "No annotations to export": "निर्यात करने के लिए कोई टिप्पणियाँ नहीं हैं", + "Author": "लेखक", + "Exported from Readest": "Readest से निर्यात किया गया", + "Highlights & Annotations": "हाइलाइट और टिप्पणियाँ", + "Note": "टिप्पणी", + "Copied to clipboard": "क्लिपबोर्ड पर कॉपी किया गया", + "Export Annotations": "टिप्पणियाँ निर्यात करें" } diff --git a/apps/readest-app/public/locales/id/translation.json b/apps/readest-app/public/locales/id/translation.json index a13b49a2..dc810fff 100644 --- a/apps/readest-app/public/locales/id/translation.json +++ b/apps/readest-app/public/locales/id/translation.json @@ -251,5 +251,12 @@ "Small": "Kecil", "Large": "Besar", "Auto": "Otomatis", - "Language": "Bahasa" + "Language": "Bahasa", + "No annotations to export": "Tidak ada anotasi yang diekspor", + "Author": "Penulis", + "Exported from Readest": "Diekspor dari Readest", + "Highlights & Annotations": "Sorotan & Anotasi", + "Note": "Catatan", + "Copied to clipboard": "Disalin ke papan klip", + "Export Annotations": "Ekspor Anotasi" } diff --git a/apps/readest-app/public/locales/it/translation.json b/apps/readest-app/public/locales/it/translation.json index 5ffecd16..ad9823a4 100644 --- a/apps/readest-app/public/locales/it/translation.json +++ b/apps/readest-app/public/locales/it/translation.json @@ -251,5 +251,12 @@ "Small": "Piccolo", "Large": "Grande", "Auto": "Automatico", - "Language": "Lingua" + "Language": "Lingua", + "No annotations to export": "Nessuna annotazione da esportare", + "Author": "Autore", + "Exported from Readest": "Esportato da Readest", + "Highlights & Annotations": "Evidenziazioni e annotazioni", + "Note": "Nota", + "Copied to clipboard": "Copiato negli appunti", + "Export Annotations": "Esporta annotazioni" } diff --git a/apps/readest-app/public/locales/ja/translation.json b/apps/readest-app/public/locales/ja/translation.json index 632051d4..381f60c2 100644 --- a/apps/readest-app/public/locales/ja/translation.json +++ b/apps/readest-app/public/locales/ja/translation.json @@ -251,5 +251,12 @@ "Small": "小", "Large": "大", "Auto": "自動", - "Language": "言語" + "Language": "言語", + "No annotations to export": "エクスポートする注釈はありません", + "Author": "著者", + "Exported from Readest": "Readestからエクスポート", + "Highlights & Annotations": "ハイライトと注釈", + "Note": "メモ", + "Copied to clipboard": "クリップボードにコピー", + "Export Annotations": "注釈をエクスポート" } diff --git a/apps/readest-app/public/locales/ko/translation.json b/apps/readest-app/public/locales/ko/translation.json index 48a5095c..965127e3 100644 --- a/apps/readest-app/public/locales/ko/translation.json +++ b/apps/readest-app/public/locales/ko/translation.json @@ -251,5 +251,12 @@ "Small": "작음", "Large": "큼", "Auto": "자동", - "Language": "언어" + "Language": "언어", + "No annotations to export": "내보낼 주석이 없습니다", + "Author": "저자", + "Exported from Readest": "Readest에서 내보냄", + "Highlights & Annotations": "하이라이트 및 주석", + "Note": "노트", + "Copied to clipboard": "클립보드에 복사", + "Export Annotations": "주석 내보내기" } diff --git a/apps/readest-app/public/locales/pl/translation.json b/apps/readest-app/public/locales/pl/translation.json index 0b5a2a3a..a09e9f1f 100644 --- a/apps/readest-app/public/locales/pl/translation.json +++ b/apps/readest-app/public/locales/pl/translation.json @@ -251,5 +251,12 @@ "Small": "Mały", "Large": "Duży", "Auto": "Automatyczny", - "Language": "Język" + "Language": "Język", + "No annotations to export": "Brak adnotacji do eksportu", + "Author": "Autor", + "Exported from Readest": "Eksportowane z Readest", + "Highlights & Annotations": "Zaznaczenia i adnotacje", + "Note": "Notatka", + "Copied to clipboard": "Skopiowano do schowka", + "Export Annotations": "Eksportuj adnotacje" } diff --git a/apps/readest-app/public/locales/pt/translation.json b/apps/readest-app/public/locales/pt/translation.json index eba6d773..12c0bf8f 100644 --- a/apps/readest-app/public/locales/pt/translation.json +++ b/apps/readest-app/public/locales/pt/translation.json @@ -251,5 +251,12 @@ "Small": "Pequeno", "Large": "Grande", "Auto": "Automático", - "Language": "Idioma" + "Language": "Idioma", + "No annotations to export": "Nenhuma anotação para exportar", + "Author": "Autor", + "Exported from Readest": "Exportado do Readest", + "Highlights & Annotations": "Destaques e Anotações", + "Note": "Nota", + "Copied to clipboard": "Copiado para a área de transferência", + "Export Annotations": "Exportar Anotações" } diff --git a/apps/readest-app/public/locales/ru/translation.json b/apps/readest-app/public/locales/ru/translation.json index 2fb23126..615d6827 100644 --- a/apps/readest-app/public/locales/ru/translation.json +++ b/apps/readest-app/public/locales/ru/translation.json @@ -251,5 +251,12 @@ "Small": "Маленький", "Large": "Большой", "Auto": "Авто", - "Language": "Язык" + "Language": "Язык", + "No annotations to export": "Нет аннотаций для экспорта", + "Author": "Автор", + "Exported from Readest": "Экспортировано из Readest", + "Highlights & Annotations": "Выделения и аннотации", + "Note": "Заметка", + "Copied to clipboard": "Скопировано в буфер обмена", + "Export Annotations": "Экспортировать аннотации" } diff --git a/apps/readest-app/public/locales/tr/translation.json b/apps/readest-app/public/locales/tr/translation.json index 97f7e30f..79c490fc 100644 --- a/apps/readest-app/public/locales/tr/translation.json +++ b/apps/readest-app/public/locales/tr/translation.json @@ -251,5 +251,12 @@ "Small": "Küçük", "Large": "Büyük", "Auto": "Otomatik", - "Language": "Dil" + "Language": "Dil", + "No annotations to export": "Dışa aktarılacak not yok", + "Author": "Yazar", + "Exported from Readest": "Readest'ten dışa aktarıldı", + "Highlights & Annotations": "Vurgular ve Notlar", + "Note": "Not", + "Copied to clipboard": "Panoya kopyalandı", + "Export Annotations": "Notları Dışa Aktar" } diff --git a/apps/readest-app/public/locales/uk/translation.json b/apps/readest-app/public/locales/uk/translation.json index f889a4b3..571ba7b6 100644 --- a/apps/readest-app/public/locales/uk/translation.json +++ b/apps/readest-app/public/locales/uk/translation.json @@ -251,5 +251,12 @@ "Small": "Малий", "Large": "Великий", "Auto": "Автоматичний", - "Language": "Мова" + "Language": "Мова", + "No annotations to export": "Немає анотацій для експорту", + "Author": "Автор", + "Exported from Readest": "Експортовано з Readest", + "Highlights & Annotations": "Виділення та анотації", + "Note": "Примітка", + "Copied to clipboard": "Скопійовано в буфер обміну", + "Export Annotations": "Експортувати анотації" } diff --git a/apps/readest-app/public/locales/vi/translation.json b/apps/readest-app/public/locales/vi/translation.json index e0402b88..7bee7350 100644 --- a/apps/readest-app/public/locales/vi/translation.json +++ b/apps/readest-app/public/locales/vi/translation.json @@ -251,5 +251,12 @@ "Small": "Nhỏ", "Large": "Lớn", "Auto": "Tự động", - "Language": "Ngôn ngữ" + "Language": "Ngôn ngữ", + "No annotations to export": "Không có chú thích để xuất", + "Author": "Tác giả", + "Exported from Readest": "Đã xuất từ Readest", + "Highlights & Annotations": "Điểm nổi bật & Chú thích", + "Note": "Ghi chú", + "Copied to clipboard": "Đã sao chép vào bảng ghi", + "Export Annotations": "Xuất chú thích" } diff --git a/apps/readest-app/public/locales/zh-CN/translation.json b/apps/readest-app/public/locales/zh-CN/translation.json index 58492727..3d91f1af 100644 --- a/apps/readest-app/public/locales/zh-CN/translation.json +++ b/apps/readest-app/public/locales/zh-CN/translation.json @@ -251,5 +251,12 @@ "Small": "小", "Large": "大", "Auto": "自动", - "Language": "语言" + "Language": "语言", + "No annotations to export": "没有要导出的笔记", + "Author": "作者", + "Exported from Readest": "从 Readest 导出", + "Highlights & Annotations": "划线和笔记", + "Note": "笔记", + "Copied to clipboard": "已复制到剪贴板", + "Export Annotations": "导出笔记" } diff --git a/apps/readest-app/public/locales/zh-TW/translation.json b/apps/readest-app/public/locales/zh-TW/translation.json index 261d7682..6c5e43d5 100644 --- a/apps/readest-app/public/locales/zh-TW/translation.json +++ b/apps/readest-app/public/locales/zh-TW/translation.json @@ -251,5 +251,12 @@ "Small": "小", "Large": "大", "Auto": "自動", - "Language": "語言" + "Language": "語言", + "No annotations to export": "沒有要匯出的筆記", + "Author": "作者", + "Exported from Readest": "從 Readest 匯出", + "Highlights & Annotations": "高亮和筆記", + "Note": "筆記", + "Copied to clipboard": "已複製到剪貼板", + "Export Annotations": "匯出筆記" } diff --git a/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx b/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx index 259924f0..b462543f 100644 --- a/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx +++ b/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx @@ -12,7 +12,7 @@ import { FaHeadphones } from 'react-icons/fa6'; import * as CFI from 'foliate-js/epubcfi.js'; import { Overlayer } from 'foliate-js/overlayer.js'; import { useEnv } from '@/context/EnvContext'; -import { BookNote, HighlightColor, HighlightStyle } from '@/types/book'; +import { BookNote, BooknoteGroup, HighlightColor, HighlightStyle } from '@/types/book'; import { getOSPlatform, uniqueId } from '@/utils/misc'; import { useBookDataStore } from '@/store/bookDataStore'; import { useSettingsStore } from '@/store/settingsStore'; @@ -24,6 +24,7 @@ import { useFoliateEvents } from '../../hooks/useFoliateEvents'; import { useNotesSync } from '../../hooks/useNotesSync'; import { getPopupPosition, getPosition, Position, TextSelection } from '@/utils/sel'; import { eventDispatcher } from '@/utils/event'; +import { findTocItemBS } from '@/utils/toc'; import { HIGHLIGHT_COLOR_HEX } from '@/services/constants'; import AnnotationPopup from './AnnotationPopup'; import WiktionaryPopup from './WiktionaryPopup'; @@ -240,8 +241,10 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { }; eventDispatcher.onSync('iframe-single-click', handleSingleClick); + eventDispatcher.on('export-annotations', handleExportMarkdown); return () => { eventDispatcher.offSync('iframe-single-click', handleSingleClick); + eventDispatcher.off('export-annotations', handleExportMarkdown); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -438,6 +441,90 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { eventDispatcher.dispatch('tts-speak', { bookKey, range: selection.range }); }; + const handleExportMarkdown = (event: CustomEvent) => { + const { bookKey: exportBookKey } = event.detail; + if (bookKey !== exportBookKey) return; + + const { bookDoc, book } = bookData; + if (!bookDoc || !book || !bookDoc.toc) return; + + const { booknotes: allNotes = [] } = config; + const booknotes = allNotes.filter((note) => !note.deletedAt); + if (booknotes.length === 0) { + eventDispatcher.dispatch('toast', { + type: 'info', + message: _('No annotations to export'), + className: 'whitespace-nowrap', + timeout: 2000, + }); + return; + } + const booknoteGroups: { [href: string]: BooknoteGroup } = {}; + for (const booknote of booknotes) { + const tocItem = findTocItemBS(bookDoc.toc ?? [], booknote.cfi); + const href = tocItem?.href || ''; + const label = tocItem?.label || ''; + const id = tocItem?.id || 0; + if (!booknoteGroups[href]) { + booknoteGroups[href] = { id, href, label, booknotes: [] }; + } + booknoteGroups[href].booknotes.push(booknote); + } + + Object.values(booknoteGroups).forEach((group) => { + group.booknotes.sort((a, b) => { + return CFI.compare(a.cfi, b.cfi); + }); + }); + + const sortedGroups = Object.values(booknoteGroups).sort((a, b) => { + return a.id - b.id; + }); + + 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(''); + + 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'); + + navigator.clipboard?.writeText(markdownContent); + eventDispatcher.dispatch('toast', { + type: 'info', + message: _('Copied to clipboard'), + className: 'whitespace-nowrap', + timeout: 2000, + }); + if (appService?.isMobile) return; + const blob = new Blob([markdownContent], { type: 'text/markdown;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${book.title.replace(/\s+/g, '_')}.md`; + a.click(); + URL.revokeObjectURL(url); + }; + const selectionAnnotated = selection?.annotated; const buttons = [ { tooltipText: _('Copy'), Icon: FiCopy, onClick: handleCopy }, diff --git a/apps/readest-app/src/app/reader/components/sidebar/BookMenu.tsx b/apps/readest-app/src/app/reader/components/sidebar/BookMenu.tsx index 0130b83f..967d3cb7 100644 --- a/apps/readest-app/src/app/reader/components/sidebar/BookMenu.tsx +++ b/apps/readest-app/src/app/reader/components/sidebar/BookMenu.tsx @@ -5,8 +5,10 @@ import Image from 'next/image'; import MenuItem from '@/components/MenuItem'; import { setAboutDialogVisible } from '@/components/AboutWindow'; import { useLibraryStore } from '@/store/libraryStore'; +import { useSidebarStore } from '@/store/sidebarStore'; import { useTranslation } from '@/hooks/useTranslation'; import { isWebAppPlatform } from '@/services/environment'; +import { eventDispatcher } from '@/utils/event'; import { DOWNLOAD_READEST_URL } from '@/services/constants'; import useBooksManager from '../../hooks/useBooksManager'; @@ -19,6 +21,8 @@ const BookMenu: React.FC = ({ menuClassName, setIsDropdownOpen }) const _ = useTranslation(); const { getVisibleLibrary } = useLibraryStore(); const { openParallelView } = useBooksManager(); + const { sideBarBookKey } = useSidebarStore(); + const handleParallelView = (id: string) => { openParallelView(id); setIsDropdownOpen?.(false); @@ -35,6 +39,10 @@ const BookMenu: React.FC = ({ menuClassName, setIsDropdownOpen }) window.open(DOWNLOAD_READEST_URL, '_blank'); setIsDropdownOpen?.(false); }; + const handleExportAnnotations = () => { + eventDispatcher.dispatch('export-annotations', { bookKey: sideBarBookKey }); + setIsDropdownOpen?.(false); + }; const isWebApp = isWebAppPlatform(); @@ -49,6 +57,7 @@ const BookMenu: React.FC = ({ menuClassName, setIsDropdownOpen })
    {getVisibleLibrary() + .filter((book) => book.format !== 'PDF' && book.format !== 'CBZ') .slice(0, 20) .map((book) => ( = ({ menuClassName, setIsDropdownOpen }) ))}
+
{isWebApp && } diff --git a/apps/readest-app/src/app/reader/components/sidebar/BooknoteView.tsx b/apps/readest-app/src/app/reader/components/sidebar/BooknoteView.tsx index 527b3545..fd1dd58b 100644 --- a/apps/readest-app/src/app/reader/components/sidebar/BooknoteView.tsx +++ b/apps/readest-app/src/app/reader/components/sidebar/BooknoteView.tsx @@ -4,16 +4,9 @@ import * as CFI from 'foliate-js/epubcfi.js'; import { useBookDataStore } from '@/store/bookDataStore'; import { findTocItemBS } from '@/utils/toc'; import { TOCItem } from '@/libs/document'; -import { BookNote, BookNoteType } from '@/types/book'; +import { BooknoteGroup, BookNoteType } from '@/types/book'; import BooknoteItem from './BooknoteItem'; -interface BooknoteGroup { - id: number; - href: string; - label: string; - booknotes: BookNote[]; -} - const BooknoteView: React.FC<{ type: BookNoteType; bookKey: string; diff --git a/apps/readest-app/src/types/book.ts b/apps/readest-app/src/types/book.ts index 3f45b9bd..cfced8f9 100644 --- a/apps/readest-app/src/types/book.ts +++ b/apps/readest-app/src/types/book.ts @@ -53,6 +53,13 @@ export interface BookNote { deletedAt?: number | null; } +export interface BooknoteGroup { + id: number; + href: string; + label: string; + booknotes: BookNote[]; +} + export type WritingMode = 'auto' | 'horizontal-tb' | 'horizontal-rl' | 'vertical-rl'; export interface BookLayout {