Quick Switcher: Allow searching for `title` property, similar to `aliases`

Use case or problem

The Quick Switcher core plugins allows to search for notes by their aliases frontmatter property, but not by title.

Proposed solution

The Quick Switcher should also show notes with a matching title property, just like it works with aliases.

This doesn’t happen primarily because the “title” property is not Obsidian Native (like aliases, tags, cssclasses), it’s something that some users add.

I think you can post your idea as comment to this FR, which should happen first.

You can bulk add title property key values to content with this Codescript Toolkit script:

import { App, Modal, Setting, TFolder, TFile, Notice } from "obsidian";

// Version 2

/**
 * =========================
 * CONFIG
 * =========================
 */
const CONFIG = {
    emptyLineBeforeHeading: false,
    emptyLineBeforeContent: false,
    skipIfAnyH1Exists: true,
    batchSize: 100,
    excludedFolders: [
        // Plain strings — exact match or direct parent
        ".trash",
        // Match folder name anywhere in a nested path (case-insensitive):
        /(^|\/)assets(\/|$)/i,
        /(^|\/)attachments(\/|$)/i,
        /(^|\/)templates(\/|$)/i,
    ] as Array<string | RegExp>
};

/**
 * Utility: Get all folders recursively
 */
function getAllFolders(app: App): TFolder[] {
    const folders: TFolder[] = [];
    const traverse = (folder: TFolder) => {
        folders.push(folder);
        folder.children.forEach(child => {
            if (child instanceof TFolder) traverse(child);
        });
    };
    traverse(app.vault.getRoot());
    return folders;
}

/**
 * Utility: Check if a folder path is excluded
 */
function isExcluded(folderPath: string): boolean {
    return CONFIG.excludedFolders.some(pattern => {
        if (pattern instanceof RegExp) {
            return pattern.test(folderPath);
        }
        // Plain string: exact match or direct child
        return folderPath === pattern || folderPath.startsWith(pattern + "/");
    });
}

/**
 * Utility: Get markdown files from selected folders (deduped, exclusions respected)
 */
function getFilesFromFolders(folders: TFolder[]): TFile[] {
    const seen = new Set<string>();
    const files: TFile[] = [];

    const traverse = (folder: TFolder) => {
        if (isExcluded(folder.path)) return;
        folder.children.forEach(child => {
            if (child instanceof TFolder) {
                traverse(child);
            } else if (child instanceof TFile && child.extension === "md") {
                if (!seen.has(child.path)) {
                    seen.add(child.path);
                    files.push(child);
                }
            }
        });
    };

    folders.forEach(traverse);
    return files;
}

/**
 * Extract title from YAML frontmatter
 */
function extractTitleFromYAML(content: string): { title: string | null, yamlEndIndex: number } {
    if (!content.startsWith("---")) return { title: null, yamlEndIndex: -1 };

    const end = content.indexOf("\n---", 3);
    if (end === -1) return { title: null, yamlEndIndex: -1 };

    const yamlBlock = content.substring(0, end + 4);
    const match = yamlBlock.match(/^title:\s*(.+)$/m);
    const title = match ? match[1].trim().replace(/^["']|["']$/g, "") : null;

    return { title, yamlEndIndex: end + 4 };
}

/**
 * Check if H1 exists immediately after YAML fence
 */
function hasH1AfterYaml(content: string, yamlEndIndex: number): boolean {
    const afterYaml = content.substring(yamlEndIndex).trimStart();
    return afterYaml.startsWith("# ");
}

/**
 * Check if ANY H1 exists anywhere in the file body (after YAML)
 */
function hasAnyH1InBody(content: string, yamlEndIndex: number): boolean {
    const body = content.substring(yamlEndIndex);
    return /^# .+/m.test(body);
}

/**
 * Check if a H1 with exactly the title text exists anywhere in the file body
 */
function hasMatchingH1InBody(content: string, yamlEndIndex: number, title: string): boolean {
    const body = content.substring(yamlEndIndex);
    const escapedTitle = title.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
    return new RegExp(`^# ${escapedTitle}\\s*$`, "m").test(body);
}

/**
 * Insert H1 after YAML with CONFIG spacing
 */
function insertH1(content: string, yamlEndIndex: number, title: string): string {
    const before = content.substring(0, yamlEndIndex);
    const after = content.substring(yamlEndIndex);

    const beforeHeadingSpacing = CONFIG.emptyLineBeforeHeading ? "\n\n" : "\n";
    const afterHeadingSpacing = CONFIG.emptyLineBeforeContent ? "\n\n" : "\n";

    const afterTrimmed = after.replace(/^[\n\r]+/, "");

    return `${before}${beforeHeadingSpacing}# ${title}${afterHeadingSpacing}${afterTrimmed}`;
}

/**
 * Modal UI
 */
class FolderSelectModal extends Modal {
    selectedFolders: Set<string> = new Set();
    skipIfExists: boolean = true;
    onSubmit: (folders: TFolder[], skip: boolean) => void;

    constructor(app: App, onSubmit: (folders: TFolder[], skip: boolean) => void) {
        super(app);
        this.onSubmit = onSubmit;
    }

    onOpen() {
        const { contentEl } = this;
        contentEl.empty();
        contentEl.createEl("h2", { text: "Insert H1 from title property" });

        const allFolders = getAllFolders(this.app).filter(f => !isExcluded(f.path));
        const allSelected = allFolders.length > 0 && allFolders.every(f => this.selectedFolders.has(f.path));

        // --- Select All toggle ---
        new Setting(contentEl)
            .setName("Select all folders")
            .setHeading()
            .addToggle(toggle => {
                toggle.setValue(allSelected).onChange(value => {
                    if (value) allFolders.forEach(f => this.selectedFolders.add(f.path));
                    else this.selectedFolders.clear();
                    this.onOpen();
                });
            });

        contentEl.createEl("hr");

        // --- Individual folder toggles ---
        allFolders.forEach(folder => {
            new Setting(contentEl)
                .setName(folder.path || "/")
                .addToggle(toggle => {
                    toggle
                        .setValue(this.selectedFolders.has(folder.path))
                        .onChange(value => {
                            if (value) this.selectedFolders.add(folder.path);
                            else this.selectedFolders.delete(folder.path);
                        });
                });
        });

        contentEl.createEl("hr");

        // --- Skip if H1 already exists right after YAML ---
        new Setting(contentEl)
            .setName("Skip if H1 already exists after frontmatter")
            .addToggle(toggle => {
                toggle.setValue(this.skipIfExists).onChange(v => this.skipIfExists = v);
            });

        // --- Run button ---
        new Setting(contentEl)
            .addButton(btn =>
                btn.setButtonText("Run")
                    .setCta()
                    .onClick(() => {
                        const selected = allFolders.filter(f =>
                            this.selectedFolders.has(f.path)
                        );
                        if (selected.length === 0) {
                            new Notice("No folders selected.");
                            return;
                        }
                        this.close();
                        this.onSubmit(selected, this.skipIfExists);
                    })
            );
    }

    onClose() {
        this.contentEl.empty();
    }
}

/**
 * Main processing logic
 */
async function processFiles(app: App, folders: TFolder[], skipIfExists: boolean) {
    const files = getFilesFromFolders(folders);
    let updated = 0;
    let skipped = 0;
    let noTitle = 0;

    const progressNotice = new Notice(`Processing 0 / ${files.length}...`, 0);

    for (let i = 0; i < files.length; i++) {
        const file = files[i];

        // Yield to UI every batchSize files
        if (i > 0 && i % CONFIG.batchSize === 0) {
            progressNotice.setMessage(`Processing ${i} / ${files.length}...`);
            await sleep(10);
        }

        const content = await app.vault.read(file);
        const { title, yamlEndIndex } = extractTitleFromYAML(content);

        if (!title || yamlEndIndex === -1) {
            noTitle++;
            continue;
        }

        // Safety 1: skip if H1 immediately after YAML (modal toggle)
        if (skipIfExists && hasH1AfterYaml(content, yamlEndIndex)) {
            skipped++;
            continue;
        }

        // Safety 2: skip if ANY H1 exists anywhere in body (CONFIG flag)
        if (CONFIG.skipIfAnyH1Exists && hasAnyH1InBody(content, yamlEndIndex)) {
            skipped++;
            continue;
        }

        // Safety 3: skip if matching H1 exists anywhere in body (always-on)
        if (hasMatchingH1InBody(content, yamlEndIndex, title)) {
            skipped++;
            continue;
        }

        const newContent = insertH1(content, yamlEndIndex, title);
        if (newContent !== content) {
            await app.vault.modify(file, newContent);
            updated++;
        }
    }

    progressNotice.hide();
    new Notice(
        `Done — updated ${updated} file${updated !== 1 ? "s" : ""}, skipped ${skipped}, no title ${noTitle}`,
        8000
    );
}

/**
 * Entry point for CodeScript Toolkit
 */
export async function invoke(app: App): Promise<void> {
    new FolderSelectModal(app, (folders, skip) => {
        processFiles(app, folders, skip);
    }).open();
}

Save this script as HeadingAdderBasedonTitleProp.ts or whatever and place it in a folder you specify in the Codescript Toolkit plugin’s settings.

Then you can use the Quick Switcher++ plugin to get headings (any level) as results.


Script config:

    emptyLineBeforeHeading: false,
    emptyLineBeforeContent: false,

You can add true to either of these if you want empty lines after YAML fence and an empty line before your existing content.

    skipIfAnyH1Exists: true,

Means we skip processing any files with H1 in them, regardless if they equal title property value or not.

There is an interactive modal to pick all folders or individual folders but you can still add folders to excluded array in the syntax shown.

Test the script on not so important files or in a test vault.

This thread contains info how to set up the plugin to use these .ts files as commands:

In version 2, I added safety for handling thousands of files without potentially freezing Obsidian UI.