Skip to content
Draft
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
69 changes: 69 additions & 0 deletions src/frontmatter.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
51 changes: 51 additions & 0 deletions src/frontmatter.ts
Original file line number Diff line number Diff line change
@@ -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');
}
30 changes: 29 additions & 1 deletion src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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<string>();
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.
Expand Down Expand Up @@ -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++) {
Expand Down
25 changes: 25 additions & 0 deletions src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
/**
Expand All @@ -90,6 +95,7 @@ export const DEFAULT_SETTINGS: SupernotePluginSettings = {
showExportButtons: false,
fileBrowserSortOrder: 'name-asc',
importFormat: 'images-text',
addFrontmatter: false,
syncFolder: 'Supernote sync',
syncPathFiltersRaw: '',
noteSyncState: {},
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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();
Expand Down
Loading