New Plugin: Anychar - Special characters on note title

Hi folks!

Since I started using Obsidian, I’ve struggled with the limitation on special characters in note titles, which comes from the same restrictions applied to filenames in operating systems.

Recently, I came across this forum topic where people were complaining about the same issue I’ve had for years. Following some of the suggestions there, I created a plugin to solve this pain point once and for all.

Presenting Anychar!
This plugin lets you use (almost) any of the most common special characters in note titles. It works by replacing those characters with visually similar ones as you type (or when you paste something into the title). It’s a very simple plugin, but it solves a chronic problem in Obsidian. For now, it’s available only via GitHub, and the installation process is explained there.

Looking forward, I plan to collect feedback and suggestions to keep improving the plugin, and I hope to make it available directly through the “Community Plugins” tab in the future.

3 Likes

Good idea. I had had a .ts script made with Codescript Toolkit, but didn’t spend time tracking down all cases, as in your:

    "/": "⧸", // Unicode: \uFF0F
    "\\": "⧵", // Unicode: \u29F5
    ":": "։", // Unicode: \u0589
    "|": "❘", // Unicode: \u2758
    "#": "#", // Unicode: \uFF03
    "[": "〚", // Unicode: \u301A
    "]": "〛", // Unicode: \u301B
    "^": "ˆ", // Unicode: \u02C6
    ".": "․", // Unicode: \u2024
    "?": "?", // Unicode: \uFF1F
    '"': "“", // Unicode: \uFF02

I really do not like too many plugins installed and enabled, and I have already 12 .ts patches…

Hello, I cannot install your plugin because BRAT indicates that manifest.json is missing from the repository. Obsidian itself also indicates the absence of a js file.

Can you share your version of the ts file, because my attempts to configure it have not worked.

Download first zip file from:

Unzip, put it in some folder and move that to your Obsidian plugins folder.
You can freely edit the main.js file to your liking with an AI bot.

The Codescript Toolkit method is more involved.

The funny thing is that I was able to configure everything through Codescript Toolkit, but the plugin itself does not work. I hope someone finds my typescript useful.

Before using, I strongly recommend making a backup copy of your storage!!!

export async function invoke() {
    console.log("=== SCRIPT RESTARTED ===");
    
    setupAnycharLogic();
    setupCustomRules();
}

function setupAnycharLogic() {
    const forbiddenMap: Record<string, string> = {
        "/": "⧸", "\\": "⧵", ":": "։", "|": "❘", "#": "#",
        "[": "〚", "]": "〛", "^": "ˆ", ".": "․", "?": "?", '"': "“"
    };

    document.addEventListener("keyup", (event) => {
        const target = event.target as HTMLElement;
       
        if (target && (target.classList.contains("inline-title") || target.getAttribute("contenteditable") === "true")) {
            let title = target.textContent ?? "";
            let sanitized = title;

            let hasForbidden = false;
            for (const bad in forbiddenMap) {
                if (title.includes(bad)) {
                    hasForbidden = true;
                    break;
                }
            }

            if (hasForbidden) {
                for (const [bad, good] of Object.entries(forbiddenMap)) {
                    sanitized = sanitized.split(bad).join(good);
                }

                target.textContent = sanitized;

                const range = document.createRange();
                const sel = window.getSelection();
                if (sel && target.childNodes.length > 0) {
                    range.setStart(target.childNodes[0], sanitized.length);
                    range.collapse(true);
                    sel.removeAllRanges();
                    sel.addRange(range);
                }
            }
        }
    }, true);
}

function setupCustomRules() {
    console.log("Monitoring of formatting rules");
}

invoke();

Using:

import { App, Notice, TFile } from 'obsidian';

// ===============================================
// Note Creation Guard with Character Sanitization
// ===============================================

export async function invoke(app: App): Promise<void> {
    let isActive = true;

    // Forbidden characters and their replacements (from obsidian-anychar)
    const replacementMap: Record<string, string> = {
		"/": "⧸", // Unicode: \uFF0F
		"\\": "⧵", // Unicode: \u29F5
		":": "։", // Unicode: \u0589
		"|": "❘", // Unicode: \u2758
		"#": "#", // Unicode: \uFF03
		"[": "〚", // Unicode: \u301A
		"]": "〛", // Unicode: \u301B
		"^": "ˆ", // Unicode: \u02C6
		// ".": "․", // Unicode: \u2024
        "?": "?",  // Unicode fullwidth question mark
        "\"": "“",  // Unicode fullwidth quotation mark
	};

    // Sanitize filename by replacing forbidden characters
    const sanitizeFilename = (filename: string): string => {
        let result = filename;
        for (const [badChar, goodChar] of Object.entries(replacementMap)) {
            if (result.includes(badChar)) {
                result = result.split(badChar).join(goodChar);
            }
        }
        return result;
    };

    // Store original create method
    const originalCreate = app.vault.create.bind(app.vault);

    // Override the create method
    app.vault.create = async function(path: string, data: string, options?: any): Promise<TFile> {
        if (!isActive) {
            return originalCreate(path, data, options);
        }

        // Extract filename from path
        const pathParts = path.split('/');
        const fileName = pathParts.pop() || '';
        
        // Only process .md files - ignore all other file types (CSS, JS, images, etc.)
        if (!fileName.endsWith('.md')) {
            return originalCreate(path, data, options);
        }
        
        // Skip processing if this appears to be a system/background operation:
        // 1. Files in .obsidian folder or other system folders
        // 2. Files with system-related names
        

		if (path.startsWith('.obsidian/') || 
			path.startsWith('.windows/') ||
			path.startsWith('.linux/') ||
			path.startsWith('.mobile/') ||
			path.startsWith('.obsidian-mobile/') ||
            path.includes('/.obsidian/') ||
            fileName.startsWith('.') ||
            path.includes('/snippets/') ||
            path.includes('/themes/')) {
            return originalCreate(path, data, options);
        }
        
        const fileNameWithoutExt = fileName.replace(/\.md$/, '');

        // Sanitize the filename
        const sanitizedName = sanitizeFilename(fileNameWithoutExt);
        
        // Check if sanitization changed the filename
        if (sanitizedName !== fileNameWithoutExt) {
            const sanitizedPath = [...pathParts, sanitizedName + '.md'].join('/');
            
            // Find which characters were replaced
            const replacedChars = Object.keys(replacementMap)
                .filter(char => fileNameWithoutExt.includes(char))
                .map(char => `'${char}' → '${replacementMap[char]}'`)
                .join(', ');
            
            new Notice(
                `ℹ️ Sanitized filename:\n"${fileName}" → "${sanitizedName}.md"\n` +
                `Replaced: ${replacedChars}`, 
                5000
            );
            
            // Use the sanitized path for creation
            return originalCreate(sanitizedPath, data, options);
        }

        // Problem 1: Check for malformed bracket filenames (after sanitization)
        // This catches edge cases where brackets might still cause issues
        if (sanitizedName.startsWith('[') && !sanitizedName.endsWith(']')) {
            new Notice(
                `❌ Blocked creation of malformed file: "${fileName}"\n` +
                `Likely from clicking a broken wikilink with extra bracket.`, 
                5000
            );
            throw new Error(`Blocked creation of malformed filename: ${fileName}`);
        }

        // Problem 2: Check for exact duplicate filenames in other folders
        const existingFiles = app.vault.getMarkdownFiles();
        const duplicateFiles = existingFiles.filter(file => {
            // Exact match: same basename, different path
            return file.basename === sanitizedName && file.path !== path;
        });

        if (duplicateFiles.length > 0) {
            const foldersList = duplicateFiles.map(file => {
                const folder = file.parent?.name || 'root';
                return folder;
            }).join(', ');

            new Notice(
                `❌ Blocked creation of "${sanitizedName}.md"\n` +
                `File with exact same name already exists in: ${foldersList}\n` +
                `Choose a different filename to avoid link disambiguation.`, 
                7000
            );
            throw new Error(`Duplicate filename blocked: ${sanitizedName}.md (exists in: ${foldersList})`);
        }

        // If all checks pass, create the file with sanitized name
        const finalPath = [...pathParts, sanitizedName + '.md'].join('/');
        return originalCreate(finalPath, data, options);
    };

    // Monitor file creation events for additional safety
    const handleFileCreate = (file: TFile) => {
        if (!isActive) return;

        const fileName = file.basename;
        
        // Double-check for bracket issues in case something bypassed our override
        if (fileName.startsWith('[') && !fileName.endsWith(']')) {
            setTimeout(async () => {
                try {
                    await app.vault.delete(file);
                    new Notice(`🗑️ Removed malformed file: "${fileName}"`);
                } catch (error) {
                    console.error('Failed to remove malformed file:', error);
                }
            }, 100);
        }
    };

    // Listen for file creation events
    app.vault.on('create', handleFileCreate);

    // Cleanup function
    const cleanup = () => {
        isActive = false;
        // Restore original create method
        app.vault.create = originalCreate;
        app.vault.off('create', handleFileCreate);
    };

    // Store cleanup function for potential future use
    (window as any).noteGuardCleanup = cleanup;

    new Notice('🛡️ Note Creation Guard is active (with character sanitization)');
}

Notice I also disallow creation of existing basenames for fear of Obsidian messing up my wikilinks adding path to wikilinks.
If this is not what you want, have this removed by an AI bot.

You need to add this to a startup folder so the plugin can load it as part of Obsidian main app.

Then in a main.ts in the same folder you saved Note-Creation-Guard.ts, you add:

import { invoke as notecreationGuard } from './Note-Creation-Guard.ts';

export async function invoke(app: App): Promise<void> {
  await notecreationGuard(app);
}

This way it just gets loaded, does it job and no need to register it as an full-blown plugin and you can freely change or add to rules you want.

On mobile, again, more involved if you have an older device.

An interesting option. I had forgotten about prohibiting wiki links, but I also modernized my script by limiting its operation exclusively to the note header by restricting the coordinates (it does not apply below the title). Your method is also very interesting. I think I will integrate part of your script into mine.

export async function invoke() {
    console.log("=== ANYCHAR ===");

    const forbiddenMap: Record<string, string> = {
        "/": "⧸", "\\": "⧵", ":": "։", "|": "❘", "#": "#",
        "[": "〚", "]": "〛", "^": "ˆ", ".": "․", "?": "?", '"': "“"
    };

    document.addEventListener("keyup", (event) => {
        const target = event.target as HTMLElement;
        if (!target || !target.getAttribute) return;

        const isEditable = target.getAttribute("contenteditable") === "true";
        if (!isEditable) return;

        const rect = target.getBoundingClientRect();
        const isTopZone = rect.top < 150; 
        const isSmallElement = rect.height < 200;

        if (isEditable && isTopZone && isSmallElement) {
            let title = target.textContent ?? "";
            let sanitized = title;
            let hasForbidden = false;

            for (const bad in forbiddenMap) {
                if (title.includes(bad)) {
                    hasForbidden = true;
                    break;
                }
            }

            if (hasForbidden) {
                for (const [bad, good] of Object.entries(forbiddenMap)) {
                    sanitized = sanitized.split(bad).join(good);
                }

                target.textContent = sanitized;
                const sel = window.getSelection();
                if (sel && target.childNodes.length > 0) {
                    const range = document.createRange();
                    const textNode = target.childNodes[0];
                    range.setStart(textNode, sanitized.length);
                    range.collapse(true);
                    sel.removeAllRanges();
                    sel.addRange(range);
                }
            }
        }
    }, true);
}

invoke();

I have the same gripes about not being able to use some characters in titles. I’d like to give Anychar a try, but when I try to power it up in Community plugins, I get a simple “failed to load plugin “obsidian-anychar””.

I’m a relative noob to installing plugin but how hard can it be? heh. I’m running on a Mac if that’s helpful. Obsidian 1.11.7,