mirror of
https://github.com/philips/supernote-obsidian-plugin.git
synced 2026-07-22 09:50:25 +00:00
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:
parent
e4de9fcc0f
commit
ddf2e63201
4 changed files with 430 additions and 1 deletions
10
README.md
10
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**
|
||||
|
||||
[](https://www.youtube.com/watch?v=tEoW35fYVew)
|
||||
|
|
|
|||
298
src/digestParser.ts
Normal file
298
src/digestParser.ts
Normal 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";
|
||||
}
|
||||
102
src/main.ts
102
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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue