Skip to content

Commit

Permalink
Merge pull request #442 from chhoumann/capture-tag-anywhere
Browse files Browse the repository at this point in the history
Capture with Tag & Capture Anywhere
  • Loading branch information
chhoumann authored Apr 5, 2023
2 parents 3201548 + eff0d1a commit 75c76ee
Show file tree
Hide file tree
Showing 3 changed files with 210 additions and 35 deletions.
52 changes: 42 additions & 10 deletions docs/docs/Choices/CaptureChoice.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,48 @@
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.

This field also supports the [format syntax](/FormatSyntax.md), which allows you to use dynamic file names.
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
Expand Down Expand Up @@ -50,4 +72,14 @@ Content # captures to after this, as it's considered part of the "## Heading" se
## Another heading
Content

```
```

## 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.
137 changes: 119 additions & 18 deletions src/engine/CaptureChoiceEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ import {
openFile,
replaceTemplaterTemplatesInCreatedFile,
templaterParseTemplate,
isFolder,
getMarkdownFilesInFolder,
getMarkdownFilesWithTag,
} from "../utilityObsidian";
import { VALUE_SYNTAX } from "../constants";
import type QuickAdd from "../main";
Expand All @@ -16,6 +19,8 @@ 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";
import GenericSuggester from "src/gui/GenericSuggester/genericSuggester";

export class CaptureChoiceEngine extends QuickAddChoiceEngine {
choice: ICaptureChoice;
Expand All @@ -40,7 +45,9 @@ export class CaptureChoiceEngine extends QuickAddChoiceEngine {

async run(): Promise<void> {
try {
const filePath = await this.getFormattedPathToCaptureTo();
const filePath = await this.getFormattedPathToCaptureTo(
this.choice.captureToActiveFile
);
const content = this.getCaptureContent();

let getFileAndAddContentFn: typeof this.onFileExists;
Expand All @@ -63,14 +70,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);
}
Expand All @@ -80,7 +89,7 @@ export class CaptureChoiceEngine extends QuickAddChoiceEngine {
file,
""
);

appendToCurrentLine(markdownLink, this.app);
}

Expand Down Expand Up @@ -108,8 +117,20 @@ export class CaptureChoiceEngine extends QuickAddChoiceEngine {
return content;
}

private async getFormattedPathToCaptureTo(): Promise<string> {
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<string>} 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<string> {
if (shouldCaptureToActiveFile) {
const activeFile = this.app.workspace.getActiveFile();
invariant(
activeFile,
Expand All @@ -120,21 +141,97 @@ 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);
const shouldCaptureWithTag = formattedCaptureTo.startsWith("#");

if (shouldCaptureToFolder) {
return this.selectFileInFolder(folderPath, captureAnywhereInVault);
}

if (shouldCaptureWithTag) {
const tag = formattedCaptureTo.replace(/\.md$/, "");
return this.selectFileWithTag(tag);
}

return formattedCaptureTo;
}

private async selectFileInFolder(
folderPath: string,
captureAnywhereInVault: boolean
): Promise<string> {
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 selectFileWithTag(tag: string): Promise<string> {
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
): 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");

Expand Down Expand Up @@ -178,7 +275,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) {
Expand Down
56 changes: 49 additions & 7 deletions src/utilityObsidian.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -23,9 +29,14 @@ export async function replaceTemplaterTemplatesInCreatedFile(

if (
templater &&
(force || !(templater.settings as Record<string, unknown>)["trigger_on_file_creation"])
(force ||
!(templater.settings as Record<string, unknown>)[
"trigger_on_file_creation"
])
) {
const impl = (templater?.templater as { overwrite_file_commands?: (file: TFile) => Promise<void>; });
const impl = templater?.templater as {
overwrite_file_commands?: (file: TFile) => Promise<void>;
};
if (impl?.overwrite_file_commands) {
await impl.overwrite_file_commands(file);
}
Expand All @@ -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<string>}).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<string>;
}
).parse_template({ target_file: targetFile, run_mode: 4 }, templateContent);
}

export function getNaturalLanguageDates(app: App) {
Expand Down Expand Up @@ -225,3 +240,30 @@ 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));
}

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);
});
}

1 comment on commit 75c76ee

@vercel
Copy link

@vercel vercel bot commented on 75c76ee Apr 5, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

quickadd – ./

quickadd.obsidian.guide
quickadd-git-master-chrisbbh.vercel.app
quickadd-chrisbbh.vercel.app

Please sign in to comment.