Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions esbuild.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ import esbuild from "esbuild";
import process from "process";
import builtins from "builtin-modules";
import inlineWorkerPlugin from "esbuild-plugin-inline-worker";
import { createRequire } from "module";

const require = createRequire(import.meta.url);
const bresenhamMjs = require.resolve("bresenham-zingl/dist/index.mjs");

const banner =
`/*
Expand All @@ -18,10 +22,11 @@ const context = await esbuild.context({
},
entryPoints: ["src/main.ts"],
alias: {
'supernote-typescript': 'supernote-typescript'
'supernote-typescript': 'supernote-typescript',
'bresenham-zingl': bresenhamMjs,
},
bundle: true,
plugins: [inlineWorkerPlugin()],
plugins: [inlineWorkerPlugin({ alias: { 'bresenham-zingl': bresenhamMjs } })],
external: [
"obsidian",
"electron",
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
"dependencies": {
"esbuild-plugin-inline-worker": "^0.1.1",
"fast-png": "^7.0.1",
"image-js": "^0.35.6",
"image-js": "1.2.0",
"jspdf": "^2.5.2",
"supernote": "github:philips/supernote-typescript"
}
Expand Down
273 changes: 256 additions & 17 deletions src/main.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { installAtPolyfill } from './polyfills';
import { App, Modal, TFile, Plugin, Editor, MarkdownView, WorkspaceLeaf, FileView } from 'obsidian';
import { SupernotePluginSettings, SupernoteSettingTab, DEFAULT_SETTINGS } from './settings';
import { SupernoteX, fetchMirrorFrame } from 'supernote-typescript';
import { SupernoteX, fetchMirrorFrame, ILink } from 'supernote-typescript';
import { encode } from 'image-js';
import { DownloadListModal, UploadListModal } from './FileListModal';
import { jsPDF } from 'jspdf';
import { SupernoteWorkerMessage, SupernoteWorkerResponse } from './myworker.worker';
Expand Down Expand Up @@ -35,18 +36,80 @@ function dataUrlToBuffer(dataUrl: string): ArrayBuffer {
return bytes.buffer;
}

/**
* Transforms #-prefixed words into vault tags or headings, and @-mentions into [[links]].
*
* For each `#word` or `# word` token:
* - If `word` is already a tag in the vault → `#word` (inline tag, no space)
* - Otherwise → `# word` (heading, space added)
*
* For each `@word` token → `[[word]]`
*/
/**
* noteKeywordTags: map from lowercase-normalized key (e.g. "new_tag") to the
* canonical tag string with # (e.g. "#New_tag"), derived from this note's own
* keyword stars. Used so that brand-new tags not yet indexed in the vault are
* still recognised as tags rather than headings.
*/
function processHashtagsAndMentions(
text: string,
app: App,
noteKeywordTags: Map<string, string> = new Map(),
): string {
const tagSet = new Set<string>();
try {
// getTags() is an undocumented MetadataCache method that returns all vault
// tags as Record<string, number> — much cheaper than iterating every file.
const allTags: Record<string, number> = (app.metadataCache as any).getTags();
for (const tag of Object.keys(allTags)) {
tagSet.add(tag.toLowerCase());
}
} catch {
// Vault tag lookup is best-effort; fall back to treating all #words as headings
}

const result = text
.replace(/#\s*(\w[\w-]*)(\s+\w[\w-]*)?/g, (_match, word, next) => {
if (next) {
const nextWord = next.trim();
const twoWordKey = `${word.toLowerCase()}_${nextWord.toLowerCase()}`;
// Note's own keyword tags take priority (canonical capitalisation).
if (noteKeywordTags.has(twoWordKey)) return noteKeywordTags.get(twoWordKey)!;
// Existing vault tag.
if (tagSet.has(`#${twoWordKey}`)) return `#${word}_${nextWord}`;
}
const singleKey = word.toLowerCase();
if (noteKeywordTags.has(singleKey)) return noteKeywordTags.get(singleKey)!;
if (tagSet.has(`#${singleKey}`)) return `#${word}${next ?? ''}`;
// Not a tag → Markdown heading.
return `# ${word}${next ?? ''}`;
})
.replace(/@\s*(.+)/g, (_, text) => `[[${text.trim()}]]`);

return result;
}

/**
* Processes the Supernote text based on the provided settings.
*
*
* @param text - The input text to be processed.
* @param settings - The settings for the Supernote plugin.
* @param app - The Obsidian app instance (used for tag lookup).
* @returns The processed text.
*/
function processSupernoteText(text: string, settings: SupernotePluginSettings): string {
function processSupernoteText(
text: string,
settings: SupernotePluginSettings,
app: App,
noteKeywordTags: Map<string, string> = new Map(),
): string {
let processedText = text;
if (settings.isCustomDictionaryEnabled) {
processedText = replaceTextWithCustomDictionary(processedText, settings.customDictionary);
}
if (settings.isHashtagsMentionsEnabled) {
processedText = processHashtagsAndMentions(processedText, app, noteKeywordTags);
}
return processedText;
}

Expand Down Expand Up @@ -146,23 +209,114 @@ class VaultWriter {
this.settings = settings;
}

async writeMarkdownFile(file: TFile, sn: SupernoteX, imgs: TFile[] | null) {
async writeMarkdownFile(file: TFile, sn: SupernoteX, imgs: TFile[] | null, overwrite = false) {
let content = '';

// Generate a non-conflicting filename - it has a bit of a race but that is OK
let filename = `${file.parent?.path}/${file.basename}.md`;
let i = 0;
while (this.app.vault.getFileByPath(filename) !== null) {
filename = `${file.parent?.path}/${file.basename} ${++i}.md`;
// Derive the output path. The mirror folder applies only when the file is
// inside the watched folder (or no watched folder is set). Files outside the
// watched folder always save alongside the note. When both are set, the
// watched folder prefix is stripped from the mirror path to avoid duplication.
const relMdPath = file.path.replace(/\.note$/i, '.md');
const mirrorFolder = this.settings.markdownMirrorFolder;
const watchFolder = this.settings.noteWatchFolder.replace(/\/$/, '');
let baseFilename: string;
if (mirrorFolder && (!watchFolder || file.path.startsWith(watchFolder + '/'))) {
let mirrorRelPath = relMdPath;
if (watchFolder) {
const prefix = watchFolder + '/';
if (mirrorRelPath.startsWith(prefix)) mirrorRelPath = mirrorRelPath.slice(prefix.length);
}
baseFilename = `${mirrorFolder}/${mirrorRelPath}`;
} else {
baseFilename = relMdPath;
}
const dir = baseFilename.slice(0, baseFilename.length - `${file.basename}.md`.length);
let filename = baseFilename;

if (!overwrite) {
// Generate a non-conflicting filename - it has a bit of a race but that is OK
let i = 0;
while (this.app.vault.getFileByPath(filename) !== null) {
filename = `${dir}${file.basename} ${++i}.md`;
}
}

// Create any missing parent folders before writing.
if (dir) await this.ensureFolderExists(dir.replace(/\/$/, ''));

content = this.app.fileManager.generateMarkdownLink(file, filename);
content += '\n';

// Build per-page keyword tag map and a note-level keyword lookup for OCR processing.
// Use the KEYWORD footer key prefix (first 4 digits, 1-indexed) for the page,
// matching the same format as LINKO keys. KEYWORDPAGE can be '0' (invalid).
// Each keyword text is sanitized: non-alphanumeric characters become '_'.
const keywordsByPage = new Map<number, string[]>();
// noteKeywordTags: lowercase-normalized → canonical "#Tag" for brand-new tags
// not yet in the vault so processHashtagsAndMentions can still recognise them.
const noteKeywordTags = new Map<string, string>();
if (this.settings.isKeywordsAndLinksEnabled) {
for (const key of Object.keys(sn.keywords)) {
const pageIdx = parseInt(key.slice(0, 4)) - 1;
for (const kw of sn.keywords[key]) {
const text = kw.KEYWORD.trim();
if (!text) continue;
const tag = `#${text.replace(/[^\w-]/g, '_').replace(/_+/g, '_').replace(/^_|_$/g, '')}`;
if (!keywordsByPage.has(pageIdx)) keywordsByPage.set(pageIdx, []);
if (!keywordsByPage.get(pageIdx)!.includes(tag))
keywordsByPage.get(pageIdx)!.push(tag);
// Register for OCR lookup (single-word and two-word keys).
const parts = tag.slice(1).split('_');
if (parts.length === 1) noteKeywordTags.set(parts[0].toLowerCase(), tag);
if (parts.length === 2) noteKeywordTags.set(`${parts[0].toLowerCase()}_${parts[1].toLowerCase()}`, tag);
}
}
}

// Build per-page link map. The LINKO key encodes the source page as its
// first 4 digits (1-indexed), so we use that instead of OBJPAGE.
// Sorting keys gives top-to-bottom link order within each page.
const snLinks = sn.links ?? {};
const linksByPage = new Map<number, string[]>();
if (this.settings.isKeywordsAndLinksEnabled) {
const noteCache = new Map<string, SupernoteX>();
for (const key of Object.keys(snLinks).sort()) {
for (const link of snLinks[key]) {
if (!link.text) continue;
const text = await this.resolvePageAnchor(link, noteCache);
const pageIdx = parseInt(key.slice(0, 4)) - 1;
if (!linksByPage.has(pageIdx)) linksByPage.set(pageIdx, []);
linksByPage.get(pageIdx)!.push(`[[${text}]]`);
}
}
}

for (let i = 0; i < sn.pages.length; i++) {
content += `## Page ${i + 1}\n\n`
// Process OCR text first so we can check which keyword tags are already embedded.
let pageOcrText = '';
if (sn.pages[i].text !== undefined && sn.pages[i].text.length > 0) {
content += `${processSupernoteText(sn.pages[i].text, this.settings)}\n`;
try {
pageOcrText = processSupernoteText(sn.pages[i].text, this.settings, this.app, noteKeywordTags);
} catch {
pageOcrText = sn.pages[i].text;
}
}
// Only emit keyword tags not already present in the OCR text.
if (this.settings.isKeywordsAndLinksEnabled) {
const pageTags = keywordsByPage.get(i);
if (pageTags) {
const missing = pageTags.filter(tag => !pageOcrText.includes(tag));
if (missing.length > 0) content += missing.join(' ') + '\n\n';
}
}
if (pageOcrText) content += pageOcrText + '\n';
// Append Supernote internal links that appear on this page.
if (this.settings.isKeywordsAndLinksEnabled) {
const pageLinks = linksByPage.get(i);
if (pageLinks) {
content += pageLinks.join('\n') + '\n';
}
}
if (imgs) {
let subpath = '';
Expand All @@ -175,7 +329,65 @@ class VaultWriter {
}
}

this.app.vault.create(filename, content);
if (overwrite) {
const existing = this.app.vault.getFileByPath(baseFilename);
if (existing) {
await this.app.vault.modify(existing, content);
} else {
try {
await this.app.vault.create(baseFilename, content);
} catch {
// Race condition: 'create' and 'modify' vault events both fire when
// a .note file syncs. The file may have been created between the
// getFileByPath check and vault.create — update it instead.
const raceFile = this.app.vault.getFileByPath(baseFilename);
if (raceFile) await this.app.vault.modify(raceFile, content);
}
}
} else {
await this.app.vault.create(filename, content);
}
}

private async ensureFolderExists(path: string): Promise<void> {
const parts = path.split('/').filter(Boolean);
let current = '';
for (const part of parts) {
current = current ? `${current}/${part}` : part;
if (!this.app.vault.getAbstractFileByPath(current)) {
try {
await this.app.vault.createFolder(current);
} catch {
// Folder may have been created concurrently; ignore.
}
}
}
}

private async resolvePageAnchor(link: ILink, cache: Map<string, SupernoteX>): Promise<string> {
// Library already resolved same-file links; nothing to do.
if (link.text.includes('#')) return link.text;
const pageid = link.PAGEID;
if (!pageid || pageid === '0' || pageid === 'none') return link.text;

const targetBasename = link.text;
let targetNote = cache.get(targetBasename);
if (!targetNote) {
const noteFile = this.app.vault.getFiles().find(
f => f.extension === 'note' && f.basename === targetBasename
);
if (!noteFile) return link.text;
try {
const buffer = await this.app.vault.readBinary(noteFile);
targetNote = new SupernoteX(new Uint8Array(buffer));
cache.set(targetBasename, targetNote);
} catch {
return link.text;
}
}

const pageIndex = targetNote.pages.findIndex(p => p.PAGEID === pageid);
return pageIndex >= 0 ? `${link.text}#Page ${pageIndex + 1}` : link.text;
}

async writeImageFiles(file: TFile, sn: SupernoteX): Promise<TFile[]> {
Expand All @@ -198,11 +410,11 @@ class VaultWriter {
return imgs;
}

async attachMarkdownFile(file: TFile) {
async attachMarkdownFile(file: TFile, overwrite = false) {
const note = await this.app.vault.readBinary(file);
let sn = new SupernoteX(new Uint8Array(note));

this.writeMarkdownFile(file, sn, null);
await this.writeMarkdownFile(file, sn, null, overwrite);
}

async attachNoteFiles(file: TFile) {
Expand Down Expand Up @@ -242,7 +454,7 @@ class VaultWriter {
if (sn.pages[i].text !== undefined && sn.pages[i].text.length > 0) {
pdf.setFontSize(100);
pdf.setTextColor(0, 0, 0, 0); // Transparent text
pdf.text(processSupernoteText(sn.pages[i].text, this.settings), 20, 20, { maxWidth: sn.pageWidth });
pdf.text(processSupernoteText(sn.pages[i].text, this.settings, this.app), 20, 20, { maxWidth: sn.pageWidth });
pdf.setTextColor(0, 0, 0, 1);
}

Expand Down Expand Up @@ -359,13 +571,13 @@ export class SupernoteView extends FileView {
// If Collapse Text setting is enabled, place the text into an HTML `details` element
if (this.settings.collapseRecognizedText) {
text = pageContainer.createEl('details', {
text: '\n' + processSupernoteText(sn.pages[i].text,this.settings),
text: '\n' + processSupernoteText(sn.pages[i].text, this.settings, this.app),
cls: 'page-recognized-text',
});
text.createEl('summary', { text: `Page ${i + 1} Recognized Text` });
} else {
text = pageContainer.createEl('div', {
text: processSupernoteText(sn.pages[i].text, this.settings),
text: processSupernoteText(sn.pages[i].text, this.settings, this.app),
cls: 'page-recognized-text',
});
}
Expand Down Expand Up @@ -459,7 +671,7 @@ export default class SupernotePlugin extends Plugin {
}
let image = await fetchMirrorFrame(`${this.settings.directConnectIP}:8080`);

const file = await this.app.vault.createBinary(filename, image.toBuffer());
const file = await this.app.vault.createBinary(filename, encode(image).buffer);
const path = this.app.workspace.activeEditor?.file?.path;
if (!path) {
throw new Error("Active file path is null")
Expand Down Expand Up @@ -549,6 +761,33 @@ export default class SupernotePlugin extends Plugin {
return false;
},
});

const syncDebounceMap = new Map<string, ReturnType<typeof setTimeout>>();

this.registerEvent(
this.app.vault.on('create', (file) => {
if (!this.settings.isAutoSyncMarkdownEnabled) return;
if (!(file instanceof TFile) || file.extension !== 'note') return;
const wf = this.settings.noteWatchFolder.replace(/\/$/, '');
if (wf && !file.path.startsWith(wf + '/')) return;
vw.attachMarkdownFile(file, true).catch(e => console.error('Supernote auto-sync (create) error:', e));
})
);

this.registerEvent(
this.app.vault.on('modify', (file) => {
if (!this.settings.isAutoSyncMarkdownEnabled) return;
if (!(file instanceof TFile) || file.extension !== 'note') return;
const wf = this.settings.noteWatchFolder.replace(/\/$/, '');
if (wf && !file.path.startsWith(wf + '/')) return;
const existing = syncDebounceMap.get(file.path);
if (existing) clearTimeout(existing);
syncDebounceMap.set(file.path, setTimeout(() => {
syncDebounceMap.delete(file.path);
vw.attachMarkdownFile(file, true).catch(e => console.error('Supernote auto-sync (modify) error:', e));
}, 2000));
})
);
}

onunload() {
Expand Down
Loading