Skip to content
Merged
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).

Expand Down
2 changes: 1 addition & 1 deletion manifest.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
87 changes: 79 additions & 8 deletions src/convert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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) {
Expand Down
126 changes: 97 additions & 29 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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',
Expand All @@ -40,14 +76,23 @@ export default class BasifyPlugin extends Plugin {

async loadSettings(): Promise<void> {
const data = (await this.loadData()) as
| { settings?: Partial<BasifySettings>; memory?: Partial<BasifyMemory> }
| {
settings?: Partial<BasifySettings>;
memory?: Partial<BasifyMemory>;
fileMemories?: unknown;
}
| null;
this.settings = { ...DEFAULT_SETTINGS, ...data?.settings };
this.memory = { ...DEFAULT_MEMORY, ...data?.memory };
this.fileMemories = loadFileMemories(data?.fileMemories);
}

async saveSettings(): Promise<void> {
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<void> {
Expand All @@ -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) {
Expand All @@ -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,
Expand All @@ -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 {
Expand Down Expand Up @@ -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();
Expand All @@ -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();
Expand All @@ -214,18 +278,22 @@ 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 ?? '';
return parent === '' ? file.basename : `${parent}/${file.basename}`;
}

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 {
Expand Down
Loading