From ddf2e63201167e2726c583fa9d87f3a4720e498d Mon Sep 17 00:00:00 2001 From: "Colton J. McCurdy" Date: Sun, 21 Jun 2026 09:16:40 -0400 Subject: [PATCH] feat(digest): add import digest command for Supernote highlights Add a new 'Import digest' command that converts Supernote digest .txt exports into Readwise-style Obsidian markdown files. - Parse digest blocks extracting title, author, year from path - Derive category from directory structure (#books, #articles, etc.) - Group highlights by book, one markdown file per book - Sort highlights by chapter, then page, then modify time - Configurable output folder (default: 'Supernote Digests') - Overwrites existing files on re-import (idempotent) --- README.md | 10 ++ src/digestParser.ts | 298 ++++++++++++++++++++++++++++++++++++++++++++ src/main.ts | 102 ++++++++++++++- src/settings.ts | 21 ++++ 4 files changed, 430 insertions(+), 1 deletion(-) create mode 100644 src/digestParser.ts diff --git a/README.md b/README.md index 1095f27..da2bbf5 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,16 @@ This plugin has four main features: - ⬇️ Download & Upload files directly from your device via the Supernote [Browse & Access](https://support.supernote.com/en_US/Tools-Features/wi-fi-transfer) feature. ([demo video](https://www.youtube.com/watch?v=SEkp395hbBM)) +- 📖 Import digest highlights from your Supernote into + Obsidian as Readwise-style markdown. On your Supernote, + open the Digest app, select all highlights, and export + as `.txt`. Then in Obsidian, open the command palette + and run "Import digest" to select your exported file. + Highlights are grouped by book with metadata (author, + year, category) and sorted by chapter, page, then + modification time. Output folder is configurable in + Settings. + **Video Demo** [![Watch the video](https://img.youtube.com/vi/tEoW35fYVew/hqdefault.jpg)](https://www.youtube.com/watch?v=tEoW35fYVew) diff --git a/src/digestParser.ts b/src/digestParser.ts new file mode 100644 index 0000000..f293030 --- /dev/null +++ b/src/digestParser.ts @@ -0,0 +1,298 @@ +export interface DigestHighlight { + content: string; + page: number; + chapter: string; +} + +export interface DigestBook { + title: string; + author: string; + year: string; + category: string; + readDate: string; + highlights: DigestHighlight[]; +} + +interface ParsedBlock { + path: string; + page: number; + chapter: string; + charStart: number; + modifyTime: number; + content: string; +} + +function deriveCategory(path: string): string { + if (path.includes("/Books/")) return "#books"; + if (path.includes("/Articles/")) return "#articles"; + if ( + path.includes("/Documents/") || + path.includes("/Document/") + ) { + return "#documents"; + } + return "#highlights"; +} + +function parsePathSegments( + path: string, +): { title: string; author: string; year: string } | null { + const fileProto = "file://"; + const raw = path.startsWith(fileProto) + ? path.slice(fileProto.length) + : path; + + const lastSlash = raw.lastIndexOf("/"); + const filename = + lastSlash >= 0 ? raw.slice(lastSlash + 1) : raw; + + // Strip file extension + const dotIdx = filename.lastIndexOf("."); + const base = dotIdx >= 0 + ? filename.slice(0, dotIdx) + : filename; + + const segments = base.split("--").map((s) => s.trim()); + + // No "--" delimiters: use filename as title, + // leave author and year empty. + if (segments.length < 2) { + return { title: base.trim(), author: '', year: '' }; + } + + const title = segments[0]; + const author = segments.length >= 2 + ? segments[1] : ''; + + let year = ''; + if (segments.length >= 3) { + const editionYear = segments[2]; + const parts = editionYear + .split(",").map((p) => p.trim()); + year = parts.length >= 2 + ? parts[parts.length - 1] : parts[0]; + } + + return { title, author, year }; +} + +function extractField( + block: string, + field: string, +): string | null { + // Support both flat ("linkinfo.path:") and nested + // ("linkinfo:\n path:") formats. For nested fields + // like "linkinfo.path", extract the leaf key "path". + const parts = field.split("."); + const leafKey = parts[parts.length - 1]; + const prefix = `${leafKey}:`; + for (const line of block.split("\n")) { + const trimmed = line.trim(); + if (trimmed.startsWith(prefix)) { + return trimmed.slice(prefix.length).trim(); + } + } + return null; +} + +function extractContent(block: string): string | null { + const marker = "content:\n"; + const idx = block.indexOf(marker); + if (idx < 0) return null; + + const raw = block.slice(idx + marker.length); + const lines = raw + .split("\n") + .map((l) => l.trim()) + .filter((l) => l.length > 0); + + return lines.length > 0 ? lines.join(" ") : null; +} + +function formatDate(timestamp: number): string { + const d = new Date(timestamp); + const yyyy = d.getFullYear(); + const mm = String(d.getMonth() + 1).padStart(2, '0'); + const dd = String(d.getDate()).padStart(2, '0'); + return `${yyyy}-${mm}-${dd}`; +} + +function parseChapterNumber(chapter: string): string { + const dash = chapter.indexOf("-"); + return dash >= 0 ? chapter.slice(0, dash) : chapter; +} + +function parseCharStart(chapter: string): number { + const match = chapter.match(/\[(\d+)-/); + return match ? parseInt(match[1], 10) : 0; +} + +function parseBlock(raw: string): ParsedBlock | null { + const path = extractField(raw, "linkinfo.path"); + const pageStr = extractField(raw, "linkinfo.page"); + const chapter = extractField( + raw, "linkinfo.chapter", + ); + const content = extractContent(raw); + + if (!path || !pageStr || !content) { + return null; + } + + const page = parseInt(pageStr, 10); + if (isNaN(page)) return null; + + const charStart = chapter + ? parseCharStart(chapter) + : 0; + + const modifyTimeStr = extractField( + raw, "modify_time.timestamp", + ); + const modifyTime = modifyTimeStr + ? parseInt(modifyTimeStr, 10) + : 0; + + return { + path, page, chapter: chapter || "", + charStart, modifyTime, content, + }; +} + +export function parseDigest(text: string): DigestBook[] { + const trimmed = text.trim(); + if (!trimmed) return []; + + // Strip outer brackets and split on block boundaries + const inner = trimmed.startsWith("[") + ? trimmed.slice(1) + : trimmed; + const stripped = inner.endsWith("]") + ? inner.slice(0, -1) + : inner; + + const rawBlocks = stripped.split("]\n\n["); + + const bookMap = new Map< + string, + { + title: string; + author: string; + year: string; + category: string; + maxModifyTime: number; + highlights: (DigestHighlight & { + charStart: number; + modifyTime: number; + })[]; + } + >(); + + for (const raw of rawBlocks) { + const parsed = parseBlock(raw); + if (!parsed) continue; + + const meta = parsePathSegments(parsed.path); + if (!meta) continue; + + const key = `${meta.title}\0${meta.author}`; + const chapterNum = parseChapterNumber(parsed.chapter); + const category = deriveCategory(parsed.path); + + if (!bookMap.has(key)) { + bookMap.set(key, { + title: meta.title, + author: meta.author, + year: meta.year, + category, + maxModifyTime: 0, + highlights: [], + }); + } + + const entry = bookMap.get(key)!; + if (parsed.modifyTime > entry.maxModifyTime) { + entry.maxModifyTime = parsed.modifyTime; + } + + entry.highlights.push({ + content: parsed.content, + page: parsed.page, + chapter: chapterNum, + charStart: parsed.charStart, + modifyTime: parsed.modifyTime, + }); + } + + const books: DigestBook[] = []; + for (const entry of bookMap.values()) { + entry.highlights.sort((a, b) => { + // Sort by chapter first (if chapters exist) + const chA = parseInt(a.chapter, 10); + const chB = parseInt(b.chapter, 10); + const hasChA = !isNaN(chA); + const hasChB = !isNaN(chB); + if (hasChA && hasChB && chA !== chB) { + return chA - chB; + } + // Then by page + if (a.page !== b.page) return a.page - b.page; + // Then by modify time + if (a.modifyTime !== b.modifyTime) { + return a.modifyTime - b.modifyTime; + } + return a.charStart - b.charStart; + }); + + const readDate = entry.maxModifyTime > 0 + ? formatDate(entry.maxModifyTime) + : ''; + + books.push({ + title: entry.title, + author: entry.author, + year: entry.year, + category: entry.category, + readDate, + highlights: entry.highlights.map( + ({ charStart: _, modifyTime: __, ...h }) => h, + ), + }); + } + + return books; +} + +export function generateDigestMarkdown( + book: DigestBook, +): string { + const lines: string[] = []; + + lines.push(`# ${book.title}`); + lines.push(""); + lines.push("## Metadata"); + if (book.author) { + lines.push(`- Author: [[${book.author}]]`); + } + lines.push(`- Full Title: ${book.title}`); + if (book.year) { + lines.push(`- Year: ${book.year}`); + } + lines.push(`- Category: ${book.category}`); + if (book.readDate) { + lines.push(`- Read Date: ${book.readDate}`); + } + lines.push(""); + lines.push("## Highlights"); + + for (const h of book.highlights) { + const chNum = parseInt(h.chapter, 10); + const showChapter = h.chapter && chNum > 0; + const loc = showChapter + ? `Page ${h.page}, Chapter ${h.chapter}` + : `Page ${h.page}`; + lines.push(`- ${h.content} (${loc})`); + } + + return lines.join("\n") + "\n"; +} diff --git a/src/main.ts b/src/main.ts index 8ff94d0..21e3841 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,5 +1,5 @@ import { installAtPolyfill } from './polyfills'; -import { App, Modal, TFile, Plugin, Editor, MarkdownView, WorkspaceLeaf, FileView } from 'obsidian'; +import { App, Modal, Notice, TFile, Plugin, Editor, MarkdownView, WorkspaceLeaf, FileView } from 'obsidian'; import { SupernotePluginSettings, SupernoteSettingTab, DEFAULT_SETTINGS } from './settings'; import { SupernoteX, fetchMirrorFrame } from 'supernote-typescript'; import { DownloadListModal, UploadListModal } from './FileListModal'; @@ -7,6 +7,10 @@ import { jsPDF } from 'jspdf'; import { SupernoteWorkerMessage, SupernoteWorkerResponse } from './myworker.worker'; import Worker from 'myworker.worker'; import { replaceTextWithCustomDictionary } from './customDictionary'; +import { + parseDigest, + generateDigestMarkdown, +} from './digestParser'; function generateTimestamp(): string { const date = new Date(); @@ -549,12 +553,108 @@ export default class SupernotePlugin extends Plugin { return false; }, }); + + this.addCommand({ + id: 'import-digest', + name: 'Import digest', + callback: async () => { + const input = + document.createElement('input'); + input.type = 'file'; + input.accept = '.txt'; + input.addEventListener( + 'change', + async () => { + const file = + input.files?.[0]; + if (!file) return; + try { + const text = + await file.text(); + const books = + parseDigest(text); + if ( + books.length === 0 + ) { + new ErrorModal( + this.app, + new Error( + 'No highlights' + + ' found in' + + ' digest' + + ' file', + ), + ).open(); + return; + } + await this + .importDigestBooks( + books, + ); + } catch (err: any) { + new ErrorModal( + this.app, + err, + ).open(); + } + }, + ); + input.click(); + }, + }); } onunload() { } + async importDigestBooks( + books: import('./digestParser').DigestBook[], + ) { + const folder = + this.settings.digestOutputFolder + || 'Supernote Digests'; + + // Ensure output folder exists + if ( + !this.app.vault + .getAbstractFileByPath(folder) + ) { + await this.app.vault + .createFolder(folder); + } + + let imported = 0; + for (const book of books) { + const markdown = + generateDigestMarkdown(book); + const sanitized = book.title + .replace(/[\\/:*?"<>|]/g, '-'); + const filepath = + `${folder}/${sanitized}.md`; + + const existing = + this.app.vault + .getFileByPath(filepath); + if (existing) { + await this.app.vault.modify( + existing, + markdown, + ); + } else { + await this.app.vault.create( + filepath, + markdown, + ); + } + imported++; + } + + new Notice( + `Imported ${imported} book(s) from digest`, + ); + } + async activateView() { const { workspace } = this.app; diff --git a/src/settings.ts b/src/settings.ts index 80dfeaf..8df0234 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -12,6 +12,7 @@ export interface SupernotePluginSettings extends CustomDictionarySettings { showExportButtons: boolean; collapseRecognizedText: boolean, noteImageMaxDim: number; + digestOutputFolder: string; } export const DEFAULT_SETTINGS: SupernotePluginSettings = { @@ -21,6 +22,7 @@ export const DEFAULT_SETTINGS: SupernotePluginSettings = { showExportButtons: true, collapseRecognizedText: false, noteImageMaxDim: 800, // Sensible default for Nomad pages to be legible but not too big. Unit: px + digestOutputFolder: 'Supernote Digests', ...CUSTOM_DICTIONARY_DEFAULT_SETTINGS, } @@ -126,6 +128,25 @@ export class SupernoteSettingTab extends PluginSettingTab { }) ); + new Setting(containerEl) + .setName('Digest output folder') + .setDesc( + 'Folder in your vault where imported digest' + + ' highlights will be saved. One file per' + + ' book/document.', + ) + .addText(text => text + .setPlaceholder('Supernote Digests') + .setValue( + this.plugin.settings.digestOutputFolder, + ) + .onChange(async (value) => { + this.plugin.settings + .digestOutputFolder = value; + await this.plugin.saveSettings(); + }) + ); + // Add custom dictionary settings to the settings tab createCustomDictionarySettingsUI(containerEl, this.plugin); }