2026-05-29 21:01:02 +00:00
|
|
|
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";
|
2026-05-30 11:33:38 +00:00
|
|
|
import { openPluginSettings, replaceCopy, splitFrontmatter } from "Helpers/Helpers";
|
|
|
|
|
import { mergeFrontmatterFields, mergeListIntoFrontmatter, parseFrontmatterYaml } from "Helpers/FrontmatterHelpers";
|
2026-05-29 21:01:02 +00:00
|
|
|
import { ProofreadPrompt } from "AIPrompts/QuickActionPrompts/ProofreadPrompt";
|
|
|
|
|
import { VaultCacheService } from "Services/VaultCacheService";
|
|
|
|
|
import { ApplyLinksPrompt } from "AIPrompts/QuickActionPrompts/ApplyLinksPrompt";
|
|
|
|
|
import { ApplyTagsPrompt } from "AIPrompts/QuickActionPrompts/ApplyTagsPrompt";
|
2026-05-30 11:33:38 +00:00
|
|
|
import { GenerateFrontmatterPrompt } from "AIPrompts/QuickActionPrompts/GenerateFrontmatterPrompt";
|
2026-05-29 21:01:02 +00:00
|
|
|
import { Semaphore } from "Helpers/Semaphore";
|
2026-05-30 11:33:38 +00:00
|
|
|
import { SuggestTagsPrompt } from "AIPrompts/QuickActionPrompts/SuggestTagsPrompt";
|
2026-07-04 11:16:08 +00:00
|
|
|
import { AIProvider } from "Enums/ApiProvider";
|
2026-05-29 21:01:02 +00:00
|
|
|
|
|
|
|
|
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<VaultkeeperAIPlugin>(Services.VaultkeeperAIPlugin);
|
|
|
|
|
this.fileSystemService = Resolve<FileSystemService>(Services.FileSystemService);
|
|
|
|
|
this.vaultcacheService = Resolve<VaultCacheService>(Services.VaultCacheService);
|
|
|
|
|
this.settingsService = Resolve<SettingsService>(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
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-30 15:03:46 +00:00
|
|
|
const prompt = replaceCopy(ApplyTemplatePrompt,
|
|
|
|
|
[
|
|
|
|
|
new Date(file.stat.ctime).toString(),
|
|
|
|
|
new Date(file.stat.mtime).toString(),
|
|
|
|
|
file.stat.size.toString(),
|
|
|
|
|
new Date().toString()
|
|
|
|
|
]);
|
|
|
|
|
|
2026-05-29 21:01:02 +00:00
|
|
|
const notice = this.showNotice("Applying template...");
|
|
|
|
|
try {
|
|
|
|
|
const context = `${Copy.ApplyTemplateTemplateSeparator}\n${templateContent}\n${Copy.ApplyTemplateContentSeparator}\n${content}`;
|
2026-05-30 15:03:46 +00:00
|
|
|
const result = await this.performAction(prompt, context);
|
2026-05-29 21:01:02 +00:00
|
|
|
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)
|
2026-05-30 11:33:38 +00:00
|
|
|
.filter(tag => tag.length > 0 && allowedTags.has(tag))
|
|
|
|
|
.map(tag => tag.replace(/^#/, ""));
|
2026-05-29 21:01:02 +00:00
|
|
|
|
|
|
|
|
if (chosen.length === 0) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-30 11:33:38 +00:00
|
|
|
await this.fileSystemService.updateFrontmatter(file, frontmatter => {
|
|
|
|
|
mergeListIntoFrontmatter(frontmatter, "tags", chosen);
|
|
|
|
|
});
|
|
|
|
|
} finally {
|
|
|
|
|
notice.hide();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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(/^#/, ""));
|
2026-05-29 21:01:02 +00:00
|
|
|
|
2026-05-30 11:33:38 +00:00
|
|
|
if (chosen.length === 0) {
|
|
|
|
|
return;
|
2026-05-29 21:01:02 +00:00
|
|
|
}
|
2026-05-30 11:33:38 +00:00
|
|
|
|
|
|
|
|
await this.fileSystemService.updateFrontmatter(file, frontmatter => {
|
|
|
|
|
mergeListIntoFrontmatter(frontmatter, "tags", chosen);
|
|
|
|
|
});
|
2026-05-29 21:01:02 +00:00
|
|
|
} finally {
|
|
|
|
|
notice.hide();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async generateFrontmatter(_menu: Menu, _editor: Editor, view: MarkdownView | MarkdownFileInfo) {
|
|
|
|
|
await this.asSerialAction("Generate frontmatter", async () => {
|
2026-05-30 11:33:38 +00:00
|
|
|
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;
|
2026-05-30 15:03:46 +00:00
|
|
|
const prompt = replaceCopy(GenerateFrontmatterPrompt,
|
|
|
|
|
[
|
|
|
|
|
Array.from(availableTags).join("\n"),
|
|
|
|
|
new Date(file.stat.ctime).toString(),
|
|
|
|
|
new Date().toString()
|
|
|
|
|
]);
|
2026-05-30 11:33:38 +00:00
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
}
|
2026-05-29 21:01:02 +00:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* Helpers */
|
|
|
|
|
|
|
|
|
|
private userSelectFile(plugin: VaultkeeperAIPlugin, onSelected: (file: TFile) => Promise<void>): void {
|
|
|
|
|
const fileSystemService = this.fileSystemService;
|
|
|
|
|
|
|
|
|
|
new (class extends FuzzySuggestModal<TFile> {
|
|
|
|
|
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<void>): Promise<void> {
|
|
|
|
|
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<string | null> {
|
2026-07-04 11:16:08 +00:00
|
|
|
if (this.settingsService.settings.provider !== AIProvider.Local && this.settingsService.getApiKeyForCurrentProvider().trim() == "") {
|
2026-05-29 21:01:02 +00:00
|
|
|
openPluginSettings(this.plugin);
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
const agent = Resolve<QuickAgent>(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);
|
|
|
|
|
}
|
|
|
|
|
}
|