Regex Search And/Or Replace in Current File (Extended)

Now, first of all, this functionality is currently missing and there are also workarounds as suggested in this thread, although some plugins do the replace but not the search.

In the meantime, I found another attempt to find a remedy.

I myself posted at least a couple of solutions using Templater for either search or replace, but it was either a solution for querying the full vault or some niche case for replacing in the current file.

Secondly, people who don’t know what regex is may not find it useful, although the script works for normal search and replace, which the program can do internally anyway (CNTRL+H).

  • Well, it can, but currently you cannot switch case on it. Bummer. There regex search and replace is purposefully case sensitive.*

I always hesitate before wanting to post scripts here because people who know regex can probably cook a simple script like this up on their own, while people who don’t know (of) regex, will not even make use of this, but anyway… I had some time today…
People with no regex knowledge or no wish to learn it can still make use of the third script: the benefit of querying like this is that you can take in all results at once.

I am going to put this up for the benefit of anyone who wanted especially a regex search option within a current file. Workarounds exist even for that, but I think many people are shying away from typing in the search modal the filename using the file: search operator, including myself.

Still, for the search part, we’re going to use inline queries and the same syntax, but the script takes the filename of the open file automatically.

Before I share the script…what it does is simple enough: you can search or replace strings, and on both ends regex patterns are accepted (the main idea behind writing this, in any case).

When searching only, the script expects a valid YAML frontmatter, because the insertion of the inline query happens below the YAML (this is convenient for more than one reason). I expect that since the introduction of Properties last August (or so), most everyone has a YAML frontmatter.

When the search and replace happens, the script updates the date modified field (it checks for YAML here, because I don’t want to insert this date modified property in Templater files, for instance). If this is unwanted, the user can delete these parts. I will offer different scenarios, so people can decide on what they want to use.

The full script:

<%*
//Switch to Live preview
const view = app.workspace.activeLeaf.getViewState()
view.state.mode = 'source'
view.state.source = false
app.workspace.activeLeaf.setViewState(view)

// Get the current file
const currentFile = app.workspace.getActiveFile();
if (!currentFile) return;

const fileContent = await app.vault.read(currentFile);
const separator = '---';
const firstSeparatorPos = fileContent.indexOf(separator);
const secondSeparatorPos = fileContent.indexOf(separator, firstSeparatorPos + 1);

// Ask user whether to search or replace
const userChoice = await tp.system.suggester(
    ["Search", "Replace"],
    ["Search", "Replace"],
    false,
    "Choose an option:"
);
if (!userChoice) return;

// Ask user for a search pattern – regex pattern is accepted
const userPattern = await tp.system.prompt("Enter search pattern:");
if (!userPattern) return;

if (userChoice.toLowerCase() === "replace") {
    // Ask user for a replacement pattern
    const userReplace = await tp.system.prompt("Enter replacement pattern (or leave empty for zero replacement):");
    // if (!userReplace) return;
	if (userReplace === undefined) return;
    // Now pressing enter in empty box to select nothing as replacement works

    // Perform search and replace
    const updatedContent = fileContent.replace(new RegExp(userPattern, 'gm'), userReplace);

    // Write back changes
    await app.vault.modify(currentFile, updatedContent);

    // Check for the existence of YAML and update date modified
    const yamlRegex = /^---\s*\n[\s\S]*?\n---/;
    const hasYaml = yamlRegex.test(fileContent);

    if (hasYaml) {
        // Define an async function to update date modified
        (async () => {
            await new Promise(resolve => setTimeout(resolve, 2200));
            const modDateTime = await tp.date.now("YYYY-MM-DD");
            await app.fileManager.processFrontMatter(tp.config.target_file, frontmatter => {
                frontmatter['date modified'] = modDateTime;
            });
        })();
    }
} else {
    // Prepare dynamic content with the user's regex pattern
    const dynamicContent = `
\%\%
\`\`\`query
file: /^${currentFile.basename}.md/ /${userPattern}/
\`\`\`
\%\%`;

    // Ensure an extra empty line after the YAML separator for search
    const updatedContent = fileContent.slice(0, secondSeparatorPos + separator.length) +
        `\n${dynamicContent}` +
        fileContent.slice(secondSeparatorPos + separator.length);

    // Insert inline query
    await app.vault.modify(currentFile, updatedContent);

    // Set the cursor to the beginning of the query
    this.app.workspace.activeLeaf.view.editor.setCursor({ line: 1, ch: 0 });
}
_%>

If your date modified YAML key is modifed, you simply remove date from the script, or if you have other date format, modify the relevant part (I myself have switched to YYYY-MM-DDTHH:mm now). Otherwise if you don’t want to update YAML, you can remove the whole bit and use:

<%*
//Switch to Live preview
const view = app.workspace.activeLeaf.getViewState()
view.state.mode = 'source'
view.state.source = false
app.workspace.activeLeaf.setViewState(view)

// Get the current file
const currentFile = app.workspace.getActiveFile();
if (!currentFile) return;

const fileContent = await app.vault.read(currentFile);
const separator = '---';
const firstSeparatorPos = fileContent.indexOf(separator);
const secondSeparatorPos = fileContent.indexOf(separator, firstSeparatorPos + 1);

// Ask user whether to search or replace
const userChoice = await tp.system.suggester(
    ["Search", "Replace"],
    ["Search", "Replace"],
    false,
    "Choose an option:"
);
if (!userChoice) return;

// Ask user for a search pattern – regex pattern is accepted
const userPattern = await tp.system.prompt("Enter search pattern:");
if (!userPattern) return;

if (userChoice.toLowerCase() === "replace") {
    // Ask user for a replacement pattern
    const userReplace = await tp.system.prompt("Enter replacement pattern (or leave empty for zero replacement):");
    // if (!userReplace) return;
	if (userReplace === undefined) return;
    // Now pressing enter in empty box to select nothing as replacement works

    // Perform search and replace
    const updatedContent = fileContent.replace(new RegExp(userPattern, 'gm'), userReplace);

    // Write back changes
    await app.vault.modify(currentFile, updatedContent);

} else {
    // Prepare dynamic content with the user's regex pattern
    const dynamicContent = `
\%\%
\`\`\`query
file: /^${currentFile.basename}.md/ /${userPattern}/
\`\`\`
\%\%`;

    // Ensure an extra empty line after the YAML separator for search
    const updatedContent = fileContent.slice(0, secondSeparatorPos + separator.length) +
        `\n${dynamicContent}` +
        fileContent.slice(secondSeparatorPos + separator.length);

    // Insert inline query
    await app.vault.modify(currentFile, updatedContent);

    // Set the cursor to the beginning of the query
    this.app.workspace.activeLeaf.view.editor.setCursor({ line: 1, ch: 0 });
}
_%>

For those who use a plugin for replacements and want to use only the regex query, use (slightly faster as there is no drop-down menu here):

<%*
//Switch to Live preview
const view = app.workspace.activeLeaf.getViewState()
view.state.mode = 'source'
view.state.source = false
app.workspace.activeLeaf.setViewState(view)

const currentFile = app.workspace.getActiveFile();
if (!currentFile) return;

// Ask user for a search pattern – regex pattern is accepted
const userPattern = await tp.system.prompt("Enter search pattern:");
if (!userPattern) return;

// Prepare dynamic content with the user's regex pattern
const dynamicContent = `
\%\%
\`\`\`query
file: /^${currentFile.basename}.md/ /${userPattern}/
\`\`\`
\%\%`;
// Here the backtick and semicolon were brought up one level to make sure there is only one empty line before Heading1, otherwise there were always two

// Insert dynamic content below YAML section
const fileContent = await app.vault.read(currentFile);
const separator = '---';
const firstSeparatorPos = fileContent.indexOf(separator);
const secondSeparatorPos = fileContent.indexOf(separator, firstSeparatorPos + 1);

if (secondSeparatorPos !== -1) {
  // Ensure an extra empty line after the YAML separator
  const updatedContent = fileContent.slice(0, secondSeparatorPos + separator.length) +
    `\n${dynamicContent}` +
    fileContent.slice(secondSeparatorPos + separator.length);

  await app.vault.modify(currentFile, updatedContent);

  // Set the cursor to the beginning of the query
  this.app.workspace.activeLeaf.view.editor.setCursor({line: 1, ch: 0});
}
_%>

For this to work, put the script in your Templater folder and register it by assigning a hotkey. On mobile, you can set an icon for it on the Mobile Toolbar.

If anyone says that query results are slow coming in, it is because the inline query searches all files in the vault alphabetically. It is a known thing. So if you are in a file whose filename starts with a w, search is slower than if it starts with a d, for instance.
It is good practice when starting up Obsidian to search for some random string (e.g. work) in order to cache in the contents of all files, especially if it’s a large vault.

EDIT.
The inline query results are only shown in Live Preview. One needs to switch to that before firing the script. Otherwise one can also include these lines to the front of the script (below <%*):

//Switch to Live preview
const view = app.workspace.activeLeaf.getViewState()
view.state.mode = 'source'
view.state.source = false
app.workspace.activeLeaf.setViewState(view)
  • I included these lines now above for good measure.

EDIT 2.
Fixed replacement box allowing zero string to be registered (zero/nothing on replacement side is used to delete stuff). Previously, script terminated without performing the replacement.

EDIT X. and X+1:
Added another link and a date-time format.

EDIT (last time?): *Added an important point about the built-in search and replace function being case insensitive. My script changes strings sensitively.

1 Like

The script can be extended with some built-in replacements (two of them are included within) and other functionality:

<%*
// Switch to Live Preview
const view = app.workspace.activeLeaf.getViewState()
view.state.mode = 'source'
view.state.source = false
app.workspace.activeLeaf.setViewState(view)
const updatedContent = fileContent.replace(new RegExp(userPattern, 'gm'), userReplace.replace(/\\n/g, '\n'));

// Get the current file
const currentFile = app.workspace.getActiveFile();
if (!currentFile) return;

// Read the content of the current file
const fileContent = await app.vault.read(currentFile);
const separator = '---';
const firstSeparatorPos = fileContent.indexOf(separator);
const secondSeparatorPos = fileContent.indexOf(separator, firstSeparatorPos + 1);

// Define regex pattern for removing footnote references
const removeFootnoteRefsPattern = /\[\^[0-9]{1,3}\]/gm;

// Function to apply cleanup rules to text
function applyRules(text) {
    const rules = [
        {
            from: /\s+$/gm,
            to: "",
        },
        {
            from: /(\r\n)+|\r+|\n+/gm,
            to: " ",
        },
        {
            from: /([a-zžáàäæãééíóöőüűčñßðđŋħjĸłß])(-\s{1})([a-zžáàäæãééíóöőüűčñßðđŋħjĸłß])/gm,
            to: "$1$3",
        }
    ];
    for (const rule of rules) {
        text = text.replace(rule.from, rule.to);
    }
    return text;
}

// Define Obsidian current leaf editor and current selection
const editor = app.workspace.activeLeaf.view.editor;
const selection = editor.getSelection();

// Function to perform replacements on selected text
const performReplacementsOnSelection = async (pattern, replacement) => {
    // Get the selected text from the editor
    const selectedText = editor.getSelection();
    if (pattern.test(selectedText)) {
        // Include 'g' and 'm' flags for global and multiline matching
        const regex = new RegExp(pattern.source, 'gm');
        // Perform replacements on the selected text
        const updatedText = selectedText.replace(regex, replacement.replace(/\\n/g, '\n'));
        // Replace the selected text in the editor with the modified text
        await editor.replaceSelection(updatedText);
    }
};

// Ask user whether to search, replace, or use built-in replacements
const userChoice = await tp.system.suggester(
    ["Search Current File", "Replace in Current File", "Built-in Replacements to Be Performed in Current File", "DataView Full-vault Query for Content by Dynamic Search Term"],
    ["Search Current File", "Replace in Current File", "Built-in Replacements to Be Performed in Current File", "DataView Full-vault Query for Content by Dynamic Search Term"],
    false,
    "Choose an option:"
);
if (!userChoice) return;

if (userChoice === "Replace in Current File") {
    // Ask user for a search pattern – regex pattern is accepted
    const userPattern = await tp.system.prompt("Enter search pattern:");
    if (!userPattern) return;

    // Ask user for a replacement pattern
    const userReplace = await tp.system.prompt("Enter replacement pattern (or leave empty for zero replacement):");
    if (userReplace === undefined) return;

    // Perform search and replace on selected text or entire file
    if (selection.length > 0) {
        await performReplacementsOnSelection(new RegExp(userPattern, 'gm'), userReplace);
    } else {
        const updatedContent = fileContent.replace(new RegExp(userPattern, 'gm'), userReplace);
        await app.vault.modify(currentFile, updatedContent);
    }

    // Update date modified if YAML front matter exists
    const yamlRegex = /^---\s*\n[\s\S]*?\n---/;
    const hasYaml = yamlRegex.test(fileContent);
    if (hasYaml) {
        const modDateTime = await tp.date.now("YYYY-MM-DDTHH:mm");
        await app.fileManager.processFrontMatter(tp.config.target_file, frontmatter => {
            frontmatter['date modified'] = modDateTime;
        });
    }
} else if (userChoice === "Built-in Replacements to Be Performed in Current File") {
    // Ask user to select a replacement to be done on the current file
    const builtinReplacementChoice = await tp.system.suggester(
        ["Remove all footnote references (globally or on selection)", "Clean up pasted text (globally or on selection)", "Remove InlineQueries"],
        ["Remove all footnote references (globally or on selection)", "Clean up pasted text (globally or on selection)", "Remove InlineQueries"],
        false,
        "Select which to perform on current file (globally or on selected text):"
    );

    if (builtinReplacementChoice === "Remove InlineQueries") {
        // Include the separate Templater template file
        await tp.file.include("[[Remove InLineQueries Template]]");
    } else if (builtinReplacementChoice === "Remove all footnote references (globally or on selection)") {
        // Perform regex replacement for removing all footnote references
        if (selection.length > 0) {
            await performReplacementsOnSelection(removeFootnoteRefsPattern, '');
        } else {
            const updatedContent = fileContent.replace(removeFootnoteRefsPattern, '');
            await app.vault.modify(currentFile, updatedContent);
        }
    } else if (builtinReplacementChoice === "Clean up pasted text (globally or on selection)") {
        // Perform cleanup of pasted text rules
        if (selection.length > 0) {
            const modifiedText = applyRules(selection).replace(/\\n/g, '\n');
            editor.replaceSelection(modifiedText);
        } else {
            let updatedContent = fileContent;
            // Applying cleanup rules globally
            updatedContent = applyRules(updatedContent);
            await app.vault.modify(currentFile, updatedContent);
        }
    }
} else if (userChoice === "DataView Full-vault Query for Content by Dynamic Search Term") {
    // Include the separate Templater template file
    await tp.file.include("[[Query Paragraphs Based on Searchterm with User Interaction Template]]");
} else if (userChoice === "Search Current File") {
    // Ask user for a search pattern – regex pattern is accepted
    const userPattern = await tp.system.prompt("Enter search pattern:");
    if (!userPattern) return;

    // Prepare dynamic content with the user's regex pattern
    const dynamicContent = `
\%\%
\`\`\`query
file: /^${currentFile.basename}.md/ /${userPattern}/
\`\`\`
\%\%`;

    // Ensure an extra empty line after the YAML separator for search
    const updatedContent = fileContent.slice(0, secondSeparatorPos + separator.length) +
        `\n${dynamicContent}` +
        fileContent.slice(secondSeparatorPos + separator.length);

    // Write back changes
    await app.vault.modify(currentFile, updatedContent);

    // Set the cursor to the beginning of the query
    this.app.workspace.activeLeaf.view.editor.setCursor({ line: 1, ch: 0 });
}
_%>

From the built-in replacements, one can remove footnote references (handy when one’s pasting in Wikipedia passages) and can clean up pasted text talked about here.
Script supports full note replacements but more importantly, if you have selected text before launching the script, the replacements will only be done on the selected text. Thus we can make sure we don’t remove our own footnote references or we don’t make the mistake of merging our YAML with the rest of the note. You can try what it does and CTRL+Z to undo changes.

The inline query we pasted (or it can be a DV query anywhere in the file) is easily removed with the following (make sure you save this as Remove InLineQueries Template.md):

<%*
(async () => {
    const currentFile = app.workspace.getActiveFile();
    if (!currentFile) return;
    let updatedContent;

    await this.app.workspace.activeLeaf.view.editor.setCursor({line: 1, ch: 0});
    const pattern = /^(?:%%\n)?```(?:query|dataview)[\s\S]*?```\n?(?:%%\n)?(?:\s*\n)*\s*/gm;

    const fileContent = await app.vault.read(currentFile);
    
    if (pattern.test(fileContent)) {
        updatedContent = fileContent.replace(pattern, "");
        await app.vault.modify(currentFile, updatedContent);
    }
})();
-%>

I also referenced a full-vault/unique folder Dataview search I created here. This is also not included in the main script. You need to save this script as Query Paragraphs Based on Searchterm with User Interaction Template.md again in your Templater folder.

  • Again, we seamlessly call/switch to that script with await tp.file.include("[[Query Paragraphs Based on Searchterm with User Interaction Template]]"); as if it was part of the above script.
  • You can save both scripts with more descriptive filenames and then use them in above commands to make sure they get called.

All this is (just tinkering but) useful when one has many Templater scripts and can no longer remember the hotkeys or don’t want to put dozens of icons here and there on the workspace or mobile toolbar.

The above can be further extended rather easily so one can add more items one can call with the command I mentioned.

Make sure you launch the script with one markdown file currently open and active, otherwise Templater won’t budge.

EDIT. I added a missing script for removing inline queries.

EDIT 2.
28-02-2024: Fixed a bug where replacement side was not accepting \n for new line character.

By the way, the Date-Time format can be changed to your own preferred format.
The use of the word ‘globally’ in the pop-up denotes whole file of course, not full-vault, as we’re dealing with current file when doing replacements.

1 Like