From bf0126e998b36706484da58ec81b6c80d44d22ac Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Wed, 5 Apr 2023 14:38:26 +0200 Subject: [PATCH 1/3] feat: Capture Anywhere: captures now support folder paths as the 'Capture To' target This enables you to capture to any file in a folder (or entire vault!) by asking which file to capture to. re #227 --- src/engine/CaptureChoiceEngine.ts | 105 +++++++++++++++++++++++++----- src/utilityObsidian.ts | 11 ++++ 2 files changed, 98 insertions(+), 18 deletions(-) diff --git a/src/engine/CaptureChoiceEngine.ts b/src/engine/CaptureChoiceEngine.ts index 8bafc5d..e11e2fe 100644 --- a/src/engine/CaptureChoiceEngine.ts +++ b/src/engine/CaptureChoiceEngine.ts @@ -8,6 +8,8 @@ import { openFile, replaceTemplaterTemplatesInCreatedFile, templaterParseTemplate, + isFolder, + getMarkdownFilesInFolder, } from "../utilityObsidian"; import { VALUE_SYNTAX } from "../constants"; import type QuickAdd from "../main"; @@ -16,6 +18,7 @@ import { SingleTemplateEngine } from "./SingleTemplateEngine"; import type { IChoiceExecutor } from "../IChoiceExecutor"; import invariant from "src/utils/invariant"; import merge from "three-way-merge"; +import InputSuggester from "src/gui/InputSuggester/inputSuggester"; export class CaptureChoiceEngine extends QuickAddChoiceEngine { choice: ICaptureChoice; @@ -40,7 +43,9 @@ export class CaptureChoiceEngine extends QuickAddChoiceEngine { async run(): Promise { try { - const filePath = await this.getFormattedPathToCaptureTo(); + const filePath = await this.getFormattedPathToCaptureTo( + this.choice.captureToActiveFile + ); const content = this.getCaptureContent(); let getFileAndAddContentFn: typeof this.onFileExists; @@ -63,14 +68,16 @@ export class CaptureChoiceEngine extends QuickAddChoiceEngine { const { file, newFileContent, captureContent } = await getFileAndAddContentFn(filePath, content); - if (this.choice.captureToActiveFile && !this.choice.prepend) { // Parse Templater syntax in the capture content. // If Templater isn't installed, it just returns the capture content. - const content = await templaterParseTemplate(app, captureContent, file); + const content = await templaterParseTemplate( + app, + captureContent, + file + ); appendToCurrentLine(content, this.app); - } else { await this.app.vault.modify(file, newFileContent); } @@ -80,7 +87,7 @@ export class CaptureChoiceEngine extends QuickAddChoiceEngine { file, "" ); - + appendToCurrentLine(markdownLink, this.app); } @@ -108,8 +115,20 @@ export class CaptureChoiceEngine extends QuickAddChoiceEngine { return content; } - private async getFormattedPathToCaptureTo(): Promise { - if (this.choice.captureToActiveFile) { + /** + * Gets a formatted file path to capture content to, either the active file or a specified location. + * If capturing to a folder, suggests a file within the folder to capture the content to. + * + * @param {boolean} shouldCaptureToActiveFile - Determines if the content should be captured to the active file. + * @returns {Promise} A promise that resolves to the formatted file path where the content should be captured. + * + * @throws {Error} Throws an error if there's no active file when trying to capture to active file, + * if the capture path is invalid, or if the target folder is empty. + */ + private async getFormattedPathToCaptureTo( + shouldCaptureToActiveFile: boolean + ): Promise { + if (shouldCaptureToActiveFile) { const activeFile = this.app.workspace.getActiveFile(); invariant( activeFile, @@ -120,21 +139,67 @@ export class CaptureChoiceEngine extends QuickAddChoiceEngine { } const captureTo = this.choice.captureTo; - invariant(captureTo, () => { - return `Invalid capture to for ${this.choice.name}. ${ - captureTo.length === 0 - ? "Capture path is empty." - : `Capture path is not valid: ${captureTo}` - }`; - }); - - return await this.formatFilePath(captureTo); + const formattedCaptureTo = await this.formatFilePath(captureTo); + + // Removing the trailing slash from the capture to path because otherwise isFolder will fail + // to get the folder. + const folderPath = formattedCaptureTo.replace(/^\/$|\/\.md$|^\.md$/, ""); + // Empty string means we suggest to capture anywhere in the vault. + const captureAnywhereInVault = folderPath === ""; + const shouldCaptureToFolder = + captureAnywhereInVault || isFolder(folderPath); + + if (shouldCaptureToFolder) { + return this.selectFileInFolder(folderPath, captureAnywhereInVault); + } + + return formattedCaptureTo; + } + + private async selectFileInFolder( + folderPath: string, + captureAnywhereInVault: boolean + ): Promise { + const folderPathSlash = + folderPath.endsWith("/") || captureAnywhereInVault + ? folderPath + : `${folderPath}/`; + const filesInFolder = getMarkdownFilesInFolder(folderPathSlash); + + invariant( + filesInFolder.length > 0, + `Folder ${folderPathSlash} is empty.` + ); + + const filePaths = filesInFolder.map((f) => f.path); + const targetFilePath = await InputSuggester.Suggest( + app, + filePaths.map((item) => item.replace(folderPathSlash, "")), + filePaths + ); + + invariant( + !!targetFilePath && targetFilePath.length > 0, + `No file selected for capture.` + ); + + // Ensure user has selected a file in target folder. InputSuggester allows user to write + // their own file path, so we need to make sure it's in the target folder. + const filePath = targetFilePath.startsWith(`${folderPathSlash}/`) + ? targetFilePath + : `${folderPathSlash}/${targetFilePath}`; + + return await this.formatFilePath(filePath); } private async onFileExists( filePath: string, content: string - ): Promise<{ file: TFile; newFileContent: string, captureContent: string }> { + ): Promise<{ + file: TFile; + newFileContent: string; + captureContent: string; + }> { const file: TFile = this.getFileByPath(filePath); if (!file) throw new Error("File not found"); @@ -178,7 +243,11 @@ export class CaptureChoiceEngine extends QuickAddChoiceEngine { private async onCreateFileIfItDoesntExist( filePath: string, captureContent: string - ): Promise<{ file: TFile; newFileContent: string, captureContent: string }> { + ): Promise<{ + file: TFile; + newFileContent: string; + captureContent: string; + }> { let fileContent = ""; if (this.choice.createFileIfItDoesntExist.createWithTemplate) { diff --git a/src/utilityObsidian.ts b/src/utilityObsidian.ts index ab0f914..ce6dbe0 100644 --- a/src/utilityObsidian.ts +++ b/src/utilityObsidian.ts @@ -225,3 +225,14 @@ export function getChoiceType< isMulti(choice) ); } + +export function isFolder(path: string): boolean { + const abstractItem = app.vault.getAbstractFileByPath(path); + + return !!abstractItem && abstractItem instanceof TFolder; +} + +export function getMarkdownFilesInFolder(folderPath: string): TFile[] { + return app.vault.getMarkdownFiles().filter((f) => f.path.startsWith(folderPath)); +} + From 85b2a5a0d5b14b85c4e53ba51220087ba75121ba Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Wed, 5 Apr 2023 15:28:28 +0200 Subject: [PATCH 2/3] feat: Capture with Tag: put a tag in the capture path to capture to a file with that tag Putting a tag in the capture path will now suggest files with that tag. You'll be able to select one of them to capture to a file with that tag. re #227 --- src/engine/CaptureChoiceEngine.ts | 34 ++++++++++++++++++++- src/utilityObsidian.ts | 49 +++++++++++++++++++++++++------ 2 files changed, 73 insertions(+), 10 deletions(-) diff --git a/src/engine/CaptureChoiceEngine.ts b/src/engine/CaptureChoiceEngine.ts index e11e2fe..4f81bfb 100644 --- a/src/engine/CaptureChoiceEngine.ts +++ b/src/engine/CaptureChoiceEngine.ts @@ -10,6 +10,7 @@ import { templaterParseTemplate, isFolder, getMarkdownFilesInFolder, + getMarkdownFilesWithTag, } from "../utilityObsidian"; import { VALUE_SYNTAX } from "../constants"; import type QuickAdd from "../main"; @@ -19,6 +20,7 @@ import type { IChoiceExecutor } from "../IChoiceExecutor"; import invariant from "src/utils/invariant"; import merge from "three-way-merge"; import InputSuggester from "src/gui/InputSuggester/inputSuggester"; +import GenericSuggester from "src/gui/GenericSuggester/genericSuggester"; export class CaptureChoiceEngine extends QuickAddChoiceEngine { choice: ICaptureChoice; @@ -143,16 +145,25 @@ export class CaptureChoiceEngine extends QuickAddChoiceEngine { // Removing the trailing slash from the capture to path because otherwise isFolder will fail // to get the folder. - const folderPath = formattedCaptureTo.replace(/^\/$|\/\.md$|^\.md$/, ""); + const folderPath = formattedCaptureTo.replace( + /^\/$|\/\.md$|^\.md$/, + "" + ); // Empty string means we suggest to capture anywhere in the vault. const captureAnywhereInVault = folderPath === ""; const shouldCaptureToFolder = captureAnywhereInVault || isFolder(folderPath); + const shouldCaptureWithTag = formattedCaptureTo.startsWith("#"); if (shouldCaptureToFolder) { return this.selectFileInFolder(folderPath, captureAnywhereInVault); } + if (shouldCaptureWithTag) { + const tag = formattedCaptureTo.replace(/\.md$/, ""); + return this.selectFileWithTag(tag); + } + return formattedCaptureTo; } @@ -192,6 +203,27 @@ export class CaptureChoiceEngine extends QuickAddChoiceEngine { return await this.formatFilePath(filePath); } + private async selectFileWithTag(tag: string): Promise { + const tagWithHash = tag.startsWith("#") ? tag : `#${tag}`; + const filesWithTag = getMarkdownFilesWithTag(tagWithHash); + + invariant(filesWithTag.length > 0, `No files with tag ${tag}.`); + + const filePaths = filesWithTag.map((f) => f.path); + const targetFilePath = await GenericSuggester.Suggest( + app, + filePaths, + filePaths + ); + + invariant( + !!targetFilePath && targetFilePath.length > 0, + `No file selected for capture.` + ); + + return await this.formatFilePath(targetFilePath); + } + private async onFileExists( filePath: string, content: string diff --git a/src/utilityObsidian.ts b/src/utilityObsidian.ts index ce6dbe0..c64e35b 100644 --- a/src/utilityObsidian.ts +++ b/src/utilityObsidian.ts @@ -1,4 +1,10 @@ -import type { App, TAbstractFile, WorkspaceLeaf } from "obsidian"; +import type { + App, + CachedMetadata, + TAbstractFile, + TagCache, + WorkspaceLeaf, +} from "obsidian"; import { MarkdownView, TFile, TFolder } from "obsidian"; import type { NewTabDirection } from "./types/newTabDirection"; import type { IUserScript } from "./types/macros/IUserScript"; @@ -23,9 +29,14 @@ export async function replaceTemplaterTemplatesInCreatedFile( if ( templater && - (force || !(templater.settings as Record)["trigger_on_file_creation"]) + (force || + !(templater.settings as Record)[ + "trigger_on_file_creation" + ]) ) { - const impl = (templater?.templater as { overwrite_file_commands?: (file: TFile) => Promise; }); + const impl = templater?.templater as { + overwrite_file_commands?: (file: TFile) => Promise; + }; if (impl?.overwrite_file_commands) { await impl.overwrite_file_commands(file); } @@ -40,10 +51,14 @@ export async function templaterParseTemplate( const templater = getTemplater(app); if (!templater) return templateContent; - return await (templater.templater as { parse_template: (opt: { target_file: TFile, run_mode: number}, content: string) => Promise}).parse_template( - { target_file: targetFile, run_mode: 4 }, - templateContent - ); + return await ( + templater.templater as { + parse_template: ( + opt: { target_file: TFile; run_mode: number }, + content: string + ) => Promise; + } + ).parse_template({ target_file: targetFile, run_mode: 4 }, templateContent); } export function getNaturalLanguageDates(app: App) { @@ -230,9 +245,25 @@ export function isFolder(path: string): boolean { const abstractItem = app.vault.getAbstractFileByPath(path); return !!abstractItem && abstractItem instanceof TFolder; -} +} export function getMarkdownFilesInFolder(folderPath: string): TFile[] { - return app.vault.getMarkdownFiles().filter((f) => f.path.startsWith(folderPath)); + return app.vault + .getMarkdownFiles() + .filter((f) => f.path.startsWith(folderPath)); } +export function getMarkdownFilesWithTag(tag: string): TFile[] { + const hasTags = ( + fileCache: CachedMetadata + ): fileCache is CachedMetadata & { tags: TagCache[] } => + fileCache.tags !== undefined && Array.isArray(fileCache.tags); + + return app.vault.getMarkdownFiles().filter((f) => { + const fileCache = app.metadataCache.getFileCache(f); + + if (!fileCache || !hasTags(fileCache)) return false; + + return fileCache.tags.find((item) => item.tag === tag); + }); +} From eff0d1a4ce788ef6e2159a893f4d0e2f009f87ea Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Wed, 5 Apr 2023 15:52:17 +0200 Subject: [PATCH 3/3] docs: rewrite Capture docs & explain Capture Anywhere and Captuer with Tag --- docs/docs/Choices/CaptureChoice.md | 52 ++++++++++++++++++++++++------ 1 file changed, 42 insertions(+), 10 deletions(-) diff --git a/docs/docs/Choices/CaptureChoice.md b/docs/docs/Choices/CaptureChoice.md index 8a0c344..0d7fc7f 100644 --- a/docs/docs/Choices/CaptureChoice.md +++ b/docs/docs/Choices/CaptureChoice.md @@ -2,6 +2,10 @@ title: Capture --- +![image](https://user-images.githubusercontent.com/29108628/123451366-e025e280-d5dd-11eb-81b6-c21f3ad1823d.png) +![image](https://user-images.githubusercontent.com/29108628/123451469-e61bc380-d5dd-11eb-80d1-7667427656f3.png) + +## Capture To _Capture To_ is the name of the file you are capturing to. You can choose to either enable _Capture to active file_, or you can enter a file name in the _File Name_ input field. @@ -9,19 +13,37 @@ This field also supports the [format syntax](/FormatSyntax.md), which allows you I have one for my daily journal with the name `bins/daily/{{DATE:gggg-MM-DD - ddd MMM D}}.md`. This automatically finds the file for the day, and whatever I enter will be captured to it. +### Capturing to folders +You can also type a **folder name** into the _Capture To_ field, and QuickAdd will ask you which file in the folder you'd like to capture to. +This also supports the [format syntax](/FormatSyntax.md). You can even write a filename in the suggester that opens, and it will create the file for you - assuming you have the _Create file if it doesn't exist_ setting enabled. + +For example, you might have a folder called `CRM/people`. In this folder, you have a note for the people in your life. You can type `CRM/people` in the _Capture To_ field, and QuickAdd will ask you which file to capture to. You can then type `John Doe` in the suggester, and QuickAdd will create a file called `John Doe.md` in the `CRM/people` folder. + +You could also write nothing - or `/` - in the _Capture To_ field. This will open the suggester with all of your files in it, and you can select or type the name of the file you want to capture to. + +Capturing to a folder will show all files in that folder. This means that files in nested folders will also appear. + +### Capturing to tags +Similarly, you can type a **tag name** in the _Capture To_ field, and QuickAdd will ask you which file to capture to, assuming the file has the tag you specify. + +If you have a tag called `#people`, and you type `#people` in the _Capture To_ field, QuickAdd will ask you which file to capture to, assuming the file has the `#people` tag. + + +## Capture Options - _Create file if it doesn't exist_ will do as the name implies - you can also create the file from a template, if you specify the template (the input box will appear below the setting). -- _Prepend_ will put whatever you enter at the bottom of the file. -- _Task_ will format it as a task. +- _Task_ will format your captured text as a task. +- _Write to bottom of file_ will put whatever you enter at the bottom of the file. - _Append link_ will append a link to the file you have open in the file you're capturing to. -- _Insert after_ will allow you to insert the text after some line with the specified text. I use this in my journal capture, where I insert after the line `## What did I do today?`. -_Capture format_ lets you specify the exact format that you want what you're capturing to be inserted as. You can do practically anything here. Think of it as a mini template. -See the format syntax further down on this page for inspiration. -In my journal capture, I have it set to `- {{DATE:HH:mm}} {{VALUE}}`. This inserts a bullet point with the time in hour:minute format, followed by whatever I entered in the prompt. -![image](https://user-images.githubusercontent.com/29108628/123451366-e025e280-d5dd-11eb-81b6-c21f3ad1823d.png) -![image](https://user-images.githubusercontent.com/29108628/123451469-e61bc380-d5dd-11eb-80d1-7667427656f3.png) +## Insert after +Insert After will allow you to insert the text after some line with the specified text. + +With Insert After, you can also enable `Insert at end of section` and `Consider subsections`. +You can see an explanation of these below. -## Consider subsections +I use this in my journal capture, where I insert after the line `## What did I do today?`. + +### Consider subsections Behavior with `Insert after` & `Insert at end`, but not `Consider subsections` enabled: ```markdown ## Heading # Insert after here @@ -50,4 +72,14 @@ Content # captures to after this, as it's considered part of the "## Heading" se ## Another heading Content -``` \ No newline at end of file +``` + +## Capture Format +Capture format lets you specify the exact format that you want what you're capturing to be inserted as. +You can do practically anything here. Think of it as a mini template. + +If you do not enable this, QuickAdd will default to `{{VALUE}}`, which will just insert whatever you enter in the prompt that appears when activating the Capture. + +You can use [format syntax](/FormatSyntax.md) here, which allows you to use dynamic values in your capture format. + +In my journal capture, I have it set to `- {{DATE:HH:mm}} {{VALUE}}`. This inserts a bullet point with the time in hour:minute format, followed by whatever I entered in the prompt. \ No newline at end of file