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)
This commit is contained in:
Colton J. McCurdy 2026-06-21 09:16:40 -04:00
parent e4de9fcc0f
commit ddf2e63201
4 changed files with 430 additions and 1 deletions

View file

@ -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)

298
src/digestParser.ts Normal file
View file

@ -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";
}

View file

@ -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;

View file

@ -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);
}