← Technical

Building an Obsidian Plugin

I’m a heavy user of Obsidian, which comes with an extensive plugin system. I recently found myself needing to write a plugin, so let’s discuss how that worked.

I’m a fairly heavy user of tags in Obsidian; almost every note I write gets a tag, which I pull from a canonical list of about 150 tags. Unfortunately, working with tags in Obsidian is a fairly bare-bones experience, though there is a fantastic plugin called Tag Wrangler that provides various utilities to work with tags.

What it doesn’t provide, however, is an upgrade to the tag browser. To view all notes with a particular tag, you either have to use the global search with a tag: prefix (which, annoyingly, doesn’t autocomplete) or open the tag pane and scan a massive list of tags (which, annoyingly, always defaults to sorting by count instead of alphabetical). To search for tags and tagged notes, I wanted a fuzzy-find modal similar to the note quick-open modal, and so Tag Search was born.

Luckily, getting started with Obsidian plugins is quite easy. There’s an extensive TypeScript API to interact with, as well as an official sample plugin provided as a template that shows off many of the features you might want to use. I also heavily referenced the Obsidian Plugin Developer Docs, which I’ve been led to understand are the officially-unofficial guide to developing Obsidian plugins.

To implement fuzzy-finding, I was looking into fzf for JavaScript, but luckily, the Obsidian API already exposes the fuzzy-find modal as a first-class construct. I just had to extend it:

class TagSearchModal extends FuzzySuggestModal<string> {
    search: Search;

    constructor(app: App, search: Search) {
        super(app);
        this.search = search;
    }

    onOpen(): void {
        // See below!
    }

    onClose(): void {
        // See below!
    }

    getItems(): string[] {
        // See below!
    }

    getItemText(item: string): string {
        // See below!
    }

    onChooseItem(item: string, evt: MouseEvent | KeyboardEvent): void {
        // See below!
    }

    private maybeChooseFirstSuggestion(evt: KeyboardEvent) {
        // See below!
    }
}

One interesting thing here is that FuzzySuggestModal is generic over the items being searched for. In this case, I’m searching for tags, which are just simple strings.

The other interesting thing here is the reference to Search. I want to read and write to the global search panel to append the selected tag, but the search panel isn’t exposed by the Obsidian API; it’s actually another plugin, albeit a core plugin maintained by the Obsidian team. As a result, I have to be a bit sneaky to get a reference to it, as we’ll see below. I didn’t want to couple that to my business logic, so instead I pass it in as an interface that exposes the function I need:

interface Search {
    openGlobalSearch(_: string): void;
    getGlobalSearchQuery(): string;
}

The logic for displaying the items is fairly straightforward:

getItems(): string[] {
    const files = app.vault.getMarkdownFiles();
    const itemSet = new Set<string>();
    for (const file of files) {
        const cache = app.metadataCache.getCache(file.path);
        if (cache === null) {
            continue;
        }
        getAllTags(cache)?.forEach((tag) => {
            itemSet.add(tag);
        });
    }
    return Array.from(itemSet);
}

getItemText(item: string): string {
    return item;
}

getItems is responsible for loading all the items I want to fuzzy-find over and getItemText is responsible for providing the text I display in the results. In this case, I’m fuzzy-finding strings, so getItemText can just return the tag text directly.

getItems is more interesting. First, I load all the Markdown (non-settings) files in the vault (Obsidian’s term for a directory of notes). I then load the metadata for each file, which includes the tags, then use the built-in getAllTags to retrieve the tags from the metadata. Finally, I throw all the tags into a Set to deduplicate them, before converting that back to an Array to respect the return type.

Once the user has found the tag they’re looking for, they can click or press Enter to select it. However, there’s some extra logic here:

onChooseItem handles that logic:

onChooseItem(item: string, evt: MouseEvent | KeyboardEvent): void {
    const toggle = evt.ctrlKey || evt.metaKey;
    const negate = evt.shiftKey;

    const defaultTagSearchString = `tag:${item}`;
    const negatedTagSearchString = `-tag:${item}`;
    const tagSearchString = negate
        ? negatedTagSearchString
        : defaultTagSearchString;

    if (toggle) {
        let query = this.search.getGlobalSearchQuery();
        let needsNewTagSearchString = false;

        if (negate && !query.includes(negatedTagSearchString)) {
            needsNewTagSearchString = true;
        }
        query = query.replaceAll(negatedTagSearchString, "");

        if (!negate && !query.includes(defaultTagSearchString)) {
            needsNewTagSearchString = true;
        }
        query = query.replaceAll(defaultTagSearchString, "");

        if (needsNewTagSearchString) {
            this.search.openGlobalSearch(
                query.concat(query.length === 0 ? "" : " ", tagSearchString)
            );
        } else {
            this.search.openGlobalSearch(query);
        }
    } else {
        this.search.openGlobalSearch(tagSearchString);
    }
}

Luckily, the FuzzySuggestModal provides evt, representing the mouse or keyboard event, which lets me check if Shift, Command, or Control is pressed.

The important part here is calling openGlobalSearch, passing the tag with a tag: prefix (or -tag: in the negated case), to display all notes with that tag. That’s basically all I do in the non-toggle case.

The toggle case is a bit more complicated. I have to keep track of needsNewTagSearchString so that I can append the tag if it’s not already present. An additional complication is that the non-negated search string (tag:...) is a substring of the negated version (-tag:...), so I can’t check them independently.

Instead, what I do first is check if there’s any instances of the negated string, so I can set needsNewTagSearchString for the negated case. I can then remove any instances of the negated search string, since all other cases should remove it. Then, with the negated search string removed, I can check for instances of the regular search string to set needsNewTagSearchString for the default case, before removing any instances of the regular search string. Finally, I can reopen the query, appending the new search string if necessary.

That all works fine for clicking, but onChooseItem will only be called if the user presses Enter with no modifiers, which isn’t the behavior I want. To get around that, I hook into the "keydown" event on the input element provided by the modal’s parent class:

onOpen(): void {
    super.onOpen();
    this.inputEl.addEventListener("keydown", (ev: KeyboardEvent) => {
        this.maybeChooseFirstSuggestion(ev);
    });
}

onClose(): void {
    super.onClose();
    this.inputEl.removeEventListener("keydown", (ev: KeyboardEvent) => {});
}

Notably, I’m careful to remove the event listener again when the fuzzy suggest modal is closed.

The event listener calls into a new helper:

private maybeChooseFirstSuggestion(evt: KeyboardEvent) {
    const toggle = evt.ctrlKey || evt.metaKey;
    const negate = evt.shiftKey;
    // "Enter"-only case is handled by FuzzySuggestModal already
    if (evt.key === "Enter" && (toggle || negate)) {
        const choice =
            this.resultContainerEl
                .getElementsByClassName("is-selected")
                .item(0)?.textContent ?? null;
        if (choice != null) {
            this.close();
            this.onChooseItem(choice, evt);
        }
    }
}

Now, because the Enter-only case is already handled by onChooseItem and I don’t want double-selection, I only add extra logic if the user had actually pressed a modifier key as well.

The complicated part here is that I don’t want to select the first suggestion - I want the suggestion the user actually has selected. Unfortunately, as far as I can tell, there’s no way to do this with the API provided by Obsidian. Instead, I take the resultContainerEl provided by the modal parent class, which holds the entire selection page, then find the element with the is-selected CSS class, which is only applied to the selected item. I figured out that last part with the Obsidian developer tools, which can be opened from Obsidian with Cmd-Opt-I.

If there’s actually a suggestion, I call onChooseItem manually, making sure to also close the modal itself, which is handled for me in the normal case. Now we get all the same behavior for clicking and pressing Enter!

Now that I have a fuzzy-find modal that can open search, I need some way to open the modal, and I still need to pass a Search reference to the modal as well. That’s all done from our core Plugin:

export default class TagSearchPlugin extends Plugin {
    async onload() {
        this.addCommand({
            id: "open-tag-search",
            name: "Open tag search",
            callback: () => {
                /* eslint-disable @typescript-eslint/no-explicit-any */
                const searchPlugin = (
                    this.app as any
                ).internalPlugins.getPluginById("global-search");
                /* eslint-enable @typescript-eslint/no-explicit-any */
                const search = searchPlugin && searchPlugin.instance;

                if (searchPlugin && searchPlugin.instance) {
                    new TagSearchModal(this.app, search).open();
                } else {
                    new Notice("Please enable the search core plugin!");
                }
            }, 
        });
    }
}

I only override onload, which lets me set up the plugin when it’s loaded. For now, I’ve only added it as a command (via addCommand), accessible through the Cmd-P command modal or a user-defined hotkey. In particular, when the open-tag-search command is run, I find the searchPlugin by explicitly providing its ID, then unwrap it and pass it to a new instance of my TagSearchModal, which I immediately open.

If the global search core plugin isn’t enabled, I just show a basic Notice provided by Obsidian. The eslint-disable line is there to avoid getting yelled at by the linter for casting to any, which I need to do to get this.app to typecheck, since internalPlugins isn’t publicly exposed on the App type in the API.

And that’s… about it! If I install the plugin locally, it works!

Obsidian plugin in action

I made a pull request to add Tag Search to the official list of community plugins, though if you’re curious to try it out now, you can install it immediately with BRAT - just point it at the rwblickhan/obsidian-tag-search repo!