mirror of
https://github.com/andy-stack/vaultkeeper-ai.git
synced 2026-07-22 06:42:03 +00:00
feat: add frontmatter generation and tag suggestion quick actions
- Implement "Suggest tags" action that generates and merges tags using AI - Implement "Generate frontmatter" action that creates metadata fields - Add updateFrontmatter method to VaultService and FileSystemService - Create FrontmatterHelpers for YAML parsing and field merging - Update prompts to reuse existing vault tags for consistency - Remove deprecated mergeTagsIntoFrontmatter function - Add yaml package dependency for frontmatter parsing
This commit is contained in:
parent
9cf8f1dbd7
commit
060ce4964f
15 changed files with 417 additions and 198 deletions
|
|
@ -1,6 +1,6 @@
|
|||
export const ApplyTagsPrompt: string = `You are an Obsidian note organizer. Your task is to choose tags for a note from a fixed list of tags that already exist in the vault.
|
||||
|
||||
You will be given a newline-separated list of existing vault tags (each prefixed with #) and the note's body. Choose the tags from that list that genuinely describe the note's topics, themes, or type — typically 3–7, fewer if the note is short or narrow in scope. Never invent or modify tags; only choose names that appear verbatim in the provided list. If no existing tag is a good fit, return nothing (an empty response).
|
||||
You will be given a newline-separated list of existing vault tags (each prefixed with #) and the note's body. Choose the tags from that list that genuinely describe the note's topics, themes, or type — typically 3-7, fewer if the note is short or narrow in scope. Never invent or modify tags; only choose names that appear verbatim in the provided list. If no existing tag is a good fit, return nothing (an empty response).
|
||||
|
||||
Output format:
|
||||
- Return only the chosen tags, one per line, each prefixed with # exactly as shown in the provided list. No bullet points, no quotes, no commentary, no explanation.
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
export const GenerateFrontmatterPrompt: string = `You are an Obsidian note organiser. Your task is to generate YAML frontmatter for a note based on its content.
|
||||
export const GenerateFrontmatterPrompt: string = `You are an Obsidian note organiser. You will be given the body of a note. Your task is to infer YAML frontmatter for it.
|
||||
|
||||
Infer reasonable values for the following fields where the content supports them:
|
||||
- title: a concise title for the note. Wrap in quotes if it contains colons, commas, or other punctuation.
|
||||
- aliases: alternative names the note may be referenced by (omit if none are obvious)
|
||||
- tags: a small set of specific, reusable tags. Use lowercase with no spaces and no # prefix. Prefer hierarchical tags using forward-slash notation (e.g. "type/person", "projects/active") over flat generic tags.
|
||||
- tags: a small set of specific, reusable tags. Use lowercase with no spaces and no # prefix. Prefer hierarchical tags using forward-slash notation (e.g. "type/person", "projects/active") over flat generic tags. Reuse the vault's existing tags (listed below) whenever one genuinely fits the note — this keeps the vault's tagging consistent. Only coin a new tag when none of the existing tags describes an important topic of the note.
|
||||
- title: a concise title for the note. Wrap in quotes if it contains colons, commas, or other punctuation.
|
||||
- summary: a single-sentence description of the note
|
||||
- created: today's date in YYYY-MM-DD format, only if no date is already present in the note under any of the common date keys (created, date, date_created)
|
||||
- created: today's date in YYYY-MM-DD format
|
||||
|
||||
CRITICAL — tags and aliases MUST always be emitted as YAML block-style lists, even when there is only a single value. A scalar string value for these fields is invalid in Obsidian 1.4+ and completely ignored in Obsidian 1.9+.
|
||||
|
||||
|
|
@ -19,10 +19,9 @@ tags: meeting, projects/alpha
|
|||
|
||||
Wrap any text value in double quotes if it contains a colon, comma, or other YAML-significant character.
|
||||
|
||||
Only include fields you can fill in confidently from the content — do not invent information. Place the frontmatter at the very top of the note, delimited by --- on its own lines. Use this key order when present: aliases, tags, title, summary, created.
|
||||
Only include fields you can fill in confidently from the content — do not invent information. Use this key order when present: aliases, tags, title, summary, created.
|
||||
|
||||
If the note already has frontmatter, merge your additions into it: keep existing fields and values untouched, and only add fields that are missing. Do not overwrite or reorder existing keys.
|
||||
Output ONLY the YAML frontmatter fields. Do NOT include the surrounding --- fences, do not repeat the note body, and do not add any explanation, preamble, or commentary. Existing frontmatter on the note will be merged automatically, so return only the fields you are suggesting.
|
||||
|
||||
Preserve the rest of the note exactly — do not change wording, formatting, or any other content below the frontmatter.
|
||||
|
||||
Return only the updated note with no explanation, preamble, or commentary.`;
|
||||
Existing vault tags (prefer reusing these):
|
||||
{tags}`;
|
||||
|
|
|
|||
|
|
@ -1 +1,10 @@
|
|||
export const SuggestTagsPrompt: string = ``;
|
||||
export const SuggestTagsPrompt: string = `You are an Obsidian note organizer. You will be given the body of a note. Your task is to suggest a small set of tags that describe the note's topics, themes, or type.
|
||||
|
||||
Choose specific, reusable tags — typically 3-7, fewer if the note is short or narrow in scope. Use lowercase with no spaces. Prefer hierarchical tags using forward-slash notation (e.g. "type/person", "projects/active") over flat generic tags. Reuse the vault's existing tags (listed below) whenever one genuinely fits the note — this keeps the vault's tagging consistent. Only coin a new tag when none of the existing tags describes an important topic of the note.
|
||||
|
||||
Output format:
|
||||
- Return only the chosen tags, one per line, each prefixed with # (e.g. #type/person). No bullet points, no quotes, no commentary, no explanation.
|
||||
- If no tag is a good fit, return an empty response with no characters at all.
|
||||
|
||||
Existing vault tags (prefer reusing these):
|
||||
{tags}`;
|
||||
|
|
|
|||
96
Helpers/FrontmatterHelpers.ts
Normal file
96
Helpers/FrontmatterHelpers.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import { parseYaml } from "obsidian";
|
||||
|
||||
/**
|
||||
* Normalises an arbitrary frontmatter list value into a clean string array.
|
||||
*
|
||||
* Obsidian 1.9 only recognises tags/aliases/cssclasses as YAML lists, but a value can still
|
||||
* arrive malformed — most likely AI-generated — as a scalar string or a comma-separated string
|
||||
* (e.g. `meeting, work`). This coerces all of those into an array, splitting comma-separated
|
||||
* strings, stripping a leading `#`, trimming, and dropping empties. Already-array values are
|
||||
* passed through item-by-item with the same cleaning. Non-string/array/number values are ignored.
|
||||
*/
|
||||
export function normaliseFrontmatterList(value: unknown): string[] {
|
||||
const items: unknown[] = Array.isArray(value) ? value : [value];
|
||||
|
||||
const cleaned: string[] = [];
|
||||
for (const item of items) {
|
||||
if (typeof item !== "string" && typeof item !== "number") {
|
||||
continue;
|
||||
}
|
||||
String(item)
|
||||
.split(",")
|
||||
.map(part => part.trim().replace(/^#/, ""))
|
||||
.filter(part => part.length > 0)
|
||||
.forEach(part => cleaned.push(part));
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unions `additions` into a list-valued frontmatter field in place, keeping existing entries
|
||||
* first and de-duplicating. The existing value is normalised via {@link normaliseFrontmatterList}, so a
|
||||
* malformed scalar/comma-separated value is repaired into a proper array. Intended to be called
|
||||
* from inside a `processFrontMatter` mutator, which then serialises the array as a block list.
|
||||
*/
|
||||
export function mergeListIntoFrontmatter(frontmatter: Record<string, unknown>, key: string, additions: string[]): void {
|
||||
const existing = normaliseFrontmatterList(frontmatter[key]);
|
||||
const cleanedAdditions = normaliseFrontmatterList(additions);
|
||||
|
||||
const merged = Array.from(new Set([...existing, ...cleanedAdditions]));
|
||||
if (merged.length > 0) {
|
||||
frontmatter[key] = merged;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a model's frontmatter suggestion into a plain object, or `null` if it isn't a usable
|
||||
* YAML mapping. Defends against the model wrapping its output in `---` fences or a ```yaml code
|
||||
* block even though it is asked for a bare YAML block.
|
||||
*/
|
||||
export function parseFrontmatterYaml(modelOutput: string): Record<string, unknown> | null {
|
||||
const cleaned = modelOutput
|
||||
.trim()
|
||||
.replace(/^```(?:ya?ml)?\s*\n?/i, "")
|
||||
.replace(/\n?```\s*$/i, "")
|
||||
.replace(/^---\s*\n/, "")
|
||||
.replace(/\n---\s*$/, "")
|
||||
.trim();
|
||||
|
||||
if (cleaned === "") {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed: unknown = parseYaml(cleaned);
|
||||
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return null;
|
||||
}
|
||||
return parsed as Record<string, unknown>;
|
||||
} catch {
|
||||
return null; // Malformed YAML from the model — leave the note untouched
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges suggested frontmatter fields into `frontmatter` in place. List-valued fields
|
||||
* (tags/aliases/cssclasses) are unioned (existing entries kept) and normalised to clean arrays;
|
||||
* all other fields are only added when the note doesn't already have a value.
|
||||
*/
|
||||
export function mergeFrontmatterFields(frontmatter: Record<string, unknown>, suggested: Record<string, unknown>): void {
|
||||
const listKeys = new Set(["tags", "aliases", "cssclasses"]);
|
||||
|
||||
for (const [key, value] of Object.entries(suggested)) {
|
||||
if (value === null || value === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (listKeys.has(key)) {
|
||||
mergeListIntoFrontmatter(frontmatter, key, normaliseFrontmatterList(value));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!(key in frontmatter) || frontmatter[key] === null || frontmatter[key] === undefined || frontmatter[key] === "") {
|
||||
frontmatter[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -106,85 +106,4 @@ export function splitFrontmatter(content: string): { frontmatter: string; body:
|
|||
return { frontmatter: "", body: content };
|
||||
}
|
||||
return { frontmatter: match[1], body: match[2] };
|
||||
}
|
||||
|
||||
export function mergeTagsIntoFrontmatter(content: string, tagsToAdd: string[]): string {
|
||||
const cleaned = Array.from(new Set(
|
||||
tagsToAdd
|
||||
.map(t => t.trim().replace(/^#/, ""))
|
||||
.filter(t => t.length > 0)
|
||||
));
|
||||
if (cleaned.length === 0) {
|
||||
return content;
|
||||
}
|
||||
|
||||
const { frontmatter, body } = splitFrontmatter(content);
|
||||
|
||||
if (frontmatter === "") {
|
||||
const block = `---\ntags:\n${cleaned.map(t => ` - ${t}`).join("\n")}\n---\n`;
|
||||
return block + content;
|
||||
}
|
||||
|
||||
const fmInner = frontmatter.replace(/^---\r?\n/, "").replace(/\r?\n---\r?\n?$/, "");
|
||||
const tagsKeyMatch = fmInner.match(/^(tags|tag)[ \t]*:(.*)$/m);
|
||||
|
||||
let newFmInner: string;
|
||||
if (!tagsKeyMatch) {
|
||||
const block = `tags:\n${cleaned.map(t => ` - ${t}`).join("\n")}`;
|
||||
newFmInner = fmInner.length === 0 ? block : `${fmInner}\n${block}`;
|
||||
} else {
|
||||
const keyStartIdx = tagsKeyMatch.index!;
|
||||
const valueOnLine = tagsKeyMatch[2];
|
||||
const afterKeyLineIdx = keyStartIdx + tagsKeyMatch[0].length;
|
||||
|
||||
const existing: string[] = [];
|
||||
let blockEndIdx = afterKeyLineIdx;
|
||||
|
||||
const inlineMatch = valueOnLine.match(/^\s*\[(.*)\]\s*$/);
|
||||
if (inlineMatch) {
|
||||
inlineMatch[1].split(",").map(t => t.trim().replace(/^["']|["']$/g, "")).filter(t => t.length > 0).forEach(t => existing.push(t));
|
||||
} else if (valueOnLine.trim().length > 0) {
|
||||
// A plain (non-list) scalar value. Obsidian 1.9 no longer recognises
|
||||
// these, so they are malformed input — most likely AI-generated. A
|
||||
// comma-separated string such as `tags: a, b, c` is the exact broken
|
||||
// format we normalise into a YAML list, so split on commas. Strip
|
||||
// surrounding quotes first so `tags: "a, b"` works too.
|
||||
valueOnLine.trim()
|
||||
.replace(/^["']|["']$/g, "")
|
||||
.split(",")
|
||||
.map(t => t.trim().replace(/^#/, "").replace(/^["']|["']$/g, ""))
|
||||
.filter(t => t.length > 0)
|
||||
.forEach(t => existing.push(t));
|
||||
} else {
|
||||
const remainder = fmInner.slice(afterKeyLineIdx);
|
||||
const lines = remainder.split(/\r?\n/);
|
||||
let consumed = 0;
|
||||
for (const line of lines) {
|
||||
const itemMatch = line.match(/^\s+-\s*(.*?)\s*$/);
|
||||
if (itemMatch) {
|
||||
const tag = itemMatch[1].replace(/^["']|["']$/g, "");
|
||||
if (tag.length > 0) {
|
||||
existing.push(tag);
|
||||
}
|
||||
consumed += line.length + 1;
|
||||
} else if (line.trim() === "") {
|
||||
consumed += line.length + 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
blockEndIdx = afterKeyLineIdx + Math.min(consumed, remainder.length);
|
||||
if (blockEndIdx > afterKeyLineIdx && fmInner[blockEndIdx - 1] === "\n") {
|
||||
blockEndIdx -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
const merged = Array.from(new Set([...existing, ...cleaned]));
|
||||
const replacement = `tags:\n${merged.map(t => ` - ${t}`).join("\n")}`;
|
||||
newFmInner = fmInner.slice(0, keyStartIdx) + replacement + fmInner.slice(blockEndIdx);
|
||||
}
|
||||
|
||||
const trailingNewline = /\r?\n$/.test(frontmatter) ? "\n" : "";
|
||||
const newFrontmatter = `---\n${newFmInner}\n---${trailingNewline}`;
|
||||
return newFrontmatter + body;
|
||||
}
|
||||
|
|
@ -78,6 +78,10 @@ export class FileSystemService {
|
|||
public async patchFile(file: TFile, oldContent: string[], newContent: string[], allowAccessToPluginRoot: boolean = false, requiresConfirmation: boolean = true): Promise<TFile | Error> {
|
||||
return await this.vaultService.patch(file, oldContent, newContent, allowAccessToPluginRoot, requiresConfirmation);
|
||||
}
|
||||
|
||||
public async updateFrontmatter(file: TFile, mutate: (frontmatter: Record<string, unknown>) => void, allowAccessToPluginRoot: boolean = false): Promise<TFile | Error> {
|
||||
return await this.vaultService.updateFrontmatter(file, mutate, allowAccessToPluginRoot);
|
||||
}
|
||||
|
||||
public async patchFileAtPath(filePath: string, oldContent: string[], newContent: string[], allowAccessToPluginRoot: boolean = false, requiresConfirmation: boolean = true): Promise<TFile | Error> {
|
||||
const file: TAbstractFile | null = this.vaultService.getAbstractFileByPath(filePath, allowAccessToPluginRoot);
|
||||
|
|
|
|||
|
|
@ -10,12 +10,15 @@ import { mount } from "svelte";
|
|||
import { ApplyTemplatePrompt } from "AIPrompts/QuickActionPrompts/ApplyTemplatePrompt";
|
||||
import { Copy } from "Enums/Copy";
|
||||
import type { SettingsService } from "../SettingsService";
|
||||
import { openPluginSettings, replaceCopy, splitFrontmatter, mergeTagsIntoFrontmatter } from "Helpers/Helpers";
|
||||
import { openPluginSettings, replaceCopy, splitFrontmatter } from "Helpers/Helpers";
|
||||
import { mergeFrontmatterFields, mergeListIntoFrontmatter, parseFrontmatterYaml } from "Helpers/FrontmatterHelpers";
|
||||
import { ProofreadPrompt } from "AIPrompts/QuickActionPrompts/ProofreadPrompt";
|
||||
import { VaultCacheService } from "Services/VaultCacheService";
|
||||
import { ApplyLinksPrompt } from "AIPrompts/QuickActionPrompts/ApplyLinksPrompt";
|
||||
import { ApplyTagsPrompt } from "AIPrompts/QuickActionPrompts/ApplyTagsPrompt";
|
||||
import { GenerateFrontmatterPrompt } from "AIPrompts/QuickActionPrompts/GenerateFrontmatterPrompt";
|
||||
import { Semaphore } from "Helpers/Semaphore";
|
||||
import { SuggestTagsPrompt } from "AIPrompts/QuickActionPrompts/SuggestTagsPrompt";
|
||||
|
||||
export class QuickActionsDefinitionsService {
|
||||
|
||||
|
|
@ -216,17 +219,64 @@ export class QuickActionsDefinitionsService {
|
|||
.split(/\r?\n/)
|
||||
.map(tag => tag.trim())
|
||||
.map(tag => tag.length > 0 && !tag.startsWith("#") ? `#${tag}` : tag)
|
||||
.filter(tag => tag.length > 0 && allowedTags.has(tag));
|
||||
.filter(tag => tag.length > 0 && allowedTags.has(tag))
|
||||
.map(tag => tag.replace(/^#/, ""));
|
||||
|
||||
if (chosen.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = mergeTagsIntoFrontmatter(content, chosen);
|
||||
await this.fileSystemService.updateFrontmatter(file, frontmatter => {
|
||||
mergeListIntoFrontmatter(frontmatter, "tags", chosen);
|
||||
});
|
||||
} finally {
|
||||
notice.hide();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (updated !== content) {
|
||||
await this.fileSystemService.writeToFile(file, updated, false, false);
|
||||
public async suggestTags(_menu: Menu, _editor: Editor, view: MarkdownView | MarkdownFileInfo) {
|
||||
await this.asSerialAction("Suggest tags", async () => {
|
||||
const file = view.file;
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
const content = await this.fileSystemService.readFile(file);
|
||||
|
||||
if (content instanceof Error || content.trim() === "") {
|
||||
return; // Either an excluded file or nothing to tag
|
||||
}
|
||||
|
||||
const { body } = splitFrontmatter(content);
|
||||
if (body.trim() === "") {
|
||||
return; // Nothing to base tags on
|
||||
}
|
||||
|
||||
const availableTags = this.vaultcacheService.tags;
|
||||
const prompt = replaceCopy(SuggestTagsPrompt, [Array.from(availableTags).join("\n")]);
|
||||
|
||||
const notice = this.showNotice("Suggesting tags...");
|
||||
try {
|
||||
const result = await this.performAction(prompt, body);
|
||||
if (!result || result.trim() === "") {
|
||||
return;
|
||||
}
|
||||
|
||||
const chosen = result
|
||||
.split(/\r?\n/)
|
||||
.map(tag => tag.trim())
|
||||
.map(tag => tag.length > 0 && !tag.startsWith("#") ? `#${tag}` : tag)
|
||||
.filter(tag => tag.length > 0)
|
||||
.map(tag => tag.replace(/^#/, ""));
|
||||
|
||||
if (chosen.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.fileSystemService.updateFrontmatter(file, frontmatter => {
|
||||
mergeListIntoFrontmatter(frontmatter, "tags", chosen);
|
||||
});
|
||||
} finally {
|
||||
notice.hide();
|
||||
}
|
||||
|
|
@ -235,7 +285,43 @@ export class QuickActionsDefinitionsService {
|
|||
|
||||
public async generateFrontmatter(_menu: Menu, _editor: Editor, view: MarkdownView | MarkdownFileInfo) {
|
||||
await this.asSerialAction("Generate frontmatter", async () => {
|
||||
|
||||
const file = view.file;
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
const content = await this.fileSystemService.readFile(file);
|
||||
|
||||
if (content instanceof Error || content.trim() === "") {
|
||||
return; // Either an excluded file or nothing to describe
|
||||
}
|
||||
|
||||
const { body } = splitFrontmatter(content);
|
||||
if (body.trim() === "") {
|
||||
return; // Nothing to base frontmatter on
|
||||
}
|
||||
|
||||
const availableTags = this.vaultcacheService.tags;
|
||||
const prompt = replaceCopy(GenerateFrontmatterPrompt, [Array.from(availableTags).join("\n")]);
|
||||
|
||||
const notice = this.showNotice("Generating frontmatter...");
|
||||
try {
|
||||
const result = await this.performAction(prompt, body);
|
||||
if (!result || result.trim() === "") {
|
||||
return;
|
||||
}
|
||||
|
||||
const suggested = parseFrontmatterYaml(result);
|
||||
if (!suggested) {
|
||||
return; // Model returned something that isn't a frontmatter object
|
||||
}
|
||||
|
||||
await this.fileSystemService.updateFrontmatter(file, frontmatter => {
|
||||
mergeFrontmatterFields(frontmatter, suggested);
|
||||
});
|
||||
} finally {
|
||||
notice.hide();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -77,6 +77,16 @@ export class QuickActionsService {
|
|||
.setIcon("tag")
|
||||
.onClick(async () => this.quickActionsDefinitionsService.applyTags(menu, editor, view));
|
||||
});
|
||||
menu.addItem((item) => {
|
||||
item.setTitle("Suggest tags")
|
||||
.setIcon("tags")
|
||||
.onClick(async () => this.quickActionsDefinitionsService.suggestTags(menu, editor, view));
|
||||
});
|
||||
menu.addItem((item) => {
|
||||
item.setTitle("Generate frontmatter")
|
||||
.setIcon("list-plus")
|
||||
.onClick(async () => this.quickActionsDefinitionsService.generateFrontmatter(menu, editor, view));
|
||||
});
|
||||
});
|
||||
this.plugin.registerEvent(this.editorMenuEventRef);
|
||||
}
|
||||
|
|
@ -140,6 +150,16 @@ export class QuickActionsService {
|
|||
.setIcon("tag")
|
||||
.onClick(async () => this.quickActionsDefinitionsService.applyTags(menu, editor, view))
|
||||
);
|
||||
menu.addItem((item) =>
|
||||
item.setTitle("Suggest tags")
|
||||
.setIcon("tags")
|
||||
.onClick(async () => this.quickActionsDefinitionsService.suggestTags(menu, editor, view))
|
||||
);
|
||||
menu.addItem((item) =>
|
||||
item.setTitle("Generate frontmatter")
|
||||
.setIcon("list-plus")
|
||||
.onClick(async () => this.quickActionsDefinitionsService.generateFrontmatter(menu, editor, view))
|
||||
);
|
||||
menu.showAtMouseEvent(evt);
|
||||
});
|
||||
setIcon(button, "sparkles");
|
||||
|
|
|
|||
|
|
@ -78,11 +78,11 @@ export function RegisterDependencies() {
|
|||
RegisterSingleton<DiffService>(Services.DiffService, new DiffService());
|
||||
RegisterSingleton<VaultService>(Services.VaultService, new VaultService());
|
||||
RegisterSingleton<VaultCacheService>(Services.VaultCacheService, new VaultCacheService());
|
||||
RegisterSingleton<FileSystemService>(Services.FileSystemService, new FileSystemService());
|
||||
RegisterSingleton<SearchStateStore>(Services.SearchStateStore, new SearchStateStore());
|
||||
RegisterSingleton<ExecutionPlanStore>(Services.ExecutionPlanStore, new ExecutionPlanStore());
|
||||
RegisterSingleton<UserInputService>(Services.UserInputService, new UserInputService());
|
||||
RegisterSingleton<WorkSpaceService>(Services.WorkSpaceService, new WorkSpaceService());
|
||||
RegisterSingleton<FileSystemService>(Services.FileSystemService, new FileSystemService());
|
||||
RegisterSingleton<MemoriesService>(Services.MemoriesService, new MemoriesService());
|
||||
RegisterSingleton<ConversationFileSystemService>(Services.ConversationFileSystemService, new ConversationFileSystemService());
|
||||
RegisterSingleton<ConversationNamingService>(Services.ConversationNamingService, new ConversationNamingService());
|
||||
|
|
|
|||
|
|
@ -155,6 +155,27 @@ export class VaultService {
|
|||
});
|
||||
}
|
||||
|
||||
public async updateFrontmatter(file: TFile, mutate: (frontmatter: Record<string, unknown>) => void, allowAccessToPluginRoot: boolean = false): Promise<TFile | Error> {
|
||||
const filePath = this.sanitiserService.sanitize(file.path);
|
||||
if (this.isExclusion(file.path, allowAccessToPluginRoot)) {
|
||||
Exception.log(`Plugin attempted to update frontmatter of a file that is in the exclusion list: ${filePath}`);
|
||||
return Exception.new(`File does not exist: ${filePath}`);
|
||||
}
|
||||
|
||||
if (isFileType(file.extension.toLocaleLowerCase(), FileType.PDF)) {
|
||||
return Exception.new("Modifying PDF files is not supported");
|
||||
}
|
||||
|
||||
try {
|
||||
// frontmatter updates are not fed through 'proposeChange'
|
||||
await this.fileManager.processFrontMatter(file, (frontmatter: Record<string, unknown>) => mutate(frontmatter));
|
||||
return file;
|
||||
} catch (error) {
|
||||
Exception.log(error);
|
||||
return Exception.new(error);
|
||||
}
|
||||
}
|
||||
|
||||
public async patch(file: TFile, oldContent: string[], newContent: string[], allowAccessToPluginRoot: boolean = false, requiresConfirmation: boolean = true): Promise<TFile | Error> {
|
||||
const filePath = this.sanitiserService.sanitize(file.path);
|
||||
if (this.isExclusion(file.path, allowAccessToPluginRoot)) {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,9 @@
|
|||
import { vi } from 'vitest';
|
||||
import { parse as parseYamlImpl } from 'yaml';
|
||||
|
||||
export function parseYaml(content: string): unknown {
|
||||
return parseYamlImpl(content);
|
||||
}
|
||||
|
||||
export class Component {
|
||||
public load() {}
|
||||
|
|
|
|||
154
__tests__/Helpers/FrontmatterHelpers.test.ts
Normal file
154
__tests__/Helpers/FrontmatterHelpers.test.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
normaliseFrontmatterList,
|
||||
mergeListIntoFrontmatter,
|
||||
parseFrontmatterYaml,
|
||||
mergeFrontmatterFields,
|
||||
} from '../../Helpers/FrontmatterHelpers';
|
||||
|
||||
describe('FrontmatterHelpers', () => {
|
||||
describe('normaliseFrontmatterList', () => {
|
||||
it('returns an empty array for null, undefined, and non-list scalars', () => {
|
||||
expect(normaliseFrontmatterList(null)).toEqual([]);
|
||||
expect(normaliseFrontmatterList(undefined)).toEqual([]);
|
||||
expect(normaliseFrontmatterList({})).toEqual([]);
|
||||
expect(normaliseFrontmatterList(true)).toEqual([]);
|
||||
});
|
||||
|
||||
it('splits a comma-separated string into a list (Obsidian 1.9 breakage)', () => {
|
||||
expect(normaliseFrontmatterList('meeting, work, planning')).toEqual(['meeting', 'work', 'planning']);
|
||||
});
|
||||
|
||||
it('treats a single scalar string as a one-item list', () => {
|
||||
expect(normaliseFrontmatterList('meeting')).toEqual(['meeting']);
|
||||
});
|
||||
|
||||
it('passes an existing array through, cleaning each item', () => {
|
||||
expect(normaliseFrontmatterList(['meeting', 'work'])).toEqual(['meeting', 'work']);
|
||||
});
|
||||
|
||||
it('strips a leading # and trims whitespace', () => {
|
||||
expect(normaliseFrontmatterList(['#meeting', ' work '])).toEqual(['meeting', 'work']);
|
||||
expect(normaliseFrontmatterList('#meeting, #work')).toEqual(['meeting', 'work']);
|
||||
});
|
||||
|
||||
it('drops empty entries', () => {
|
||||
expect(normaliseFrontmatterList('meeting, , work,')).toEqual(['meeting', 'work']);
|
||||
expect(normaliseFrontmatterList(['', ' ', '#'])).toEqual([]);
|
||||
});
|
||||
|
||||
it('coerces numbers to strings', () => {
|
||||
expect(normaliseFrontmatterList([2024, 'meeting'])).toEqual(['2024', 'meeting']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeListIntoFrontmatter', () => {
|
||||
it('creates the field as a list when absent', () => {
|
||||
const fm: Record<string, unknown> = {};
|
||||
mergeListIntoFrontmatter(fm, 'tags', ['noble', 'inventor']);
|
||||
expect(fm.tags).toEqual(['noble', 'inventor']);
|
||||
});
|
||||
|
||||
it('unions additions after existing entries, keeping existing first', () => {
|
||||
const fm: Record<string, unknown> = { tags: ['existing'] };
|
||||
mergeListIntoFrontmatter(fm, 'tags', ['noble', 'inventor']);
|
||||
expect(fm.tags).toEqual(['existing', 'noble', 'inventor']);
|
||||
});
|
||||
|
||||
it('de-duplicates against existing entries and within additions', () => {
|
||||
const fm: Record<string, unknown> = { tags: ['noble'] };
|
||||
mergeListIntoFrontmatter(fm, 'tags', ['noble', 'inventor', 'inventor']);
|
||||
expect(fm.tags).toEqual(['noble', 'inventor']);
|
||||
});
|
||||
|
||||
it('repairs a malformed comma-separated existing value into a list', () => {
|
||||
const fm: Record<string, unknown> = { tags: 'meeting, work' };
|
||||
mergeListIntoFrontmatter(fm, 'tags', ['noble']);
|
||||
expect(fm.tags).toEqual(['meeting', 'work', 'noble']);
|
||||
});
|
||||
|
||||
it('leaves an absent field untouched when there is nothing to add', () => {
|
||||
const fm: Record<string, unknown> = {};
|
||||
mergeListIntoFrontmatter(fm, 'tags', []);
|
||||
expect('tags' in fm).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseFrontmatterYaml', () => {
|
||||
it('parses a bare YAML mapping into an object', () => {
|
||||
const result = parseFrontmatterYaml('title: My Note\ntags:\n - work');
|
||||
expect(result).toEqual({ title: 'My Note', tags: ['work'] });
|
||||
});
|
||||
|
||||
it('strips a ```yaml fenced code block', () => {
|
||||
const result = parseFrontmatterYaml('```yaml\ntitle: My Note\n```');
|
||||
expect(result).toEqual({ title: 'My Note' });
|
||||
});
|
||||
|
||||
it('strips a plain ``` fenced code block', () => {
|
||||
const result = parseFrontmatterYaml('```\ntitle: My Note\n```');
|
||||
expect(result).toEqual({ title: 'My Note' });
|
||||
});
|
||||
|
||||
it('strips surrounding --- frontmatter fences', () => {
|
||||
const result = parseFrontmatterYaml('---\ntitle: My Note\n---');
|
||||
expect(result).toEqual({ title: 'My Note' });
|
||||
});
|
||||
|
||||
it('returns null for empty or whitespace-only input', () => {
|
||||
expect(parseFrontmatterYaml('')).toBeNull();
|
||||
expect(parseFrontmatterYaml(' \n ')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the YAML is not a mapping', () => {
|
||||
expect(parseFrontmatterYaml('- just\n- a\n- list')).toBeNull();
|
||||
expect(parseFrontmatterYaml('just a scalar')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for malformed YAML', () => {
|
||||
expect(parseFrontmatterYaml('title: "unterminated')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeFrontmatterFields', () => {
|
||||
it('unions list-valued fields and keeps existing entries first', () => {
|
||||
const fm: Record<string, unknown> = { tags: ['existing'] };
|
||||
mergeFrontmatterFields(fm, { tags: ['new'] });
|
||||
expect(fm.tags).toEqual(['existing', 'new']);
|
||||
});
|
||||
|
||||
it('normalises a malformed comma-separated list suggestion', () => {
|
||||
const fm: Record<string, unknown> = {};
|
||||
mergeFrontmatterFields(fm, { tags: 'work, meeting' });
|
||||
expect(fm.tags).toEqual(['work', 'meeting']);
|
||||
});
|
||||
|
||||
it('treats aliases and cssclasses as list fields too', () => {
|
||||
const fm: Record<string, unknown> = {};
|
||||
mergeFrontmatterFields(fm, { aliases: ['Alias'], cssclasses: 'wide' });
|
||||
expect(fm.aliases).toEqual(['Alias']);
|
||||
expect(fm.cssclasses).toEqual(['wide']);
|
||||
});
|
||||
|
||||
it('adds scalar fields only when the note has no value', () => {
|
||||
const fm: Record<string, unknown> = { author: 'Ada' };
|
||||
mergeFrontmatterFields(fm, { author: 'Babbage', status: 'draft' });
|
||||
expect(fm.author).toBe('Ada');
|
||||
expect(fm.status).toBe('draft');
|
||||
});
|
||||
|
||||
it('fills scalar fields that are present but empty/null', () => {
|
||||
const fm: Record<string, unknown> = { author: '', status: null };
|
||||
mergeFrontmatterFields(fm, { author: 'Ada', status: 'draft' });
|
||||
expect(fm.author).toBe('Ada');
|
||||
expect(fm.status).toBe('draft');
|
||||
});
|
||||
|
||||
it('skips null and undefined suggested values', () => {
|
||||
const fm: Record<string, unknown> = {};
|
||||
mergeFrontmatterFields(fm, { author: null, status: undefined });
|
||||
expect('author' in fm).toBe(false);
|
||||
expect('status' in fm).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { StringTools } from "../../Helpers/StringTools";
|
||||
import { randomSample, openPluginSettings, splitFrontmatter, mergeTagsIntoFrontmatter } from '../../Helpers/Helpers';
|
||||
import { randomSample, openPluginSettings, splitFrontmatter } from '../../Helpers/Helpers';
|
||||
|
||||
describe('Helpers', () => {
|
||||
describe('dateToString', () => {
|
||||
|
|
@ -432,100 +432,4 @@ describe('Helpers', () => {
|
|||
expect(result.frontmatter + result.body).toBe(content);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeTagsIntoFrontmatter', () => {
|
||||
it('returns content unchanged when there are no tags to add', () => {
|
||||
const content = '# Heading\n\nBody.';
|
||||
expect(mergeTagsIntoFrontmatter(content, [])).toBe(content);
|
||||
});
|
||||
|
||||
it('returns content unchanged when all tags are empty after cleaning', () => {
|
||||
const content = '# Heading\n\nBody.';
|
||||
expect(mergeTagsIntoFrontmatter(content, ['', ' ', '#'])).toBe(content);
|
||||
});
|
||||
|
||||
it('prepends new frontmatter when none exists', () => {
|
||||
const content = '# Heading\n\nBody.';
|
||||
const result = mergeTagsIntoFrontmatter(content, ['noble', 'inventor']);
|
||||
expect(result).toBe('---\ntags:\n - noble\n - inventor\n---\n# Heading\n\nBody.');
|
||||
});
|
||||
|
||||
it('strips leading # from tag inputs', () => {
|
||||
const content = '# Heading\n\nBody.';
|
||||
const result = mergeTagsIntoFrontmatter(content, ['#noble', '#inventor']);
|
||||
expect(result).toBe('---\ntags:\n - noble\n - inventor\n---\n# Heading\n\nBody.');
|
||||
});
|
||||
|
||||
it('deduplicates within the input list', () => {
|
||||
const content = '# Heading\n\nBody.';
|
||||
const result = mergeTagsIntoFrontmatter(content, ['noble', 'noble', '#noble']);
|
||||
expect(result).toBe('---\ntags:\n - noble\n---\n# Heading\n\nBody.');
|
||||
});
|
||||
|
||||
it('injects a tags field into existing frontmatter that lacks one', () => {
|
||||
const content = '---\ntitle: My Note\nrace: Human\n---\n# Heading\n\nBody.';
|
||||
const result = mergeTagsIntoFrontmatter(content, ['noble']);
|
||||
expect(result).toBe('---\ntitle: My Note\nrace: Human\ntags:\n - noble\n---\n# Heading\n\nBody.');
|
||||
});
|
||||
|
||||
it('merges into an existing block-style tags list and preserves other fields', () => {
|
||||
const content = '---\ntitle: My Note\ntags:\n - existing\nrace: Human\n---\n# Heading\n\nBody.';
|
||||
const result = mergeTagsIntoFrontmatter(content, ['noble', 'inventor']);
|
||||
expect(result).toBe('---\ntitle: My Note\ntags:\n - existing\n - noble\n - inventor\nrace: Human\n---\n# Heading\n\nBody.');
|
||||
});
|
||||
|
||||
it('always writes added tags as a YAML list (Obsidian 1.9 requirement)', () => {
|
||||
const content = '# Heading\n\nBody.';
|
||||
const result = mergeTagsIntoFrontmatter(content, ['meeting', 'work', 'planning']);
|
||||
expect(result).toBe('---\ntags:\n - meeting\n - work\n - planning\n---\n# Heading\n\nBody.');
|
||||
});
|
||||
|
||||
it('merges into an existing inline tags list and normalises to block style', () => {
|
||||
const content = '---\ntitle: My Note\ntags: [existing, other]\nrace: Human\n---\n# Heading\n\nBody.';
|
||||
const result = mergeTagsIntoFrontmatter(content, ['noble']);
|
||||
expect(result).toBe('---\ntitle: My Note\ntags:\n - existing\n - other\n - noble\nrace: Human\n---\n# Heading\n\nBody.');
|
||||
});
|
||||
|
||||
it('repairs a comma-separated string value into a YAML list (Obsidian 1.9 breakage)', () => {
|
||||
const content = '---\ntags: meeting, work, planning\n---\n# Heading\n';
|
||||
const result = mergeTagsIntoFrontmatter(content, ['noble']);
|
||||
expect(result).toBe('---\ntags:\n - meeting\n - work\n - planning\n - noble\n---\n# Heading\n');
|
||||
});
|
||||
|
||||
it('repairs a quoted comma-separated string value into a YAML list', () => {
|
||||
const content = '---\ntags: "meeting, work"\n---\n# Heading\n';
|
||||
const result = mergeTagsIntoFrontmatter(content, ['noble']);
|
||||
expect(result).toBe('---\ntags:\n - meeting\n - work\n - noble\n---\n# Heading\n');
|
||||
});
|
||||
|
||||
it('upgrades a single scalar string value to a YAML list', () => {
|
||||
const content = '---\ntags: meeting\n---\n# Heading\n';
|
||||
const result = mergeTagsIntoFrontmatter(content, ['noble']);
|
||||
expect(result).toBe('---\ntags:\n - meeting\n - noble\n---\n# Heading\n');
|
||||
});
|
||||
|
||||
it('deduplicates against existing tags', () => {
|
||||
const content = '---\ntags:\n - noble\n---\n# Heading\n';
|
||||
const result = mergeTagsIntoFrontmatter(content, ['noble', 'inventor']);
|
||||
expect(result).toBe('---\ntags:\n - noble\n - inventor\n---\n# Heading\n');
|
||||
});
|
||||
|
||||
it('upgrades a singular tag: key into a tags: YAML list (unsupported in 1.9)', () => {
|
||||
const content = '---\ntag: existing\ntitle: My Note\n---\n# Heading\n';
|
||||
const result = mergeTagsIntoFrontmatter(content, ['noble']);
|
||||
expect(result).toBe('---\ntags:\n - existing\n - noble\ntitle: My Note\n---\n# Heading\n');
|
||||
});
|
||||
|
||||
it('preserves body byte-for-byte', () => {
|
||||
const content = '# Heading\n\nBody with --- divider\n\nand more text.';
|
||||
const result = mergeTagsIntoFrontmatter(content, ['noble']);
|
||||
expect(result.endsWith('# Heading\n\nBody with --- divider\n\nand more text.')).toBe(true);
|
||||
});
|
||||
|
||||
it('handles a tags field that is the last key in frontmatter', () => {
|
||||
const content = '---\ntitle: My Note\ntags:\n - existing\n---\n# Heading\n';
|
||||
const result = mergeTagsIntoFrontmatter(content, ['noble']);
|
||||
expect(result).toBe('---\ntitle: My Note\ntags:\n - existing\n - noble\n---\n# Heading\n');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
3
package-lock.json
generated
3
package-lock.json
generated
|
|
@ -50,7 +50,8 @@
|
|||
"svelte-preprocess": "^6.0.5",
|
||||
"tslib": "2.8.1",
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "^4.1.7"
|
||||
"vitest": "^4.1.7",
|
||||
"yaml": "^2.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/sdk": {
|
||||
|
|
|
|||
|
|
@ -41,7 +41,8 @@
|
|||
"svelte-preprocess": "^6.0.5",
|
||||
"tslib": "2.8.1",
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "^4.1.7"
|
||||
"vitest": "^4.1.7",
|
||||
"yaml": "^2.9.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.98.0",
|
||||
|
|
|
|||
Loading…
Reference in a new issue