diff --git a/src/frontmatter.test.ts b/src/frontmatter.test.ts new file mode 100644 index 0000000..3b2ffc8 --- /dev/null +++ b/src/frontmatter.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect } from 'vitest'; +import { buildFrontmatter } from './frontmatter'; + +describe('buildFrontmatter', () => { + it('always includes the supernote tag, source link, and page count', () => { + const block = buildFrontmatter({ + sourceLink: '[[Meeting notes.note]]', + device: '', + pageCount: 3, + keywords: [], + }); + expect(block).toBe( + '---\n' + + 'tags:\n' + + ' - supernote\n' + + 'source: "[[Meeting notes.note]]"\n' + + 'pages: 3\n' + + '---\n', + ); + }); + + it('omits the device line when device is empty or the "0" sentinel', () => { + for (const device of ['', '0']) { + const block = buildFrontmatter({ sourceLink: '[[a]]', device, pageCount: 1, keywords: [] }); + expect(block).not.toContain('device:'); + } + }); + + it('includes the device line when set', () => { + const block = buildFrontmatter({ sourceLink: '[[a]]', device: 'A5X2', pageCount: 1, keywords: [] }); + expect(block).toContain('device: "A5X2"\n'); + }); + + it('omits the keywords block when there are no keywords', () => { + const block = buildFrontmatter({ sourceLink: '[[a]]', device: '', pageCount: 1, keywords: [] }); + expect(block).not.toContain('keywords:'); + }); + + it('renders keywords as a YAML list, preserving order', () => { + const block = buildFrontmatter({ + sourceLink: '[[a]]', + device: '', + pageCount: 1, + keywords: ['budget', 'Q3 plan'], + }); + expect(block).toContain('keywords:\n - "budget"\n - "Q3 plan"\n'); + }); + + it('safely quotes keywords and links containing YAML-significant characters', () => { + const block = buildFrontmatter({ + sourceLink: '[[Notes: "quarterly" review]]', + device: '', + pageCount: 1, + keywords: ['contains: a colon', 'has "quotes"'], + }); + // The block must stay valid, single-value-per-line YAML: none of the + // quoted scalars should introduce an unescaped colon or quote that + // would be parsed as starting a new key or breaking the string. + expect(block).toContain('source: "[[Notes: \\"quarterly\\" review]]"\n'); + expect(block).toContain(' - "contains: a colon"\n'); + expect(block).toContain(' - "has \\"quotes\\""\n'); + }); + + it('starts and ends with the YAML delimiter, ending in a trailing blank line', () => { + const block = buildFrontmatter({ sourceLink: '[[a]]', device: 'A5X2', pageCount: 2, keywords: ['x'] }); + expect(block.startsWith('---\n')).toBe(true); + expect(block.endsWith('---\n')).toBe(true); + }); +}); diff --git a/src/frontmatter.ts b/src/frontmatter.ts new file mode 100644 index 0000000..550cfe2 --- /dev/null +++ b/src/frontmatter.ts @@ -0,0 +1,51 @@ +// Builds the optional YAML front matter block for generated markdown files. +// Front matter becomes Obsidian "properties" — searchable via the syntax at +// https://obsidian.md/help/plugins/search#Search+properties (e.g. +// `["device": A5X2]`) and usable in Dataview/Bases queries — so a vault with +// many imported Supernote notes can filter/sort on them instead of relying +// on full-text search alone. See issue #57. +export interface NoteFrontmatterData { + /** Link back to the source .note/.spd file, already formatted by + * `app.fileManager.generateMarkdownLink()` (wikilink or `[text](path)`, + * following the vault's own link-format setting). */ + sourceLink: string; + /** Device model that captured the note (SupernoteX header's + * APPLY_EQUIPMENT, e.g. "A5X2"). Omitted from the block when empty or + * '0' (the device's own "unset" sentinel). */ + device: string; + /** Total page count. */ + pageCount: number; + /** Deduplicated keywords — the device's own "star" keywords — across + * every page, in first-seen order. Omitted from the block when empty. */ + keywords: string[]; +} + +// Double-quoted YAML scalars use (almost) the same escaping rules as JSON +// strings, so JSON.stringify is a safe, dependency-free way to quote +// arbitrary text (colons, quotes, `#`, leading `[[`, etc.) for a YAML value. +function yamlScalar(value: string): string { + return JSON.stringify(value); +} + +function yamlStringList(values: string[]): string { + return values.map(v => ` - ${yamlScalar(v)}`).join('\n'); +} + +export function buildFrontmatter(data: NoteFrontmatterData): string { + const lines: string[] = ['---', 'tags:', ' - supernote']; + + lines.push(`source: ${yamlScalar(data.sourceLink)}`); + + if (data.device !== '' && data.device !== '0') { + lines.push(`device: ${yamlScalar(data.device)}`); + } + + lines.push(`pages: ${data.pageCount}`); + + if (data.keywords.length > 0) { + lines.push('keywords:', yamlStringList(data.keywords)); + } + + lines.push('---', ''); + return lines.join('\n'); +} diff --git a/src/main.ts b/src/main.ts index 8bca411..e90ee55 100644 --- a/src/main.ts +++ b/src/main.ts @@ -14,6 +14,7 @@ import { formatSyncFailureLogEntry } from './deviceSync'; import { parseLinkRect, bucketLinksByPage } from './linkOverlay'; import { SupernoteAtelierEmbed, SupernoteAtelierView, VIEW_TYPE_SUPERNOTE_ATELIER, renderAtelierCompositeDataUrl, renderAtelierCompositeFromBuffer } from './atelierView'; import { PDFDocument } from 'pdf-lib'; +import { buildFrontmatter } from './frontmatter'; function generateTimestamp(): string { const date = new Date(); @@ -522,6 +523,22 @@ function collectPageKeywords(sn: SupernoteX, pageIndex: number): string[] { return result; } +// All of a note's starred keywords, deduplicated across every page (not just +// within one) and in first-seen order — used for the frontmatter `keywords` +// property, which describes the whole note rather than a single page. +function collectAllKeywords(sn: SupernoteX): string[] { + const seen = new Set(); + const result: string[] = []; + for (let i = 0; i < sn.pages.length; i++) { + for (const keyword of collectPageKeywords(sn, i)) { + if (seen.has(keyword)) continue; + seen.add(keyword); + result.push(keyword); + } + } + return result; +} + // This page's own links. See supernote-typescript's _parseLinks doc comment // for why sn.links' Record keys (not ILink.OBJPAGE) are the reliable way to // find which page a link is drawn on. @@ -618,7 +635,18 @@ class VaultWriter { filename = `${file.parent?.path}/${file.basename} ${++i}.md`; } - content = this.app.fileManager.generateMarkdownLink(file, filename); + const sourceLink = this.app.fileManager.generateMarkdownLink(file, filename); + + if (this.settings.addFrontmatter) { + content += buildFrontmatter({ + sourceLink, + device: sn.header.APPLY_EQUIPMENT ?? '', + pageCount: sn.pages.length, + keywords: collectAllKeywords(sn), + }); + } + + content += sourceLink; content += '\n'; for (let i = 0; i < sn.pages.length; i++) { diff --git a/src/settings.ts b/src/settings.ts index 579de8c..0de7de4 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -67,6 +67,11 @@ export interface SupernotePluginSettings extends CustomDictionarySettings { showExportButtons: boolean; fileBrowserSortOrder: FileBrowserSortOrder; importFormat: ImportFormat; + /** Prefix generated markdown files with a YAML front matter block (tags, + * source link, device, page count, keywords) so imported notes are + * filterable via Obsidian's property search/Dataview/Bases. See + * buildFrontmatter() in frontmatter.ts and issue #57. */ + addFrontmatter: boolean; /** Vault folder that device sync writes into; never touches anything outside it. */ syncFolder: string; /** @@ -90,6 +95,7 @@ export const DEFAULT_SETTINGS: SupernotePluginSettings = { showExportButtons: false, fileBrowserSortOrder: 'name-asc', importFormat: 'images-text', + addFrontmatter: false, syncFolder: 'Supernote sync', syncPathFiltersRaw: '', noteSyncState: {}, @@ -157,6 +163,14 @@ export class SupernoteSettingTab extends PluginSettingTab { options: IMPORT_FORMAT_LABELS, }, }, + { + name: 'Add YAML front matter to Markdown files', + desc: 'Prefix generated Markdown files with properties (tags, a link back to the source file, device model, page count, and keywords) so imported notes show up in Obsidian\'s property search, dataview, and bases.', + control: { + type: 'toggle', + key: 'addFrontmatter', + }, + }, { name: 'Sync', render: (setting) => { @@ -284,6 +298,17 @@ export class SupernoteSettingTab extends PluginSettingTab { }) ); + new Setting(containerEl) + .setName('Add YAML front matter to Markdown files') + .setDesc('Prefix generated Markdown files with properties (tags, a link back to the source file, device model, page count, and keywords) so imported notes show up in Obsidian\'s property search, dataview, and bases.') + .addToggle(text => text + .setValue(this.plugin.settings.addFrontmatter) + .onChange(async (value) => { + this.plugin.settings.addFrontmatter = value; + await this.plugin.saveSettings(); + }) + ); + new Setting(containerEl) .setName('Sync') .setHeading();