diff --git a/AIClasses/Claude/Claude.ts b/AIClasses/Claude/Claude.ts index 4cee7b8..079c924 100644 --- a/AIClasses/Claude/Claude.ts +++ b/AIClasses/Claude/Claude.ts @@ -16,7 +16,8 @@ import { MimeTypeToFileTypes } from "Enums/FileTypeMimeTypeMapping"; import { ApiError, ApiErrorType } from "Types/ApiError"; import { parseToolCall, parseFunctionResponse } from "Helpers/ResponseHelper"; import { AIToolUsageMode } from "Enums/AIToolUsageMode"; -import { Copy, replaceCopy } from "Enums/Copy"; +import { Copy } from "Enums/Copy"; +import { replaceCopy } from 'Helpers/Helpers'; export class Claude extends BaseAIClass { diff --git a/AIClasses/Gemini/Gemini.ts b/AIClasses/Gemini/Gemini.ts index ea8969e..b689bde 100644 --- a/AIClasses/Gemini/Gemini.ts +++ b/AIClasses/Gemini/Gemini.ts @@ -18,7 +18,8 @@ import { ApiError, ApiErrorType } from "Types/ApiError"; import { parseToolCall, parseFunctionResponse } from "Helpers/ResponseHelper"; import type { GeminiRetryInfo, GeminiErrorResponse } from "./GeminiTypes"; import { AIToolUsageMode } from "Enums/AIToolUsageMode"; -import { Copy, replaceCopy } from "Enums/Copy"; +import { replaceCopy } from 'Helpers/Helpers'; +import { Copy } from "Enums/Copy"; export class Gemini extends BaseAIClass { diff --git a/AIClasses/Mistral/Mistral.ts b/AIClasses/Mistral/Mistral.ts index 964c332..ed0ebbd 100644 --- a/AIClasses/Mistral/Mistral.ts +++ b/AIClasses/Mistral/Mistral.ts @@ -18,7 +18,8 @@ import { parseToolCall, parseFunctionResponse } from "Helpers/ResponseHelper"; import { AIToolUsageMode } from "Enums/AIToolUsageMode"; import type { MistralStreamChunk, MistralToolDefinition, MistralMessage, MistralContentPart } from "./MistralTypes"; import type { MistralFileService } from "./MistralFileService"; -import { Copy, replaceCopy } from "Enums/Copy"; +import { replaceCopy } from 'Helpers/Helpers'; +import { Copy } from "Enums/Copy"; import { MistralAgent } from "./MistralAgent"; import { AIToolResponsePayload } from "AIClasses/ToolDefinitions/AIToolResponsePayload"; diff --git a/AIClasses/OpenAI/OpenAI.ts b/AIClasses/OpenAI/OpenAI.ts index 251f419..62aa307 100644 --- a/AIClasses/OpenAI/OpenAI.ts +++ b/AIClasses/OpenAI/OpenAI.ts @@ -15,7 +15,8 @@ import { isTextFile } from "Enums/FileType"; import { MimeTypeToFileTypes } from "Enums/FileTypeMimeTypeMapping"; import { parseToolCall, parseFunctionResponse } from "Helpers/ResponseHelper"; import { AIToolUsageMode } from "Enums/AIToolUsageMode"; -import { Copy, replaceCopy } from "Enums/Copy"; +import { replaceCopy } from 'Helpers/Helpers'; +import { Copy } from "Enums/Copy"; export class OpenAI extends BaseAIClass { diff --git a/AIPrompts/IPrompt.ts b/AIPrompts/IPrompt.ts index fc64dfa..2195719 100644 --- a/AIPrompts/IPrompt.ts +++ b/AIPrompts/IPrompt.ts @@ -7,7 +7,8 @@ import { PlanningPrompt } from "AIPrompts/PlanningPrompt"; import { ExecutionPrompt } from "./ExecutionPrompt"; import { OrchestrationPrompt } from "./OrchestrationPrompt"; import type { MemoriesService } from "Services/MemoriesService"; -import { Copy, replaceCopy } from "Enums/Copy"; +import { replaceCopy } from 'Helpers/Helpers'; +import { Copy } from "Enums/Copy"; import { ChatMode } from "Enums/ChatMode"; export interface IPrompt { diff --git a/AIPrompts/QuickActionPrompts/ApplyLinksPrompt.ts b/AIPrompts/QuickActionPrompts/ApplyLinksPrompt.ts new file mode 100644 index 0000000..bfc4f0b --- /dev/null +++ b/AIPrompts/QuickActionPrompts/ApplyLinksPrompt.ts @@ -0,0 +1,18 @@ +export const ApplyLinksPrompt: string = `You are an Obsidian note organizer. Your task is to add wikilinks to a note by linking mentions of existing vault pages. + +You will be given a newline-separated list of page names that already exist in the vault. Scan the note for mentions of those exact names — including obvious case variations and simple plural/singular forms — and wrap each mention in [[ and ]] to turn it into a wikilink. + +Rules: +- Only link pages that appear in the provided list. Never invent new links. +- Do not link a page that is not mentioned in the note's text. +- Link every occurrence of a mentioned page, not just the first. +- Do not link inside fenced code blocks, inline code, existing links, existing wikilinks, image embeds, or URLs. +- If a name overlaps with another (e.g. "Paris" and "Paris Agreement"), prefer the longest matching name. +- If a mention uses a different display form than the canonical page name, use the alias syntax: [[Canonical Name|displayed text]]. +- Preserve the rest of the note exactly — do not change wording, formatting, headings, whitespace, or any other content. +- If no mentions of any provided page are found, return the note unchanged. + +Existing vault pages: +{links} + +Return only the updated note with no explanation, preamble, or commentary.`; \ No newline at end of file diff --git a/AIPrompts/QuickActionPrompts/ApplyTagsPrompt.ts b/AIPrompts/QuickActionPrompts/ApplyTagsPrompt.ts new file mode 100644 index 0000000..39232de --- /dev/null +++ b/AIPrompts/QuickActionPrompts/ApplyTagsPrompt.ts @@ -0,0 +1,10 @@ +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). + +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. +- If no tags fit, return an empty response with no characters at all. + +Existing vault tags: +{tags}`; \ No newline at end of file diff --git a/AIPrompts/QuickActionPrompts/ApplyTemplatePrompt.ts b/AIPrompts/QuickActionPrompts/ApplyTemplatePrompt.ts index 7ecf97a..61c96b2 100644 --- a/AIPrompts/QuickActionPrompts/ApplyTemplatePrompt.ts +++ b/AIPrompts/QuickActionPrompts/ApplyTemplatePrompt.ts @@ -11,4 +11,4 @@ Rewrite the content so it fits the template's structure and headings. Preserve a If the template section does not resemble a document template (e.g. it is a journal entry, a regular note, or otherwise makes no sense as a template), do not apply it. Instead return exactly the following with no other text: ${Copy.ApplyTemplateCancelled} -Return only the reformatted note with no explanation, preamble, or commentary.`; +Return only the reformatted note with no explanation, preamble, or commentary.`; \ No newline at end of file diff --git a/AIPrompts/QuickActionPrompts/Beautify.ts b/AIPrompts/QuickActionPrompts/BeautifyPrompt.ts similarity index 100% rename from AIPrompts/QuickActionPrompts/Beautify.ts rename to AIPrompts/QuickActionPrompts/BeautifyPrompt.ts diff --git a/AIPrompts/QuickActionPrompts/GenerateFrontmatterPrompt.ts b/AIPrompts/QuickActionPrompts/GenerateFrontmatterPrompt.ts new file mode 100644 index 0000000..87685b1 --- /dev/null +++ b/AIPrompts/QuickActionPrompts/GenerateFrontmatterPrompt.ts @@ -0,0 +1,28 @@ +export const GenerateFrontmatterPrompt: string = `You are an Obsidian note organiser. Your task is to generate YAML frontmatter for a note based on its content. + +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. +- 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) + +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+. + +Correct: +tags: + - meeting + - projects/alpha + +Incorrect (will be silently broken): +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. + +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. + +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.`; \ No newline at end of file diff --git a/AIPrompts/QuickActionPrompts/ProofreadPrompt.ts b/AIPrompts/QuickActionPrompts/ProofreadPrompt.ts new file mode 100644 index 0000000..c9a43a8 --- /dev/null +++ b/AIPrompts/QuickActionPrompts/ProofreadPrompt.ts @@ -0,0 +1,7 @@ +export const ProofreadPrompt: string = `You are a careful proofreader. Your task is to correct errors in the provided text. + +Fix spelling, grammar, punctuation, capitalization, and obvious typos. Do not rewrite for style, change the author's voice, restructure sentences, or alter meaning. Preserve all existing Markdown formatting, links, code blocks, and whitespace exactly. Leave content inside fenced code blocks and inline code untouched. + +If the text contains no errors, return it unchanged. + +Return only the corrected text with no explanation, preamble, or commentary.`; \ No newline at end of file diff --git a/AIPrompts/QuickActionPrompts/SuggestTagsPrompt.ts b/AIPrompts/QuickActionPrompts/SuggestTagsPrompt.ts new file mode 100644 index 0000000..f562617 --- /dev/null +++ b/AIPrompts/QuickActionPrompts/SuggestTagsPrompt.ts @@ -0,0 +1 @@ +export const SuggestTagsPrompt: string = ``; \ No newline at end of file diff --git a/Components/ChatInput.svelte b/Components/ChatInput.svelte index 62c5e90..00a114a 100644 --- a/Components/ChatInput.svelte +++ b/Components/ChatInput.svelte @@ -19,12 +19,13 @@ import ChatAttachments from "./ChatAttachments.svelte"; import InputDisplay from "./InputDisplay.svelte"; import { InputMode } from "Enums/InputMode"; - import { Copy, replaceCopy } from "Enums/Copy"; + import { Copy } from "Enums/Copy"; import { HelpModal } from "Modals/HelpModal"; import type { IPrompt } from "AIPrompts/IPrompt"; import type { SettingsService } from "Services/SettingsService"; import { ChatMode, chatModeAllowsEdits, iconForChatMode } from "Enums/ChatMode"; import { hideDrawerElements, restoreDrawerElements } from "Helpers/ElementHelper"; + import { replaceCopy } from "Helpers/Helpers"; export let attachments: Attachment[] = []; diff --git a/Enums/Copy.ts b/Enums/Copy.ts index e066d3e..9709a9c 100644 --- a/Enums/Copy.ts +++ b/Enums/Copy.ts @@ -1,5 +1,3 @@ -import { Exception } from "Helpers/Exception"; - export enum Copy { // General Copy UserInstructions1 = "You can create custom ", @@ -180,6 +178,7 @@ The following context explains why you are doing the task. It is NOT an instruct ApplyTemplateContentSeparator = "---CONTENT---", ApplyTemplateCancelled = "APPLY_TEMPLATE_CANCELLED", + // Active Capabilities ActiveCapabilitiesHeader = `\n\n---\n\n## Active Capabilities\n\nThe following reflects your current configuration. Follow these directives exactly.\n\n{directives}`, DirectiveChatModeReadOnly = "- **Chat Mode**: READ-ONLY — you can read and search the vault but cannot create, edit, move, or delete files; if the user asks you to make changes, inform them that edit mode is currently off", @@ -680,44 +679,4 @@ Preferred programming language: {{language}} --- **Remember:** A good system prompt is clear, dense, and easy to understand, leaving no room for misinterpretation. Start simple, test thoroughly, and refine based on real results.` -} - -/** - * Replaces placeholders in Copy strings with provided values. - * Placeholders are denoted by curly braces: {placeholderName} - * - * @param copyString - The Copy enum string containing placeholders - * @param replacements - Array of replacement values in the order they appear in the string - * @returns The string with all placeholders replaced - * - * @example - * replaceCopy(Copy.WorkflowFailedAtStep, ["authentication"]) - * // Returns: "The planned workflow failed when executing step 'authentication'. Consult with the user on how to continue." - */ -export function replaceCopy(copyString: string, replacements: string[]): string { - const placeholderRegex = /\{[^}]+\}/g; - const placeholders = copyString.match(placeholderRegex); - - if (!placeholders) { - if (replacements.length > 0) { - Exception.log(`No placeholders found in copy string, but ${replacements.length} replacement(s) provided.`); - } - return copyString; - } - - if (placeholders.length !== replacements.length) { - Exception.log(`Placeholder count (${placeholders.length}) does not match replacement count (${replacements.length}). Using best effort.`); - } - - let result = copyString; - let replacementIndex = 0; - - result = result.replace(placeholderRegex, () => { - if (replacementIndex < replacements.length) { - return replacements[replacementIndex++]; - } - return placeholders[replacementIndex++]; // Return original placeholder if no replacement available - }); - - return result; } \ No newline at end of file diff --git a/Enums/Event.ts b/Enums/Event.ts index 49abd6f..64389e0 100644 --- a/Enums/Event.ts +++ b/Enums/Event.ts @@ -1,6 +1,5 @@ export enum Event { DiffOpened = "diffOpened", DiffClosed = "diffClosed", - RateLimitCountdown = "rateLimitCountdown", - QuickActionsSettingsChanged = "quickActionsSettingsChanged" + RateLimitCountdown = "rateLimitCountdown" } \ No newline at end of file diff --git a/Helpers/Helpers.ts b/Helpers/Helpers.ts index 58cd5ed..d202abe 100644 --- a/Helpers/Helpers.ts +++ b/Helpers/Helpers.ts @@ -1,5 +1,46 @@ import type VaultkeeperAIPlugin from "main"; import path from "path-browserify"; +import { Exception } from "./Exception"; + +/** + * Replaces placeholders in Copy strings with provided values. + * Placeholders are denoted by curly braces: {placeholderName} + * + * @param copyString - The Copy enum string containing placeholders + * @param replacements - Array of replacement values in the order they appear in the string + * @returns The string with all placeholders replaced + * + * @example + * replaceCopy(Copy.WorkflowFailedAtStep, ["authentication"]) + * // Returns: "The planned workflow failed when executing step 'authentication'. Consult with the user on how to continue." + */ +export function replaceCopy(copyString: string, replacements: string[]): string { + const placeholderRegex = /\{[^}]+\}/g; + const placeholders = copyString.match(placeholderRegex); + + if (!placeholders) { + if (replacements.length > 0) { + Exception.log(`No placeholders found in copy string, but ${replacements.length} replacement(s) provided.`); + } + return copyString; + } + + if (placeholders.length !== replacements.length) { + Exception.log(`Placeholder count (${placeholders.length}) does not match replacement count (${replacements.length}). Using best effort.`); + } + + let result = copyString; + let replacementIndex = 0; + + result = result.replace(placeholderRegex, () => { + if (replacementIndex < replacements.length) { + return replacements[replacementIndex++]; + } + return placeholders[replacementIndex++]; // Return original placeholder if no replacement available + }); + + return result; +} export function openPluginSettings(plugin: VaultkeeperAIPlugin) { if (!("setting" in plugin.app) || typeof plugin.app.setting !== "object" || plugin.app.setting === null) { @@ -57,4 +98,93 @@ export function pathExtname(filePath: string) { export async function sleep(ms: number): Promise { return new Promise(resolve => window.setTimeout(resolve, ms)); +} + +export function splitFrontmatter(content: string): { frontmatter: string; body: string } { + const match = content.match(/^(---\r?\n[\s\S]*?\r?\n---\r?\n?)([\s\S]*)$/); + if (!match) { + 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; } \ No newline at end of file diff --git a/Helpers/Semaphore.ts b/Helpers/Semaphore.ts index f4ea034..6e52167 100644 --- a/Helpers/Semaphore.ts +++ b/Helpers/Semaphore.ts @@ -12,7 +12,7 @@ export class Semaphore { this.queue = []; } - async wait(): Promise { + async wait(timeoutMs?: number): Promise { if (this.count > 0) { this.count--; return true; @@ -23,7 +23,38 @@ export class Semaphore { } return new Promise((resolve) => { - this.queue.push(resolve); + let settled = false; + let timeoutId: number | null = null; + + const waiter = (value: boolean) => { + if (settled) { + if (value) { + this.release(); + } + return; + } + settled = true; + if (timeoutId !== null) { + window.clearTimeout(timeoutId); + } + resolve(value); + }; + + this.queue.push(waiter); + + if (timeoutMs !== undefined && timeoutMs >= 0) { + timeoutId = window.setTimeout(() => { + if (settled) { + return; + } + settled = true; + const idx = this.queue.indexOf(waiter); + if (idx !== -1) { + this.queue.splice(idx, 1); + } + resolve(false); + }, timeoutMs); + } }); } diff --git a/Services/AIServices/AIToolService.ts b/Services/AIServices/AIToolService.ts index 320ea0e..12d9793 100644 --- a/Services/AIServices/AIToolService.ts +++ b/Services/AIServices/AIToolService.ts @@ -8,8 +8,8 @@ import type { ISearchMatch } from "../../Types/SearchTypes"; import { AbortService } from "../AbortService"; import { normalizePath, TAbstractFile, TFile } from "obsidian"; import { Exception } from "Helpers/Exception"; -import { Copy, replaceCopy } from "Enums/Copy"; -import { pathExtname } from "Helpers/Helpers"; +import { Copy } from "Enums/Copy"; +import { pathExtname, replaceCopy } from "Helpers/Helpers"; import type { MemoriesService } from "Services/MemoriesService"; import type { SettingsService } from "Services/SettingsService"; import type { WebViewerService } from "Services/WebViewerService"; diff --git a/Services/AIServices/ExecutionAgent.ts b/Services/AIServices/ExecutionAgent.ts index 52cb60a..c8d8bfd 100644 --- a/Services/AIServices/ExecutionAgent.ts +++ b/Services/AIServices/ExecutionAgent.ts @@ -10,7 +10,8 @@ import { CompleteTaskArgsSchema, type CompleteTaskArgs } from "AIClasses/Schemas import { AIToolResponse } from "AIClasses/ToolDefinitions/AIToolResponse"; import { Exception } from "Helpers/Exception"; import { AIToolDefinitions } from "AIClasses/ToolDefinitions/AIToolDefinitions"; -import { Copy, replaceCopy } from "Enums/Copy"; +import { replaceCopy } from 'Helpers/Helpers'; +import { Copy } from "Enums/Copy"; import { DebugColor } from "Enums/DebugColor"; import { AIToolUsageMode } from "Enums/AIToolUsageMode"; import { AIToolResponsePayload } from "AIClasses/ToolDefinitions/AIToolResponsePayload"; diff --git a/Services/AIServices/OrchestrationAgent.ts b/Services/AIServices/OrchestrationAgent.ts index 5c223ac..d6372d9 100644 --- a/Services/AIServices/OrchestrationAgent.ts +++ b/Services/AIServices/OrchestrationAgent.ts @@ -4,7 +4,8 @@ import { ConversationContent } from "Conversations/ConversationContent"; import { Role } from "Enums/Role"; import type { IChatServiceCallbacks } from "Services/ChatService"; import { ExecutionAgent } from "./ExecutionAgent"; -import { Copy, replaceCopy } from "Enums/Copy"; +import { replaceCopy } from 'Helpers/Helpers'; +import { Copy } from "Enums/Copy"; import { Conversation } from "Conversations/Conversation"; import { PlanningAgent } from "./PlanningAgent"; import { Exception } from "Helpers/Exception"; diff --git a/Services/EventService.ts b/Services/EventService.ts index ae9a0f9..168a676 100644 --- a/Services/EventService.ts +++ b/Services/EventService.ts @@ -6,7 +6,6 @@ export class EventService extends Events { public on(name: Event.DiffOpened, callback: () => void): EventRef; public on(name: Event.DiffClosed, callback: () => void): EventRef; public on(name: Event.RateLimitCountdown, callback: (delayMs: number) => void): EventRef; - public on(name: Event.QuickActionsSettingsChanged, callback: (data?: unknown) => void): EventRef; public on(name: string, callback: (...data: T) => unknown): EventRef { return super.on(name, callback as (...data: unknown[]) => unknown); @@ -15,7 +14,6 @@ export class EventService extends Events { public trigger(name: Event.DiffOpened, data?: unknown): void; public trigger(name: Event.DiffClosed, data?: unknown): void; public trigger(name: Event.RateLimitCountdown, delayMs: number): void; - public trigger(name: Event.QuickActionsSettingsChanged, data?: unknown): void; public trigger(name: string, ...data: unknown[]): void { super.trigger(name, ...data); diff --git a/Services/FileSystemService.ts b/Services/FileSystemService.ts index aa7f790..7644872 100644 --- a/Services/FileSystemService.ts +++ b/Services/FileSystemService.ts @@ -17,6 +17,10 @@ export class FileSystemService { return this.vaultService.getMarkdownFiles(allowAccessToPluginRoot); } + public isExclusion(filePath: string, allowAccessToPluginRoot: boolean = false): boolean { + return this.vaultService.isExclusion(filePath, allowAccessToPluginRoot); + } + public async exists(filePath: string, allowAccessToPluginRoot: boolean = false): Promise { return await this.vaultService.exists(filePath, allowAccessToPluginRoot); } diff --git a/Services/MemoriesService.ts b/Services/MemoriesService.ts index d161555..23f7e83 100644 --- a/Services/MemoriesService.ts +++ b/Services/MemoriesService.ts @@ -1,4 +1,5 @@ -import { Copy, replaceCopy } from "Enums/Copy"; +import { replaceCopy } from 'Helpers/Helpers'; +import { Copy } from "Enums/Copy"; import { Path } from "Enums/Path"; import { Resolve } from "./DependencyService"; import type { FileSystemService } from "./FileSystemService"; diff --git a/Services/QuickActions/QuickActionsDefinitionsService.ts b/Services/QuickActions/QuickActionsDefinitionsService.ts new file mode 100644 index 0000000..45cb84c --- /dev/null +++ b/Services/QuickActions/QuickActionsDefinitionsService.ts @@ -0,0 +1,292 @@ +import { Resolve } from "../DependencyService"; +import { Services } from "../Services"; +import type { QuickAgent } from "../AIServices/QuickAgent"; +import type VaultkeeperAIPlugin from "main"; +import { FuzzySuggestModal, MarkdownView, Menu, Notice, TFile, type Editor, type MarkdownFileInfo } from "obsidian"; +import { FileSystemService } from "../FileSystemService"; +import { BeautifyPrompt } from "AIPrompts/QuickActionPrompts/BeautifyPrompt"; +import Spinner from "Components/Spinner.svelte"; +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 { ProofreadPrompt } from "AIPrompts/QuickActionPrompts/ProofreadPrompt"; +import { VaultCacheService } from "Services/VaultCacheService"; +import { ApplyLinksPrompt } from "AIPrompts/QuickActionPrompts/ApplyLinksPrompt"; +import { ApplyTagsPrompt } from "AIPrompts/QuickActionPrompts/ApplyTagsPrompt"; +import { Semaphore } from "Helpers/Semaphore"; + +export class QuickActionsDefinitionsService { + + private plugin: VaultkeeperAIPlugin; + private fileSystemService: FileSystemService; + private vaultcacheService: VaultCacheService; + private settingsService: SettingsService; + + private semaphore: Semaphore = new Semaphore(1, true); + + public constructor() { + this.plugin = Resolve(Services.VaultkeeperAIPlugin); + this.fileSystemService = Resolve(Services.FileSystemService); + this.vaultcacheService = Resolve(Services.VaultCacheService); + this.settingsService = Resolve(Services.SettingsService); + } + + public async proofread(_menu: Menu, editor: Editor, view: MarkdownView | MarkdownFileInfo) { + await this.asSerialAction("Proofread", async () => { + const file = view.file; + if (!file) { + return; + } + + const selection = editor.getSelection(); + const content = await this.fileSystemService.readFile(file); + + if (content instanceof Error || (selection.trim() === "" && content.trim() === "")) { + return; // Either an excluded file or nothing to proofread + } + + const notice = this.showNotice("Proofreading..."); + try { + if (selection.length > 0) { + const result = await this.performAction(ProofreadPrompt, selection); + if (result) { + await this.fileSystemService.patchFile(file, [selection], [result], false, false); + } + } else { + const { body } = splitFrontmatter(content); + if (body.trim() === "") { + return; + } + const result = await this.performAction(ProofreadPrompt, body); + if (result) { + await this.fileSystemService.patchFile(file, [body], [result], false, false); + } + } + } finally { + notice.hide(); + } + }); + } + + public async beautify(_menu: Menu, editor: Editor, view: MarkdownView | MarkdownFileInfo) { + await this.asSerialAction("Beautify", async () => { + const file = view.file; + if (!file) { + return; + } + + const selection = editor.getSelection(); + const content = await this.fileSystemService.readFile(file); + + if (content instanceof Error || (selection.trim() === "" && content.trim() === "")) { + return; // Either an excluded file or nothing to beautify + } + + const notice = this.showNotice("Beautifying content..."); + try { + if (selection.length > 0) { + const result = await this.performAction(BeautifyPrompt, selection); + if (result) { + await this.fileSystemService.patchFile(file, [selection], [result], false, false); + } + } else { + const { body } = splitFrontmatter(content); + if (body.trim() === "") { + return; + } + const result = await this.performAction(BeautifyPrompt, body); + if (result) { + await this.fileSystemService.patchFile(file, [body], [result], false, false); + } + } + } finally { + notice.hide(); + } + }); + } + + public async applyTemplate(_menu: Menu, _editor: Editor, view: MarkdownView | MarkdownFileInfo) { + const file = view.file; + if (!file) { + return; + } + + const preview = await this.fileSystemService.readFile(file); + if (preview instanceof Error || preview.trim() === "") { + return; // Either an excluded file or nothing to apply a template to + } + + this.userSelectFile(this.plugin, async (templateFile) => { + await this.asSerialAction("Apply template", async () => { + const content = await this.fileSystemService.readFile(file); + if (content instanceof Error || content.trim() === "") { + return; // Either an excluded file or nothing to apply a template to + } + + const templateContent = await this.fileSystemService.readFile(templateFile); + if (templateContent instanceof Error || templateContent.trim() === "") { + return; // Either an excluded file or the template is empty + } + + const notice = this.showNotice("Applying template..."); + try { + const context = `${Copy.ApplyTemplateTemplateSeparator}\n${templateContent}\n${Copy.ApplyTemplateContentSeparator}\n${content}`; + const result = await this.performAction(ApplyTemplatePrompt, context); + if (result && result.trim() !== Copy.ApplyTemplateCancelled.toString()) { + await this.fileSystemService.writeToFile(file, result, false, false); + } + } finally { + notice.hide(); + } + }); + }); + } + + public async applyLinks(_menu: Menu, editor: Editor, view: MarkdownView | MarkdownFileInfo) { + await this.asSerialAction("Apply links", async () => { + const file = view.file; + if (!file) { + return; + } + + const selection = editor.getSelection(); + const content = await this.fileSystemService.readFile(file); + + if (content instanceof Error || (selection.trim() === "" && content.trim() === "")) { + return; // Either an excluded file or nothing to proofread + } + + const links = this.vaultcacheService.wikiLinks.links.join("\n"); + const prompt = replaceCopy(ApplyLinksPrompt, [links]); + + const notice = this.showNotice("Applying links..."); + try { + if (selection.length > 0) { + const result = await this.performAction(prompt, selection); + if (result) { + await this.fileSystemService.patchFile(file, [selection], [result], false, false); + } + } else { + const { body } = splitFrontmatter(content); + if (body.trim() === "") { + return; + } + const result = await this.performAction(prompt, body); + if (result) { + await this.fileSystemService.patchFile(file, [body], [result], false, false); + } + } + } finally { + notice.hide(); + } + }); + } + + public async applyTags(_menu: Menu, _editor: Editor, view: MarkdownView | MarkdownFileInfo) { + await this.asSerialAction("Apply 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 allowedTags = this.vaultcacheService.tags; + const prompt = replaceCopy(ApplyTagsPrompt, [Array.from(allowedTags).join("\n")]); + + const notice = this.showNotice("Applying 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 && allowedTags.has(tag)); + + if (chosen.length === 0) { + return; + } + + const updated = mergeTagsIntoFrontmatter(content, chosen); + + if (updated !== content) { + await this.fileSystemService.writeToFile(file, updated, false, false); + } + } finally { + notice.hide(); + } + }); + } + + public async generateFrontmatter(_menu: Menu, _editor: Editor, view: MarkdownView | MarkdownFileInfo) { + await this.asSerialAction("Generate frontmatter", async () => { + + }); + } + + /* Helpers */ + + private userSelectFile(plugin: VaultkeeperAIPlugin, onSelected: (file: TFile) => Promise): void { + const fileSystemService = this.fileSystemService; + + new (class extends FuzzySuggestModal { + getItems() { return fileSystemService.getMarkdownFiles(); } + getItemText(f: TFile) { return f.path; } + onChooseItem(f: TFile) { void onSelected(f); } + })(plugin.app).open(); + } + + private async asSerialAction(name: string, action: () => Promise): Promise { + if (!await this.semaphore.wait(30000)) { + this.showNotice(`Quick action '${name}' timed out`, 3000); + return; + } + + try { + await action(); + } finally { + this.semaphore.release(); + } + } + + private async performAction(action: string, context: string): Promise { + if (this.settingsService.getApiKeyForCurrentModel().trim() == "") { + openPluginSettings(this.plugin); + return null; + } + const agent = Resolve(Services.QuickAgent); + agent.resolveAIProvider(); + return agent.quickAction(action, context); + } + + private showNotice(message: string, durationMs: number = 0): Notice { + const fragment = createFragment(); + const container = fragment.createDiv(); + + container.addClass("quick-action-notice"); + mount(Spinner, { target: container, props: { + width: "var(--size-4-4)", + height: "var(--size-4-4)", + background: "var(--background-modifier-message)" + }}); + + container.createSpan({ text: message }); + + return new Notice(fragment, durationMs); + } +} \ No newline at end of file diff --git a/Services/QuickActions/QuickActionsService.ts b/Services/QuickActions/QuickActionsService.ts new file mode 100644 index 0000000..9f0aee8 --- /dev/null +++ b/Services/QuickActions/QuickActionsService.ts @@ -0,0 +1,173 @@ +import type VaultkeeperAIPlugin from "main"; +import { MarkdownView, Menu, setIcon, type EventRef } from "obsidian"; +import { Resolve } from "Services/DependencyService"; +import { Services } from "Services/Services"; +import type { SettingsService } from "Services/SettingsService"; +import { WorkSpaceService } from "Services/WorkSpaceService"; +import type { QuickActionsDefinitionsService } from "./QuickActionsDefinitionsService"; + +export class QuickActionsService { + + private readonly plugin: VaultkeeperAIPlugin; + private readonly settingsService: SettingsService; + private readonly workSpaceService: WorkSpaceService; + private readonly quickActionsDefinitionsService: QuickActionsDefinitionsService; + + private editorMenuEventRef: EventRef | null = null; + private layoutChangeEventRef: EventRef | null = null; + + private readonly settingsSubscription: object; + + public constructor() { + this.plugin = Resolve(Services.VaultkeeperAIPlugin); + this.settingsService = Resolve(Services.SettingsService); + this.workSpaceService = Resolve(Services.WorkSpaceService); + this.quickActionsDefinitionsService = Resolve(Services.QuickActionsDefinitionsService); + + this.settingsSubscription = this.settingsService.subscribeToSettingsChanged(changed => { + if (changed.includes("enableToolbarActions") || changed.includes("enableContextMenuActions")) { + this.updateRegistrations(); + } + }); + + this.registerEditorMenuActions(); + this.registerViewActions(); + } + + public dispose() { + this.settingsService.unsubscribe(this.settingsSubscription); + this.unregisterEditorMenuActions(); + this.unregisterViewActions(); + } + + /* Action Registration */ + + private registerEditorMenuActions() { + if (!this.settingsService.settings.enableContextMenuActions) { + this.unregisterEditorMenuActions(); + return; + } + if (this.editorMenuEventRef) { + return; + } + + this.editorMenuEventRef = this.plugin.app.workspace.on("editor-menu", (menu, editor, view) => { + menu.addItem((item) => { + item.setTitle("Proofread") + .setIcon("scan-text") + .onClick(async () => this.quickActionsDefinitionsService.proofread(menu, editor, view)); + }); + menu.addItem((item) => { + item.setTitle("Beautify") + .setIcon("palette") + .onClick(async () => this.quickActionsDefinitionsService.beautify(menu, editor, view)); + }); + menu.addItem((item) => { + item.setTitle("Apply template") + .setIcon("notepad-text-dashed") + .onClick(async () => this.quickActionsDefinitionsService.applyTemplate(menu, editor, view)); + }); + menu.addItem((item) => { + item.setTitle("Apply links") + .setIcon("link") + .onClick(async () => this.quickActionsDefinitionsService.applyLinks(menu, editor, view)); + }); + menu.addItem((item) => { + item.setTitle("Apply tags") + .setIcon("tag") + .onClick(async () => this.quickActionsDefinitionsService.applyTags(menu, editor, view)); + }); + }); + this.plugin.registerEvent(this.editorMenuEventRef); + } + + private registerViewActions() { + if (!this.settingsService.settings.enableToolbarActions) { + this.unregisterViewActions(); + return; + } + if (this.layoutChangeEventRef) { + return; + } + + this.layoutChangeEventRef = this.plugin.app.workspace.on("layout-change", () => { + this.injectToolbarButton(); + }); + this.plugin.registerEvent(this.layoutChangeEventRef); + this.injectToolbarButton(); + } + + private injectToolbarButton() { + const leaves = this.workSpaceService.getLeavesOfType("markdown"); + for (const leaf of leaves) { + const view = leaf.view; + if (!(view instanceof MarkdownView)) { + continue; + } + + const actionsEl = view.containerEl.querySelector(".view-actions"); + if (!actionsEl || actionsEl.querySelector(".vault-keeper-ai-actions")) { + continue; + } + + const button = createEl("button", { cls: "clickable-icon view-action vault-keeper-ai-actions" }); + button.setAttribute("aria-label", "AI quick actions"); + button.addEventListener("click", (evt) => { + const { editor } = view; + const menu = new Menu(); + menu.addItem((item) => + item.setTitle("Proofread") + .setIcon("scan-text") + .onClick(async () => this.quickActionsDefinitionsService.proofread(menu, editor, view)) + ); + menu.addItem((item) => + item.setTitle("Beautify") + .setIcon("palette") + .onClick(async () => this.quickActionsDefinitionsService.beautify(menu, editor, view)) + ); + menu.addItem((item) => + item.setTitle("Apply template") + .setIcon("notepad-text-dashed") + .onClick(async () => this.quickActionsDefinitionsService.applyTemplate(menu, editor, view)) + ); + menu.addItem((item) => + item.setTitle("Apply links") + .setIcon("link") + .onClick(async () => this.quickActionsDefinitionsService.applyLinks(menu, editor, view)) + ); + menu.addItem((item) => + item.setTitle("Apply tags") + .setIcon("tag") + .onClick(async () => this.quickActionsDefinitionsService.applyTags(menu, editor, view)) + ); + menu.showAtMouseEvent(evt); + }); + setIcon(button, "sparkles"); + actionsEl.prepend(button); + } + } + + private updateRegistrations() { + this.registerEditorMenuActions(); + this.registerViewActions(); + } + + private unregisterEditorMenuActions() { + if (this.editorMenuEventRef) { + this.plugin.app.workspace.offref(this.editorMenuEventRef); + this.editorMenuEventRef = null; + } + } + + private unregisterViewActions(){ + if (this.layoutChangeEventRef) { + this.plugin.app.workspace.offref(this.layoutChangeEventRef); + this.layoutChangeEventRef = null; + // Remove any existing toolbar buttons + this.plugin.app.workspace.containerEl + .querySelectorAll(".vault-keeper-ai-actions") + .forEach(element => element.remove()); + } + } + +} \ No newline at end of file diff --git a/Services/QuickActionsService.ts b/Services/QuickActionsService.ts deleted file mode 100644 index 6c053af..0000000 --- a/Services/QuickActionsService.ts +++ /dev/null @@ -1,235 +0,0 @@ -import { Resolve } from "./DependencyService"; -import { Services } from "./Services"; -import type { QuickAgent } from "./AIServices/QuickAgent"; -import type VaultkeeperAIPlugin from "main"; -import { FuzzySuggestModal, MarkdownView, Menu, Notice, setIcon, TFile, type Editor, type EventRef, type MarkdownFileInfo } from "obsidian"; -import { FileSystemService } from "./FileSystemService"; -import { BeautifyPrompt } from "AIPrompts/QuickActionPrompts/Beautify"; -import Spinner from "Components/Spinner.svelte"; -import { mount } from "svelte"; -import { ApplyTemplatePrompt } from "AIPrompts/QuickActionPrompts/ApplyTemplatePrompt"; -import { Copy } from "Enums/Copy"; -import type { WorkSpaceService } from "./WorkSpaceService"; -import type { SettingsService } from "./SettingsService"; -import type { EventService } from "./EventService"; -import { Event } from "Enums/Event"; -import { openPluginSettings } from "Helpers/Helpers"; - -export class QuickActionsService { - - private plugin: VaultkeeperAIPlugin; - private workSpaceService: WorkSpaceService; - private fileSystemService: FileSystemService; - private settingsService: SettingsService; - private eventService: EventService; - - private editorMenuEventRef: EventRef | null = null; - private layoutChangeEventRef: EventRef | null = null; - - public constructor() { - this.plugin = Resolve(Services.VaultkeeperAIPlugin); - this.workSpaceService = Resolve(Services.WorkSpaceService); - this.fileSystemService = Resolve(Services.FileSystemService); - this.settingsService = Resolve(Services.SettingsService); - this.eventService = Resolve(Services.EventService); - - this.plugin.registerEvent( - this.eventService.on(Event.QuickActionsSettingsChanged, () => { - this.updateRegistrations(); - }) - ); - - this.registerEditorMenuActions(); - this.registerViewActions(); - } - - /* Action Definitions */ - - private async beautify(_menu: Menu, editor: Editor, view: MarkdownView | MarkdownFileInfo) { - const file = view.file; - if (!file) { - return; - } - - const selection = editor.getSelection(); - const content = await this.fileSystemService.readFile(file); - - if (content instanceof Error || (selection.trim() === "" && content.trim() === "")) { - return; // Either an excluded file or nothing to beautify - } - - const notice = this.showNotice("Beautifying content..."); - try { - if (selection.length > 0) { - const result = await this.newAction(BeautifyPrompt, selection); - if (result) { - await this.fileSystemService.patchFile(file, [selection], [result], false, false); - } - } else { - const result = await this.newAction(BeautifyPrompt, content); - if (result) { - await this.fileSystemService.writeToFile(file, result, false, false); - } - } - } finally { - notice.hide(); - } - } - - private async applyTemplate(_menu: Menu, _editor: Editor, view: MarkdownView | MarkdownFileInfo) { - 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 apply a template to - } - - this.userSelectFile(this.plugin, async (templateFile) => { - const templateContent = await this.fileSystemService.readFile(templateFile); - if (templateContent instanceof Error || templateContent.trim() === "") { - return; // Either an excluded file or the template is empty - } - - const notice = this.showNotice("Applying template..."); - try { - const context = `${Copy.ApplyTemplateTemplateSeparator}\n${templateContent}\n${Copy.ApplyTemplateContentSeparator}\n${content}`; - const result = await this.newAction(ApplyTemplatePrompt, context); - - if (result && result.trim() !== Copy.ApplyTemplateCancelled.toString()) { - await this.fileSystemService.writeToFile(file, result, false, false); - } - } finally { - notice.hide(); - } - }); - } - - /* Action Registration */ - - private registerEditorMenuActions() { - if (!this.settingsService.settings.enableContextMenuActions) { - if (this.editorMenuEventRef) { - this.plugin.app.workspace.offref(this.editorMenuEventRef); - this.editorMenuEventRef = null; - } - return; - } - if (this.editorMenuEventRef) { - return; - } - - this.editorMenuEventRef = this.plugin.app.workspace.on("editor-menu", (menu, editor, view) => { - menu.addItem((item) => { - item.setTitle("Beautify") - .setIcon("palette") - .onClick(async () => this.beautify(menu, editor, view)); - }); - menu.addItem((item) => { - item.setTitle("Apply template") - .setIcon("notepad-text-dashed") - .onClick(async () => this.applyTemplate(menu, editor, view)); - }); - }); - this.plugin.registerEvent(this.editorMenuEventRef); - } - - private registerViewActions() { - if (!this.settingsService.settings.enableToolbarActions) { - if (this.layoutChangeEventRef) { - this.plugin.app.workspace.offref(this.layoutChangeEventRef); - this.layoutChangeEventRef = null; - // Remove any existing toolbar buttons - this.plugin.app.workspace.containerEl - .querySelectorAll(".vault-keeper-ai-actions") - .forEach(el => el.remove()); - } - return; - } - if (this.layoutChangeEventRef) { - return; - } - - this.layoutChangeEventRef = this.plugin.app.workspace.on("layout-change", () => { - this.injectToolbarButton(); - }); - this.plugin.registerEvent(this.layoutChangeEventRef); - this.injectToolbarButton(); - } - - private injectToolbarButton() { - const actionsView = this.workSpaceService.getViewActionsView(); - const view = this.workSpaceService.getActiveViewOfType(MarkdownView); - if (!actionsView || !view || actionsView.querySelector(".vault-keeper-ai-actions")) { - return; - } - const button = createEl("button", { cls: "clickable-icon view-action vault-keeper-ai-actions" }); - button.setAttribute('aria-label', 'AI quick actions'); - button.addEventListener('click', (evt) => { - const view = this.workSpaceService.getActiveViewOfType(MarkdownView); - if (view) { - const { editor } = view; - const menu = new Menu(); - menu.addItem((item) => - item.setTitle("Beautify") - .setIcon("palette") - .onClick(async () => this.beautify(menu, editor, view)) - ); - menu.addItem((item) => - item.setTitle("Apply template") - .setIcon("notepad-text-dashed") - .onClick(async () => this.applyTemplate(menu, editor, view)) - ); - menu.showAtMouseEvent(evt); - } - }); - setIcon(button, "sparkles"); - actionsView.prepend(button); - } - - private updateRegistrations() { - this.registerEditorMenuActions(); - this.registerViewActions(); - } - - /* Helpers */ - - private userSelectFile(plugin: VaultkeeperAIPlugin, onSelected: (file: TFile) => Promise): void { - const fileSystemService = this.fileSystemService; - - new (class extends FuzzySuggestModal { - getItems() { return fileSystemService.getMarkdownFiles(); } - getItemText(f: TFile) { return f.path; } - onChooseItem(f: TFile) { void onSelected(f); } - })(plugin.app).open(); - } - - private async newAction(action: string, context: string): Promise { - if (this.settingsService.getApiKeyForCurrentModel().trim() == "") { - openPluginSettings(this.plugin); - return null; - } - const agent = Resolve(Services.QuickAgent); - agent.resolveAIProvider(); - return agent.quickAction(action, context); - } - - private showNotice(message: string): Notice { - const fragment = createFragment(); - const container = activeDocument.createDiv(); - - container.addClass("quick-action-notice"); - mount(Spinner, { target: container, props: { - width: "var(--size-4-4)", - height: "var(--size-4-4)", - background: "var(--background-modifier-message)" - }}); - - container.createSpan({ text: message }); - fragment.appendChild(container); - - return new Notice(fragment, 0); - } -} \ No newline at end of file diff --git a/Services/ServiceRegistration.ts b/Services/ServiceRegistration.ts index 089da2d..4d08ba7 100644 --- a/Services/ServiceRegistration.ts +++ b/Services/ServiceRegistration.ts @@ -57,7 +57,9 @@ import { OpenAIFileService } from "AIClasses/OpenAI/OpenAIFileService"; // Prompts import { AIPrompt, type IPrompt } from "AIPrompts/IPrompt"; import { QuickAgent } from "./AIServices/QuickAgent"; -import { QuickActionsService } from "./QuickActionsService"; +import { QuickActionsDefinitionsService } from "./QuickActions/QuickActionsDefinitionsService"; +import { QuickActionsService } from "./QuickActions/QuickActionsService"; + export async function RegisterPlugin(plugin: VaultkeeperAIPlugin) { RegisterSingleton(Services.VaultkeeperAIPlugin, plugin); @@ -84,6 +86,7 @@ export function RegisterDependencies() { RegisterSingleton(Services.MemoriesService, new MemoriesService()); RegisterSingleton(Services.ConversationFileSystemService, new ConversationFileSystemService()); RegisterSingleton(Services.ConversationNamingService, new ConversationNamingService()); + RegisterSingleton(Services.QuickActionsDefinitionsService, new QuickActionsDefinitionsService()); RegisterSingleton(Services.QuickActionsService, new QuickActionsService()); RegisterTransient(Services.WebViewerService, () => new WebViewerService()); diff --git a/Services/Services.ts b/Services/Services.ts index 31503dc..77d3eed 100644 --- a/Services/Services.ts +++ b/Services/Services.ts @@ -12,6 +12,7 @@ export class Services { static ConversationFileSystemService = Symbol("ConversationFileSystemService"); static ConversationNamingService = Symbol("ConversationNamingService"); static QuickActionsService = Symbol("QuickActionsService"); + static QuickActionsDefinitionsService = Symbol("QuickActionsDefinitionsService"); static StreamingService = Symbol("StreamingService"); static MarkdownService = Symbol("MarkdownService"); static StreamingMarkdownService = Symbol("StreamingMarkdownService"); diff --git a/Services/VaultCacheService.ts b/Services/VaultCacheService.ts index 712e82e..5c06f62 100644 --- a/Services/VaultCacheService.ts +++ b/Services/VaultCacheService.ts @@ -13,6 +13,7 @@ import { WikiLinks } from "Helpers/WikiLinks"; export class VaultCacheService { + public tags: Set = new Set(); public wikiLinks: WikiLinks = new WikiLinks(); private readonly fuzzysortOptions = { @@ -25,7 +26,6 @@ export class VaultCacheService { private readonly vaultService: VaultService; private readonly metaDataCache: MetadataCache; - private tags: Set = new Set(); private files: Map = new Map(); private folders: Map = new Map(); private mapping: FileTagMapping = new FileTagMapping(); diff --git a/Services/WorkSpaceService.ts b/Services/WorkSpaceService.ts index a850960..04dfec1 100644 --- a/Services/WorkSpaceService.ts +++ b/Services/WorkSpaceService.ts @@ -1,12 +1,12 @@ import type VaultkeeperAIPlugin from "main"; import { Resolve } from "./DependencyService"; import { Services } from "./Services"; -import { Notice, TFile, type View, type WorkspaceLeaf } from "obsidian"; -import type { VaultService } from "./VaultService"; +import { Notice, TFile, type WorkspaceLeaf } from "obsidian"; +import type { FileSystemService } from "./FileSystemService"; export class WorkSpaceService { private readonly plugin: VaultkeeperAIPlugin = Resolve(Services.VaultkeeperAIPlugin); - private readonly vaultService: VaultService = Resolve(Services.VaultService); + private readonly fileSystemService: FileSystemService = Resolve(Services.FileSystemService); public async openNote(noteName: string) { const file: TFile | null = this.plugin.app.metadataCache.getFirstLinkpathDest(noteName, ""); @@ -33,20 +33,14 @@ export class WorkSpaceService { public getActiveFile(allowAccessToPluginRoot: boolean = false): TFile | null { const activeFile = this.plugin.app.workspace.getActiveFile(); - if (!activeFile || this.vaultService.isExclusion(activeFile.path, allowAccessToPluginRoot)) { + if (!activeFile || this.fileSystemService.isExclusion(activeFile.path, allowAccessToPluginRoot)) { return null; } return activeFile; } - public getActiveViewOfType(type: new (...args: WorkspaceLeaf[]) => ViewType): ViewType | null { - return this.plugin.app.workspace.getActiveViewOfType(type); - } - - - public getViewActionsView(): Element | null | undefined { - const leaf = this.plugin.app.workspace.getMostRecentLeaf(); - return leaf?.view?.containerEl?.querySelector('.view-actions'); + public getLeavesOfType(type: string): WorkspaceLeaf[] { + return this.plugin.app.workspace.getLeavesOfType(type); } } \ No newline at end of file diff --git a/Views/VaultkeeperAISettingTab.ts b/Views/VaultkeeperAISettingTab.ts index 39fb90b..aaa8abc 100644 --- a/Views/VaultkeeperAISettingTab.ts +++ b/Views/VaultkeeperAISettingTab.ts @@ -1,6 +1,5 @@ import { AIProvider, AIProviderModel, fromModel, isValidProviderModel } from "Enums/ApiProvider"; import { Copy } from "Enums/Copy"; -import { Event } from "Enums/Event"; import { Selector } from "Enums/Selector"; import type VaultkeeperAIPlugin from "main"; import { HelpModal } from "Modals/HelpModal"; @@ -298,7 +297,6 @@ export class VaultkeeperAISettingTab extends PluginSettingTab { await this.settingsService.updateSettings(settings => { settings.enableContextMenuActions = value; }); - this.eventService.trigger(Event.QuickActionsSettingsChanged); }); }); @@ -313,7 +311,6 @@ export class VaultkeeperAISettingTab extends PluginSettingTab { await this.settingsService.updateSettings(settings => { settings.enableToolbarActions = value; }); - this.eventService.trigger(Event.QuickActionsSettingsChanged); }); }); diff --git a/__tests__/AIClasses/Claude.test.ts b/__tests__/AIClasses/Claude.test.ts index 98b3e3a..71b97bd 100644 --- a/__tests__/AIClasses/Claude.test.ts +++ b/__tests__/AIClasses/Claude.test.ts @@ -13,7 +13,8 @@ import { SettingsService } from '../../Services/SettingsService'; import { AIProvider } from '../../Enums/ApiProvider'; import { AbortService } from '../../Services/AbortService'; import { Exception } from '../../Helpers/Exception'; -import { Copy, replaceCopy } from 'Enums/Copy'; +import { Copy } from 'Enums/Copy'; +import { replaceCopy } from 'Helpers/Helpers'; describe('Claude', () => { let claude: Claude; diff --git a/__tests__/AIClasses/Gemini.test.ts b/__tests__/AIClasses/Gemini.test.ts index bec825b..ab699b8 100644 --- a/__tests__/AIClasses/Gemini.test.ts +++ b/__tests__/AIClasses/Gemini.test.ts @@ -12,7 +12,8 @@ import { SettingsService } from '../../Services/SettingsService'; import { AIProvider } from '../../Enums/ApiProvider'; import { AbortService } from '../../Services/AbortService'; import { Exception } from '../../Helpers/Exception'; -import { Copy, replaceCopy } from 'Enums/Copy'; +import { Copy } from 'Enums/Copy'; +import { replaceCopy } from 'Helpers/Helpers'; describe('Gemini', () => { let gemini: Gemini; diff --git a/__tests__/AIClasses/Mistral.test.ts b/__tests__/AIClasses/Mistral.test.ts index 3c9bdcf..d261134 100644 --- a/__tests__/AIClasses/Mistral.test.ts +++ b/__tests__/AIClasses/Mistral.test.ts @@ -13,7 +13,8 @@ import { AIProvider } from '../../Enums/ApiProvider'; import { AbortService } from '../../Services/AbortService'; import { Exception } from '../../Helpers/Exception'; import { AITool } from 'Enums/AITool'; -import { Copy, replaceCopy } from 'Enums/Copy'; +import { Copy } from 'Enums/Copy'; +import { replaceCopy } from 'Helpers/Helpers'; describe('Mistral', () => { let mistral: Mistral; diff --git a/__tests__/AIClasses/OpenAI.test.ts b/__tests__/AIClasses/OpenAI.test.ts index 18033e0..0918fb8 100644 --- a/__tests__/AIClasses/OpenAI.test.ts +++ b/__tests__/AIClasses/OpenAI.test.ts @@ -12,7 +12,8 @@ import { SettingsService } from '../../Services/SettingsService'; import { AIProvider } from '../../Enums/ApiProvider'; import { Exception } from '../../Helpers/Exception'; import { AbortService } from '../../Services/AbortService'; -import { Copy, replaceCopy } from 'Enums/Copy'; +import { Copy } from 'Enums/Copy'; +import { replaceCopy } from 'Helpers/Helpers'; describe('OpenAI', () => { let openai: OpenAI; diff --git a/__tests__/Helpers/Helpers.test.ts b/__tests__/Helpers/Helpers.test.ts index 6952679..22411ab 100644 --- a/__tests__/Helpers/Helpers.test.ts +++ b/__tests__/Helpers/Helpers.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi } from 'vitest'; import { StringTools } from "../../Helpers/StringTools"; -import { randomSample, openPluginSettings } from '../../Helpers/Helpers'; +import { randomSample, openPluginSettings, splitFrontmatter, mergeTagsIntoFrontmatter } from '../../Helpers/Helpers'; describe('Helpers', () => { describe('dateToString', () => { @@ -369,4 +369,163 @@ describe('Helpers', () => { expect(mockPlugin.app.setting.openTabById).toHaveBeenCalledWith(pluginId); }); }); + + describe('splitFrontmatter', () => { + it('returns empty frontmatter and the whole content as body when no frontmatter is present', () => { + const content = '# Heading\n\nSome body text.'; + const result = splitFrontmatter(content); + expect(result.frontmatter).toBe(''); + expect(result.body).toBe(content); + }); + + it('splits a standard frontmatter block from the body', () => { + const content = '---\ntitle: My Note\ntags: [a, b]\n---\n# Heading\n\nBody text.'; + const result = splitFrontmatter(content); + expect(result.frontmatter).toBe('---\ntitle: My Note\ntags: [a, b]\n---\n'); + expect(result.body).toBe('# Heading\n\nBody text.'); + }); + + it('handles CRLF line endings', () => { + const content = '---\r\ntitle: My Note\r\n---\r\nBody text.'; + const result = splitFrontmatter(content); + expect(result.frontmatter).toBe('---\r\ntitle: My Note\r\n---\r\n'); + expect(result.body).toBe('Body text.'); + }); + + it('handles a closing frontmatter line that has no trailing newline', () => { + const content = '---\ntitle: My Note\n---'; + const result = splitFrontmatter(content); + expect(result.frontmatter).toBe('---\ntitle: My Note\n---'); + expect(result.body).toBe(''); + }); + + it('returns empty body when content is only frontmatter followed by a newline', () => { + const content = '---\ntitle: My Note\n---\n'; + const result = splitFrontmatter(content); + expect(result.frontmatter).toBe('---\ntitle: My Note\n---\n'); + expect(result.body).toBe(''); + }); + + it('returns empty frontmatter and empty body for empty input', () => { + const result = splitFrontmatter(''); + expect(result.frontmatter).toBe(''); + expect(result.body).toBe(''); + }); + + it('does not treat a non-leading --- divider as frontmatter', () => { + const content = '# Heading\n\n---\n\nA horizontal rule above.'; + const result = splitFrontmatter(content); + expect(result.frontmatter).toBe(''); + expect(result.body).toBe(content); + }); + + it('preserves later --- dividers in the body when frontmatter is present', () => { + const content = '---\ntitle: My Note\n---\nIntro paragraph.\n\n---\n\nSection after a horizontal rule.'; + const result = splitFrontmatter(content); + expect(result.frontmatter).toBe('---\ntitle: My Note\n---\n'); + expect(result.body).toBe('Intro paragraph.\n\n---\n\nSection after a horizontal rule.'); + }); + + it('reassembles to the original content', () => { + const content = '---\ntitle: My Note\ntags: [a, b]\n---\n# Heading\n\nBody text with --- inside.'; + const result = splitFrontmatter(content); + 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'); + }); + }); });