diff --git a/README.md b/README.md index 06acd44..7bb735b 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ Everything you can configure in the dialog: - **Extract tags** — `#tag` (and nested `#tag/sub`) become a `tags` list property. - **Extract dates** — labeled dates like `due:2025-01-01`, `start:2026-08-01`, or `@2023-04-05` become fields named after the label (`due`, `start`, `date`, ...). An unlisted label (e.g. `meeting:2026-01-01`) falls back to a `date` field — only the first one; extra ones stay in the name. -- **Dynamic field extraction** — every `key:value` pair becomes a field (so `type: task` → a `type` field). It supersedes the tag and date options: when it's on, tags and dates are extracted too. +- **Dynamic field extraction** — every `key:value` pair becomes a field (so `type: task` → a `type` field); quote multi-word values containing `key:` text, such as `title: "Research: a guide"`. It supersedes the tag and date options: when it's on, tags and dates are extracted too. - **Lowercase property names** — use lowercase letters in the frontmatter property names (for example `Publication Year` → `publication_year`). diff --git a/manifest.json b/manifest.json index 5da7321..db080c1 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "basify", "name": "Basify", - "version": "0.1.5", + "version": "0.1.6", "minAppVersion": "1.13.0", "description": "Convert lists, tables and task lists into a base. One note per item.", "author": "Leonardo Laurindo", diff --git a/package-lock.json b/package-lock.json index 2c4b952..dd59dd7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "basify", - "version": "0.1.5", + "version": "0.1.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "basify", - "version": "0.1.5", + "version": "0.1.6", "license": "ISC", "devDependencies": { "@eslint/js": "^9.39.4", diff --git a/package.json b/package.json index 24c639e..2cb9472 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "basify", - "version": "0.1.5", + "version": "0.1.6", "description": "Convert a selected list or table into an Obsidian base, creating one note per item.", "main": "main.js", "type": "module", diff --git a/src/convert.ts b/src/convert.ts index f53a0d9..36e0cd6 100644 --- a/src/convert.ts +++ b/src/convert.ts @@ -78,7 +78,7 @@ const ILLEGAL_FILENAME_CHARS = /[\\/:*?"<>|#^[\]]/g; const TAG_PATTERN = /#([A-Za-z0-9_/-]+)/g; const LABELED_DATE = /\b([A-Za-z]+)\s*:\s*(\d{4}-\d{2}-\d{2})\b/g; const AT_DATE = /@(\d{4}-\d{2}-\d{2})\b/g; -const KEY_VALUE = /\b([A-Za-z][A-Za-z0-9_-]*)\s*:\s*([^\s]+)/g; +const KEY_VALUE = /\b([A-Za-z][A-Za-z0-9_-]*)\s*:/g; const URL_VALUE = /\b[A-Za-z][A-Za-z0-9+.-]*:\/\/[^\s<>()]+/g; const DATE_LABELS = [ 'due', @@ -478,28 +478,74 @@ function extractMetadata( } if (options.extractDynamic) { - for (const match of text.matchAll(KEY_VALUE)) { + const fields = [...text.matchAll(KEY_VALUE)].flatMap((match) => { const key = match[1] ?? ''; + const start = match.index ?? 0; + const valueStart = start + match[0].length; const lowerKey = key.toLowerCase(); - if (lowerKey === '' || /^(https?|ftp)$/.test(lowerKey)) { + if ( + lowerKey === '' || + /^(https?|ftp)$/.test(lowerKey) || + text.slice(valueStart, valueStart + 2) === '//' || + urlRanges.some((range) => start >= range.start && start < range.end) + ) { + return []; + } + return [{ key, start, valueStart }]; + }); + for (let index = 0; index < fields.length; index++) { + const field = fields[index]; + if (field === undefined) { + continue; + } + const firstValueCharacter = skipWhitespace(text, field.valueStart); + const quote = text[firstValueCharacter]; + const quoteEnd = + quote === '"' || quote === "'" + ? closingQuote(text, firstValueCharacter, quote) + : -1; + let nextIndex = index + 1; + let rawValue: string; + let valueEnd: number; + if (quoteEnd !== -1) { + rawValue = unescapeQuotedValue( + text.slice(firstValueCharacter + 1, quoteEnd), + quote as '"' | "'", + ); + valueEnd = quoteEnd + 1; + while ( + fields[nextIndex] !== undefined && + (fields[nextIndex]?.start ?? valueEnd) < valueEnd + ) { + nextIndex++; + } + } else { + const next = fields[nextIndex]; + rawValue = text + .slice(field.valueStart, next?.start ?? text.length) + .trim(); + valueEnd = next?.start ?? text.length; + } + if (rawValue === '') { + index = nextIndex - 1; continue; } - const rawValue = match[2] ?? ''; const value = isUrlValue(rawValue) ? rawValue : rawValue.replace(/[,.;:!?]+$/g, ''); if (value === '') { + index = nextIndex - 1; continue; } - const start = match.index ?? 0; candidates.push({ - start, - end: start + match[0].length, + start: field.start, + end: valueEnd, type: 'field', - label: key, + label: field.key, value, repeatable: true, }); + index = nextIndex - 1; } } else if (doFields) { for (const match of text.matchAll(LABELED_DATE)) { @@ -587,6 +633,31 @@ function isUrlValue(value: string): boolean { return /^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(value); } +function skipWhitespace(text: string, start: number): number { + let index = start; + while (/\s/.test(text[index] ?? '')) { + index++; + } + return index; +} + +function closingQuote(text: string, start: number, quote: string): number { + for (let index = start + 1; index < text.length; index++) { + if (text[index] === '\\') { + index++; + continue; + } + if (text[index] === quote) { + return index; + } + } + return -1; +} + +function unescapeQuotedValue(value: string, quote: '"' | "'"): string { + return value.replace(new RegExp(`\\\\([\\\\${quote}])`, 'g'), '$1'); +} + function buildNoteContent(note: NoteSpec): string { const keys = Object.keys(note.properties); if (keys.length === 0) { diff --git a/src/main.ts b/src/main.ts index c200dc6..b7419a4 100644 --- a/src/main.ts +++ b/src/main.ts @@ -19,15 +19,51 @@ import { DEFAULT_MEMORY, DEFAULT_SETTINGS, } from './settings'; +import { + BasifyFileMemories, + loadFileMemories, + removeFileMemory, + renameFileMemory, + saveFileMemory, +} from './memory'; import { promptBasify } from './ui'; export default class BasifyPlugin extends Plugin { settings!: BasifySettings; memory!: BasifyMemory; + fileMemories!: BasifyFileMemories; async onload(): Promise { await this.loadSettings(); this.addSettingTab(new BasifySettingTab(this.app, this)); + this.registerEvent( + this.app.vault.on('rename', (file, oldPath) => { + if (!(file instanceof TFile)) { + return; + } + const fileMemories = renameFileMemory( + this.fileMemories, + oldPath, + file.path, + ); + if (fileMemories !== this.fileMemories) { + this.fileMemories = fileMemories; + void this.saveSettings(); + } + }), + ); + this.registerEvent( + this.app.vault.on('delete', (file) => { + if (!(file instanceof TFile)) { + return; + } + const fileMemories = removeFileMemory(this.fileMemories, file.path); + if (fileMemories !== this.fileMemories) { + this.fileMemories = fileMemories; + void this.saveSettings(); + } + }), + ); this.addCommand({ id: 'convert-selection-to-base', @@ -40,14 +76,23 @@ export default class BasifyPlugin extends Plugin { async loadSettings(): Promise { const data = (await this.loadData()) as - | { settings?: Partial; memory?: Partial } + | { + settings?: Partial; + memory?: Partial; + fileMemories?: unknown; + } | null; this.settings = { ...DEFAULT_SETTINGS, ...data?.settings }; this.memory = { ...DEFAULT_MEMORY, ...data?.memory }; + this.fileMemories = loadFileMemories(data?.fileMemories); } async saveSettings(): Promise { - await this.saveData({ settings: this.settings, memory: this.memory }); + await this.saveData({ + settings: this.settings, + memory: this.memory, + fileMemories: this.fileMemories, + }); } private async convertSelection(editor: Editor): Promise { @@ -58,34 +103,39 @@ export default class BasifyPlugin extends Plugin { return; } + const sourceFile = this.activeFile(); + const fileMemory = + sourceFile === null ? undefined : this.fileMemories[sourceFile.path]; + const memory = fileMemory ?? this.memory; const options = await promptBasify(this.app, { columns: selection.type === 'table' ? selection.columns : [], hasHeader: selection.type === 'table' ? selection.hasHeader : true, isTaskList: isTaskList(selection), - defaultFolder: this.defaultOutputFolder(), - defaultBaseFolder: this.defaultBaseFolder(), + defaultFolder: this.defaultOutputFolder(fileMemory), + defaultBaseFolder: this.defaultBaseFolder(fileMemory), + initialColumns: memory.lastColumns, initial: { - nameColumn: this.memory.lastNameColumn, - statusField: this.memory.lastStatusField, - mode: this.memory.lastMode, - embedBase: this.memory.lastEmbedBase, - extractTags: this.memory.lastExtractTags, - extractDates: this.memory.lastExtractDates, - extractDynamic: this.memory.lastExtractDynamic, - nameSeparator: this.memory.lastNameSeparator, - fileNameField: this.memory.lastFileNameField, - fileNameFieldSeparator: this.memory.lastFileNameFieldSeparator, - lowercaseNames: this.memory.lastLowercaseNames, - lowercaseNameField: this.memory.lastLowercaseNameField, - lowercaseYamlFields: this.memory.lastLowercaseYamlFields, - sourceMode: this.memory.lastSourceMode, - conflictMode: this.memory.lastConflictMode, - conflictSuffix: this.memory.lastConflictSuffix, - repeatedFieldMode: this.memory.lastRepeatedFieldMode, - longFilenameMode: this.memory.lastLongFilenameMode, - maxFilenameLength: this.memory.lastMaxFilenameLength, - advancedOptions: this.memory.lastAdvancedOptions, + nameColumn: memory.lastNameColumn, + statusField: memory.lastStatusField, + mode: memory.lastMode, + embedBase: memory.lastEmbedBase, + extractTags: memory.lastExtractTags, + extractDates: memory.lastExtractDates, + extractDynamic: memory.lastExtractDynamic, + nameSeparator: memory.lastNameSeparator, + fileNameField: memory.lastFileNameField, + fileNameFieldSeparator: memory.lastFileNameFieldSeparator, + lowercaseNames: memory.lastLowercaseNames, + lowercaseNameField: memory.lastLowercaseNameField, + lowercaseYamlFields: memory.lastLowercaseYamlFields, + sourceMode: memory.lastSourceMode, + conflictMode: memory.lastConflictMode, + conflictSuffix: memory.lastConflictSuffix, + repeatedFieldMode: memory.lastRepeatedFieldMode, + longFilenameMode: memory.lastLongFilenameMode, + maxFilenameLength: memory.lastMaxFilenameLength, + advancedOptions: memory.lastAdvancedOptions, }, }); if (options === null) { @@ -95,6 +145,7 @@ export default class BasifyPlugin extends Plugin { this.memory = { lastFolder: options.folder, lastBaseFolder: options.baseFolder, + lastColumns: options.columns, lastNameColumn: options.nameColumn ?? 0, lastStatusField: options.statusField, lastMode: options.mode, @@ -116,6 +167,13 @@ export default class BasifyPlugin extends Plugin { lastMaxFilenameLength: options.maxFilenameLength ?? 120, lastAdvancedOptions: options.advancedOptions ?? false, }; + if (sourceFile !== null) { + this.fileMemories = saveFileMemory( + this.fileMemories, + sourceFile.path, + this.memory, + ); + } await this.saveSettings(); try { @@ -191,7 +249,10 @@ export default class BasifyPlugin extends Plugin { } } - private defaultOutputFolder(): string { + private defaultOutputFolder(fileMemory?: BasifyMemory): string { + if (fileMemory !== undefined) { + return fileMemory.lastFolder; + } switch (this.settings.outputFolderMode) { case 'fixed': return this.settings.fixedOutputFolder.trim() || this.suggestedFolder(); @@ -202,7 +263,10 @@ export default class BasifyPlugin extends Plugin { } } - private defaultBaseFolder(): string { + private defaultBaseFolder(fileMemory?: BasifyMemory): string { + if (fileMemory !== undefined) { + return fileMemory.lastBaseFolder; + } switch (this.settings.baseFolderMode) { case 'fixed': return this.settings.fixedBaseFolder.trim() || this.activeFolder(); @@ -214,8 +278,8 @@ export default class BasifyPlugin extends Plugin { } private suggestedFolder(): string { - const file = this.app.workspace.getActiveViewOfType(MarkdownView)?.file; - if (file === undefined || file === null) { + const file = this.activeFile(); + if (file === null) { return ''; } const parent = file.parent?.path ?? ''; @@ -223,9 +287,13 @@ export default class BasifyPlugin extends Plugin { } private activeFolder(): string { - const file = this.app.workspace.getActiveViewOfType(MarkdownView)?.file; + const file = this.activeFile(); return file?.parent?.path ?? ''; } + + private activeFile(): TFile | null { + return this.app.workspace.getActiveViewOfType(MarkdownView)?.file ?? null; + } } function joinSourceAndGenerated(source: string, generated: string): string { diff --git a/src/memory.ts b/src/memory.ts new file mode 100644 index 0000000..094d1c8 --- /dev/null +++ b/src/memory.ts @@ -0,0 +1,127 @@ +import type { + ConflictModeValue, + LongFilenameMode, + RepeatedFieldMode, +} from './convert'; + +export const MAX_FILE_MEMORIES = 25; + +export interface BasifyMemory { + lastFolder: string; + lastBaseFolder: string; + lastColumns: string[]; + lastNameColumn: number; + lastStatusField: string; + lastMode: 'file' | 'codeblock' | 'none'; + lastEmbedBase: boolean; + lastExtractTags: boolean; + lastExtractDates: boolean; + lastExtractDynamic: boolean; + lastNameSeparator: 'space' | 'dash' | 'underscore'; + lastFileNameField: string; + lastFileNameFieldSeparator: 'space' | 'dash' | 'underscore'; + lastLowercaseNames: boolean; + lastLowercaseNameField: boolean; + lastLowercaseYamlFields: boolean; + lastSourceMode: 'keep' | 'converted' | 'all'; + lastConflictMode: ConflictModeValue; + lastConflictSuffix: string; + lastRepeatedFieldMode: RepeatedFieldMode; + lastLongFilenameMode: LongFilenameMode; + lastMaxFilenameLength: number; + lastAdvancedOptions: boolean; +} + +export type BasifyFileMemories = Record; + +export const DEFAULT_MEMORY: BasifyMemory = { + lastFolder: '', + lastBaseFolder: '', + lastColumns: [], + lastNameColumn: 0, + lastStatusField: 'status', + lastMode: 'file', + lastEmbedBase: false, + lastExtractTags: true, + lastExtractDates: true, + lastExtractDynamic: false, + lastNameSeparator: 'space', + lastFileNameField: '', + lastFileNameFieldSeparator: 'space', + lastLowercaseNames: false, + lastLowercaseNameField: false, + lastLowercaseYamlFields: false, + lastSourceMode: 'converted', + lastConflictMode: 'skip', + lastConflictSuffix: 'copy', + lastRepeatedFieldMode: 'list', + lastLongFilenameMode: 'shorten', + lastMaxFilenameLength: 120, + lastAdvancedOptions: false, +}; + +export function loadFileMemories(value: unknown): BasifyFileMemories { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + return {}; + } + + const memories: BasifyFileMemories = {}; + for (const [path, memory] of Object.entries(value as Record)) { + if (memory === null || typeof memory !== 'object' || Array.isArray(memory)) { + continue; + } + memories[path] = { ...DEFAULT_MEMORY, ...memory }; + } + return limitFileMemories(memories); +} + +export function saveFileMemory( + memories: BasifyFileMemories, + path: string, + memory: BasifyMemory, +): BasifyFileMemories { + const next = { ...memories }; + delete next[path]; + next[path] = memory; + return limitFileMemories(next); +} + +export function renameFileMemory( + memories: BasifyFileMemories, + oldPath: string, + newPath: string, +): BasifyFileMemories { + const memory = memories[oldPath]; + if (memory === undefined) { + return memories; + } + const next = { ...memories }; + delete next[oldPath]; + delete next[newPath]; + next[newPath] = memory; + return next; +} + +export function removeFileMemory( + memories: BasifyFileMemories, + path: string, +): BasifyFileMemories { + if (memories[path] === undefined) { + return memories; + } + const next = { ...memories }; + delete next[path]; + return next; +} + +function limitFileMemories(memories: BasifyFileMemories): BasifyFileMemories { + const paths = Object.keys(memories); + if (paths.length <= MAX_FILE_MEMORIES) { + return memories; + } + const limited: BasifyFileMemories = {}; + for (const path of paths.slice(-MAX_FILE_MEMORIES)) { + limited[path] = memories[path] as BasifyMemory; + } + return limited; +} diff --git a/src/settings.ts b/src/settings.ts index 376bcb2..bb3f885 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -1,10 +1,5 @@ import { App, PluginSettingTab, SettingDefinitionItem } from 'obsidian'; import type BasifyPlugin from './main'; -import type { - ConflictModeValue, - LongFilenameMode, - RepeatedFieldMode, -} from './convert'; export type FolderMode = 'folder' | 'last' | 'fixed'; @@ -22,55 +17,8 @@ export const DEFAULT_SETTINGS: BasifySettings = { fixedBaseFolder: '', }; -export interface BasifyMemory { - lastFolder: string; - lastBaseFolder: string; - lastNameColumn: number; - lastStatusField: string; - lastMode: 'file' | 'codeblock' | 'none'; - lastEmbedBase: boolean; - lastExtractTags: boolean; - lastExtractDates: boolean; - lastExtractDynamic: boolean; - lastNameSeparator: 'space' | 'dash' | 'underscore'; - lastFileNameField: string; - lastFileNameFieldSeparator: 'space' | 'dash' | 'underscore'; - lastLowercaseNames: boolean; - lastLowercaseNameField: boolean; - lastLowercaseYamlFields: boolean; - lastSourceMode: 'keep' | 'converted' | 'all'; - lastConflictMode: ConflictModeValue; - lastConflictSuffix: string; - lastRepeatedFieldMode: RepeatedFieldMode; - lastLongFilenameMode: LongFilenameMode; - lastMaxFilenameLength: number; - lastAdvancedOptions: boolean; -} - -export const DEFAULT_MEMORY: BasifyMemory = { - lastFolder: '', - lastBaseFolder: '', - lastNameColumn: 0, - lastStatusField: 'status', - lastMode: 'file', - lastEmbedBase: false, - lastExtractTags: true, - lastExtractDates: true, - lastExtractDynamic: false, - lastNameSeparator: 'space', - lastFileNameField: '', - lastFileNameFieldSeparator: 'space', - lastLowercaseNames: false, - lastLowercaseNameField: false, - lastLowercaseYamlFields: false, - lastSourceMode: 'converted', - lastConflictMode: 'skip', - lastConflictSuffix: 'copy', - lastRepeatedFieldMode: 'list', - lastLongFilenameMode: 'shorten', - lastMaxFilenameLength: 120, - lastAdvancedOptions: false, -}; +export { DEFAULT_MEMORY } from './memory'; +export type { BasifyMemory } from './memory'; const FOLDER_OPTIONS: Record = { folder: 'Same folder as the active note', diff --git a/src/ui.ts b/src/ui.ts index 02b1e80..63e6d60 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -14,6 +14,7 @@ export interface BasifyPrompt { isTaskList: boolean; defaultFolder: string; defaultBaseFolder: string; + initialColumns?: string[]; initial?: Partial; } @@ -54,7 +55,11 @@ class BasifyModal extends Modal { resolve: (options: BasifyOptions | null) => void, ) { super(app); - this.columns = prompt.columns; + this.columns = + !prompt.hasHeader && + prompt.initialColumns?.length === prompt.columns.length + ? [...prompt.initialColumns] + : [...prompt.columns]; this.hasHeader = prompt.hasHeader; this.isTaskList = prompt.isTaskList; this.defaultFolder = prompt.defaultFolder; @@ -99,6 +104,14 @@ class BasifyModal extends Modal { cls: 'basify-warning', text: 'Make sure you have a backup of your vault before continuing.', }); + const footer = this.contentEl.createDiv({ cls: 'basify-footer' }); + const convertButton = footer.createEl('button', { + cls: 'mod-cta', + text: 'Convert', + attr: { type: 'button' }, + }); + convertButton.addEventListener('click', () => this.submit()); + convertButton.focus(); new Setting(this.contentEl) .setName('Output folder') @@ -187,15 +200,6 @@ class BasifyModal extends Modal { cls: 'basify-advanced-options', }); this.renderAdvancedOptions(); - - const footer = this.contentEl.createDiv({ cls: 'basify-footer' }); - footer - .createEl('button', { - cls: 'mod-cta', - text: 'Convert', - attr: { type: 'button' }, - }) - .addEventListener('click', () => this.submit()); } private renderAdvancedOptions(): void { diff --git a/styles.css b/styles.css index 017d1b6..e4ea31f 100644 --- a/styles.css +++ b/styles.css @@ -6,7 +6,7 @@ } .basify-footer { - margin-top: 1em; + margin: 1em 0; display: flex; justify-content: flex-end; } diff --git a/tests/run.test.ts b/tests/run.test.ts index 661153d..0ea66c7 100644 --- a/tests/run.test.ts +++ b/tests/run.test.ts @@ -23,6 +23,14 @@ import { shortHash, shortenFilename, } from '../src/convert'; +import { + DEFAULT_MEMORY, + loadFileMemories, + MAX_FILE_MEMORIES, + removeFileMemory, + renameFileMemory, + saveFileMemory, +} from '../src/memory'; class FakeVault extends Vault { store = new Map(); @@ -149,6 +157,35 @@ function test(name: string, fn: () => void | Promise): void { }); } +test('file memories are bounded and maintained by file lifecycle', () => { + let memories = loadFileMemories(null); + for (let index = 0; index <= MAX_FILE_MEMORIES; index++) { + memories = saveFileMemory(memories, `Note ${index}.md`, { + ...DEFAULT_MEMORY, + lastFolder: `Folder ${index}`, + }); + } + if (Object.keys(memories).length !== MAX_FILE_MEMORIES) { + throw new Error('file memory limit was not applied'); + } + if (memories['Note 0.md'] !== undefined) { + throw new Error('oldest file memory was retained'); + } + + memories = renameFileMemory(memories, 'Note 1.md', 'Renamed.md'); + if (memories['Note 1.md'] !== undefined) { + throw new Error('old file path was retained'); + } + if (memories['Renamed.md']?.lastFolder !== 'Folder 1') { + throw new Error('file memory was not migrated'); + } + + memories = removeFileMemory(memories, 'Renamed.md'); + if (memories['Renamed.md'] !== undefined) { + throw new Error('deleted file memory was retained'); + } +}); + async function run(): Promise { for (const t of tests) { await t(); @@ -356,6 +393,70 @@ test('dynamic field extraction turns key:value into fields', () => { if (props['start']?.value !== '2026-12-01') throw new Error('start'); }); +test('dynamic field extraction keeps multi-word values until the next field', () => { + const sel = parseSelection( + '- prepare project: Multi word project owner: Ada Lovelace', + ); + if (sel === null) throw new Error('no selection'); + const input = buildConvertInput(sel, opts({ extractDynamic: true })); + const properties = input.notes[0]?.properties ?? {}; + if (properties.project?.value !== 'Multi word project') { + throw new Error('project: ' + JSON.stringify(properties.project?.value)); + } + if (properties.owner?.value !== 'Ada Lovelace') { + throw new Error('owner: ' + JSON.stringify(properties.owner?.value)); + } + if (input.notes[0]?.name !== 'prepare') { + throw new Error('name: ' + input.notes[0]?.name); + } +}); + +test('dynamic field extraction keeps quoted key-like text in the value', () => { + const sel = parseSelection( + '- resource title: "Research: a practical guide" owner: Ada Lovelace', + ); + if (sel === null) throw new Error('no selection'); + const input = buildConvertInput(sel, opts({ extractDynamic: true })); + const properties = input.notes[0]?.properties ?? {}; + if (properties.title?.value !== 'Research: a practical guide') { + throw new Error('title: ' + JSON.stringify(properties.title?.value)); + } + if (properties.owner?.value !== 'Ada Lovelace') { + throw new Error('owner: ' + JSON.stringify(properties.owner?.value)); + } + if ('Research' in properties) { + throw new Error('quoted text became a field'); + } +}); + +test('dynamic field extraction supports escaped quotes', () => { + const sel = parseSelection('- resource title: "A \\"quoted\\" guide" owner: Ada'); + if (sel === null) throw new Error('no selection'); + const input = buildConvertInput(sel, opts({ extractDynamic: true })); + const properties = input.notes[0]?.properties ?? {}; + if (properties.title?.value !== 'A "quoted" guide') { + throw new Error('title: ' + JSON.stringify(properties.title?.value)); + } +}); + +test('dynamic field extraction does not split URL values', () => { + const sel = parseSelection( + '- resource link: https://www.example.com/path?query=one:two title: Useful link', + ); + if (sel === null) throw new Error('no selection'); + const input = buildConvertInput(sel, opts({ extractDynamic: true })); + const properties = input.notes[0]?.properties ?? {}; + if ( + properties.link?.value !== + 'https://www.example.com/path?query=one:two' + ) { + throw new Error('link: ' + JSON.stringify(properties.link?.value)); + } + if (properties.title?.value !== 'Useful link') { + throw new Error('title: ' + JSON.stringify(properties.title?.value)); + } +}); + test('dynamic extraction preserves complete URL values', () => { const sel = parseSelection( '- project url:https://example.com/a_b~c/*?x=1#section', diff --git a/versions.json b/versions.json index f84306c..87efebb 100644 --- a/versions.json +++ b/versions.json @@ -4,5 +4,6 @@ "0.1.2": "1.13.0", "0.1.3": "1.13.0", "0.1.4": "1.13.0", - "0.1.5": "1.13.0" + "0.1.5": "1.13.0", + "0.1.6": "1.13.0" } \ No newline at end of file