How to add Wikipedia like citations?

You could do something like the following:

Save this script…

import { App, Plugin, Modal, Editor, TFile } from 'obsidian';

// --- Config ---
const ENABLE_AUTO_LINT_AFTER_FOOTNOTE = false; // Set to true to enable auto-linting

class CitationModal extends Modal {
    private result: string | null = null;
    private resolve!: (value: string | null) => void;
    private promise: Promise<string | null>;
    private suggestions: string[] = [];
    private suggestionContainer: HTMLElement | null = null;
    private selectedSuggestion: number = -1;
    private files: TFile[] = [];

    constructor(app: App) {
        super(app);
        this.promise = new Promise(resolve => {
            this.resolve = resolve;
        });
        this.files = app.vault.getMarkdownFiles();
    }

    async getCitation(): Promise<string | null> {
        super.open();
        return this.promise;
    }

    onOpen() {
        const contentEl = this.contentEl;
        contentEl.createEl('h3', { text: 'Add Citation Link' });
        const input = contentEl.createEl('input', {
            type: 'text',
            placeholder: 'Enter note name...'
        });
        input.style.width = '100%';
        input.style.marginBottom = '10px';
        const buttonContainer = contentEl.createDiv();
        buttonContainer.style.textAlign = 'right';
        buttonContainer.style.marginBottom = '16px';
        const cancelBtn = buttonContainer.createEl('button', { text: 'Cancel' });
        cancelBtn.style.marginRight = '10px';
        cancelBtn.onclick = () => {
            this.result = null;
            super.close();
        };
        const okBtn = buttonContainer.createEl('button', { text: 'Add Citation' });
        okBtn.onclick = () => {
            this.result = input.value.trim();
            super.close();
        };

        // Suggestion dropdown
        this.suggestionContainer = contentEl.createDiv();
        this.suggestionContainer.style.position = 'relative';
        this.suggestionContainer.style.width = '100%';
        this.suggestionContainer.style.zIndex = '1000';
        this.suggestionContainer.style.marginTop = '-10px';
        this.suggestionContainer.style.display = 'none';
        this.suggestionContainer.style.minHeight = '40px';
        this.suggestionContainer.style.maxHeight = '300px';
        this.suggestionContainer.style.minHeight = '200px';
        this.suggestionContainer.style.overflowY = 'auto';
        // Make modal taller to fit dropdown
        contentEl.style.minHeight = '180px';

        input.addEventListener('input', (e) => {
            this.updateSuggestions(input.value);
        });

        input.addEventListener('keydown', (e: KeyboardEvent) => {
            if (this.suggestions.length > 0 && this.suggestionContainer && this.suggestionContainer.style.display !== 'none') {
                if (e.key === 'ArrowDown') {
                    e.preventDefault();
                    this.selectedSuggestion = (this.selectedSuggestion + 1) % this.suggestions.length;
                    this.renderSuggestions(input);
                } else if (e.key === 'ArrowUp') {
                    e.preventDefault();
                    this.selectedSuggestion = (this.selectedSuggestion - 1 + this.suggestions.length) % this.suggestions.length;
                    this.renderSuggestions(input);
                } else if (e.key === 'Enter') {
                    if (this.selectedSuggestion >= 0 && this.selectedSuggestion < this.suggestions.length) {
                        input.value = `[[${this.suggestions[this.selectedSuggestion]}]]`;
                        if (this.suggestionContainer) this.suggestionContainer.style.display = 'none';
                        this.suggestions = [];
                        this.selectedSuggestion = -1;
                    } else {
                        this.result = input.value.trim();
                        super.close();
                    }
                } else if (e.key === 'Escape') {
                    if (this.suggestionContainer) this.suggestionContainer.style.display = 'none';
                    this.suggestions = [];
                    this.selectedSuggestion = -1;
                }
            } else {
                if (e.key === 'Enter') {
                    this.result = input.value.trim();
                    super.close();
                } else if (e.key === 'Escape') {
                    this.result = null;
                    super.close();
                }
            }
        });
        input.focus();
    }

    updateSuggestions(value: string) {
        const search = value.trim().toLowerCase();
        this.suggestions = getFileSuggestions(this.files, search);
        this.selectedSuggestion = -1;
        const inputEl = this.contentEl.querySelector('input');
        if (inputEl) this.renderSuggestions(inputEl);
    }

    renderSuggestions(input: HTMLInputElement) {
        if (!this.suggestionContainer) return;
        this.suggestionContainer.innerHTML = '';
        if (this.suggestions.length === 0) {
            this.suggestionContainer.style.display = 'none';
            return;
        }
        this.suggestionContainer.style.display = 'block';
        const list = document.createElement('ul');
        list.style.listStyle = 'none';
        list.style.padding = '0';
        list.style.margin = '0';
        list.style.background = 'var(--background-secondary)';
        list.style.border = '1px solid var(--background-modifier-border)';
        list.style.position = 'absolute';
        list.style.width = '100%';
        list.style.maxHeight = '160px';
        list.style.overflowY = 'auto';
        this.suggestions.forEach((suggestion, idx) => {
            const item = document.createElement('li');
            item.textContent = suggestion;
            item.style.padding = '4px 8px';
            item.style.cursor = 'pointer';
            if (idx === this.selectedSuggestion) {
                item.style.background = 'var(--background-modifier-hover)';
            }
            item.onmousedown = (e) => {
                e.preventDefault();
                const inputEl = this.contentEl.querySelector('input');
                if (inputEl) {
                    inputEl.value = `[[${suggestion}]]`;
                    if (this.suggestionContainer) this.suggestionContainer.style.display = 'none';
                    this.suggestions = [];
                    this.selectedSuggestion = -1;
                    inputEl.focus();
                }
            };
            list.appendChild(item);
        });
        this.suggestionContainer.appendChild(list);
    }

    onClose() {
        this.resolve(this.result);
    }
}

// Shared file suggestion logic
function getFileSuggestions(files: TFile[], search: string): string[] {
    const normalizedSearch = search.trim().toLowerCase();
    if (!normalizedSearch) return files.map(f => f.basename).slice(0, 10);
    const exactMatches: string[] = [];
    const startsWithMatches: string[] = [];
    const containsMatches: string[] = [];
    files.forEach(file => {
        const basename = file.basename;
        const lowerBasename = basename.toLowerCase();
        if (lowerBasename === normalizedSearch) {
            exactMatches.push(basename);
        } else if (lowerBasename.startsWith(normalizedSearch)) {
            startsWithMatches.push(basename);
        } else if (lowerBasename.includes(normalizedSearch)) {
            containsMatches.push(basename);
        }
    });
    return [...exactMatches, ...startsWithMatches, ...containsMatches].slice(0, 10);
}

const addCitationToSelection = async (app: App): Promise<void> => {
    const file = app.workspace.getActiveFile();
    const editor: Editor | undefined = app.workspace.activeLeaf?.view?.editor;
    if (!file || !editor) return;
    const selection = editor.getSelection();
    if (!selection) return;
    const modal = new CitationModal(app);
    const link = await modal.getCitation();
    if (!link) return;
    const citationCallout = `\n> [!cite|clean no-title right]\n> ${link}\n\n`;
    editor.replaceSelection(selection + citationCallout);
};

// --- Footnote Modal and Logic ---
class FootnoteModal extends Modal {
    private result: { text: string; link: string } | null = null;
    private resolve!: (value: { text: string; link: string } | null) => void;
    private promise: Promise<{ text: string; link: string } | null>;
    private suggestions: string[] = [];
    private suggestionContainer: HTMLElement | null = null;
    private selectedSuggestion: number = -1;
    private files: TFile[] = [];

    constructor(app: App) {
        super(app);
        this.promise = new Promise(resolve => {
            this.resolve = resolve;
        });
        this.files = app.vault.getMarkdownFiles();
    }

    async getFootnote(): Promise<{ text: string; link: string } | null> {
        super.open();
        return this.promise;
    }

    onOpen() {
        const contentEl = this.contentEl;
        contentEl.createEl('h3', { text: 'Add Footnote Citation' });
        const textLabel = contentEl.createEl('label', { text: 'Citation text (optional):' });
        textLabel.style.display = 'block';
        textLabel.style.marginBottom = '5px';
        const textInput = contentEl.createEl('input', {
            type: 'text',
            placeholder: 'Brief citation text (optional)'
        });
        textInput.style.width = '100%';
        textInput.style.marginBottom = '15px';
        const linkLabel = contentEl.createEl('label', { text: 'Link (internal note or URL):' });
        linkLabel.style.display = 'block';
        linkLabel.style.marginBottom = '5px';
        const linkInput = contentEl.createEl('input', {
            type: 'text',
            placeholder: 'Note name or https://...'
        });
        linkInput.style.width = '100%';
        linkInput.style.marginBottom = '10px';
        const buttonContainer = contentEl.createDiv();
        buttonContainer.style.textAlign = 'right';
        buttonContainer.style.marginBottom = '16px';
        const cancelBtn = buttonContainer.createEl('button', { text: 'Cancel' });
        cancelBtn.style.marginRight = '10px';
        cancelBtn.onclick = () => {
            this.result = null;
            super.close();
        };
        const okBtn = buttonContainer.createEl('button', { text: 'Add Footnote' });
        okBtn.onclick = () => {
            const text = textInput.value.trim();
            const link = linkInput.value.trim();
            if (link) {
                // Format link - if it's not a URL, make it a wikilink
                const formattedLink = link.startsWith('http') ? link : `[[${link}]]`;
                this.result = { text, link: formattedLink };
            } else {
                this.result = null;
            }
            super.close();
        };
        // Suggestion dropdown for link input (match citation modal)
        this.suggestionContainer = contentEl.createDiv();
        this.suggestionContainer.style.position = 'relative';
        this.suggestionContainer.style.width = '100%';
        this.suggestionContainer.style.zIndex = '1000';
        this.suggestionContainer.style.marginTop = '-10px';
        this.suggestionContainer.style.display = 'none';
        this.suggestionContainer.style.minHeight = '40px';
        this.suggestionContainer.style.maxHeight = '300px';
        this.suggestionContainer.style.minHeight = '200px';
        this.suggestionContainer.style.overflowY = 'auto';
        // Always show file suggestions as you type
        linkInput.addEventListener('input', (e) => {
            this.updateSuggestions(linkInput.value);
        });
        linkInput.addEventListener('keydown', (e: KeyboardEvent) => {
            if (this.suggestions.length > 0 && this.suggestionContainer && this.suggestionContainer.style.display !== 'none') {
                if (e.key === 'ArrowDown') {
                    e.preventDefault();
                    this.selectedSuggestion = (this.selectedSuggestion + 1) % this.suggestions.length;
                    this.renderSuggestions(linkInput);
                } else if (e.key === 'ArrowUp') {
                    e.preventDefault();
                    this.selectedSuggestion = (this.selectedSuggestion - 1 + this.suggestions.length) % this.suggestions.length;
                    this.renderSuggestions(linkInput);
                } else if (e.key === 'Enter') {
                    if (this.selectedSuggestion >= 0 && this.selectedSuggestion < this.suggestions.length) {
                        linkInput.value = this.suggestions[this.selectedSuggestion];
                        if (this.suggestionContainer) this.suggestionContainer.style.display = 'none';
                        this.suggestions = [];
                        this.selectedSuggestion = -1;
                    }
                } else if (e.key === 'Escape') {
                    if (this.suggestionContainer) this.suggestionContainer.style.display = 'none';
                    this.suggestions = [];
                    this.selectedSuggestion = -1;
                }
            }
        });
        // Show suggestions immediately if field is not empty
        this.updateSuggestions(linkInput.value);
        textInput.focus();
    }
    updateSuggestions(value: string) {
        const search = value.toLowerCase();
        // Always use shared file suggestion logic
        this.suggestions = getFileSuggestions(this.files, search);
        this.selectedSuggestion = -1;
        const linkInput = this.contentEl.querySelectorAll('input')[1] as HTMLInputElement;
        if (linkInput) this.renderSuggestions(linkInput);
    }
    renderSuggestions(linkInput: HTMLInputElement) {
        if (!this.suggestionContainer) return;
        this.suggestionContainer.innerHTML = '';
        if (this.suggestions.length === 0) {
            this.suggestionContainer.style.display = 'none';
            return;
        }
        this.suggestionContainer.style.display = 'block';
        const list = document.createElement('ul');
        list.style.listStyle = 'none';
        list.style.padding = '0';
        list.style.margin = '0';
        list.style.background = 'var(--background-secondary)';
        list.style.border = '1px solid var(--background-modifier-border)';
        list.style.position = 'absolute';
        list.style.width = '100%';
        const itemHeight = 32;
        const maxItems = Math.min(this.suggestions.length, 6);
        list.style.height = `${maxItems * itemHeight}px`;
        list.style.overflowY = this.suggestions.length > 6 ? 'auto' : 'hidden';
        this.suggestions.forEach((suggestion, idx) => {
            const item = document.createElement('li');
            item.textContent = suggestion;
            item.style.padding = '8px';
            item.style.cursor = 'pointer';
            item.style.height = `${itemHeight}px`;
            item.style.boxSizing = 'border-box';
            item.style.display = 'flex';
            item.style.alignItems = 'center';
            if (idx === this.selectedSuggestion) {
                item.style.background = 'var(--background-modifier-hover)';
            }
            item.onmousedown = (e) => {
                e.preventDefault();
                linkInput.value = suggestion;
                if (this.suggestionContainer) this.suggestionContainer.style.display = 'none';
                this.suggestions = [];
                this.selectedSuggestion = -1;
                linkInput.focus();
            };
            list.appendChild(item);
        });
        this.suggestionContainer.appendChild(list);
    }
    onClose() {
        this.resolve(this.result);
    }
}

const addFootnoteCitation = async (app: App): Promise<void> => {
    const file = app.workspace.getActiveFile();
    const editor: Editor | undefined = app.workspace.activeLeaf?.view?.editor;
    if (!file || !editor) return;
    const modal = new FootnoteModal(app);
    const footnote = await modal.getFootnote();
    if (!footnote) return;
    // Read file content to count existing footnotes
    const fileContent = await app.vault.read(file);
    // Match all [^number] refs
    const matches = fileContent.match(/\[\^(\d+)\]/g);
    let nextNumber = 1;
    if (matches) {
        // Extract numbers, get max, increment
        const numbers = matches.map(m => parseInt(m.replace(/\D/g, ''), 10)).filter(n => !isNaN(n));
        if (numbers.length > 0) {
            nextNumber = Math.max(...numbers) + 1;
        }
    }
    // Get current cursor position
    const cursor = editor.getCursor();
    // Add footnote reference at cursor
    const footnoteRef = `[^${nextNumber}]`;
    editor.replaceRange(footnoteRef, cursor);
    // Find the end of the document to add footnote
    const lastLine = editor.lastLine();
    const lastLineContent = editor.getLine(lastLine);
    // Always add \n\n before the new footnote definition
    const citationText = footnote.text ? footnote.text + ' ' : '';
    const footnoteDefinition = `\n\n[^${nextNumber}]: ${citationText}${footnote.link}`;
    const insertPos = { line: lastLine, ch: lastLineContent.length };
    editor.replaceRange(footnoteDefinition, insertPos);
    // Auto-lint if enabled
    if (ENABLE_AUTO_LINT_AFTER_FOOTNOTE) {
        await new Promise(resolve => setTimeout(resolve, 200));
        await app.commands.executeCommandById('obsidian-linter:lint-file-unless-ignored');
    }
};

// --- Smart Command ---
const smartCitationCommand = async (app: App): Promise<void> => {
    const file = app.workspace.getActiveFile();
    const editor: Editor | undefined = app.workspace.activeLeaf?.view?.editor;
    if (!file || !editor) return;
    const selection = editor.getSelection();
    if (selection && selection.length > 0) {
        await addCitationToSelection(app);
    } else {
        await addFootnoteCitation(app);
    }
};

export class CitationAdderPlugin extends Plugin {
    async onload() {
        this.addCommand({
            id: 'smart-citation',
            name: 'Add Citation or Footnote',
            callback: () => smartCitationCommand(this.app)
        });
    }
}

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

…as Add-Citation.ts in the folder you specify in the settings of CodeScript Toolkit you need to install and enable.

  • You can find some info on how to do this in the top part of the guide I share here.

That plugin will handle running the script.

Currently you can do two things:

  1. If you have a line or paragraph selected, it inserts the wikilink of a file you choose from the file picker.
  2. If you have zero text selected, meaning you point the cursor at the end of a line and run the script (you can bind a hotkey to the .ts file), you can add a footnote with an optional text plus an internal or external link.

Because your note can be long and this script will not handle all previously added footnote references to manage them in order, you can use the Linter plugin to do this for you after each addition of a footnote.
See this leaf in settings:

So if you installed and enabled Linter and configged it, you can now change this line in the script:
const ENABLE_AUTO_LINT_AFTER_FOOTNOTE = false; // Set to true to enable auto-linting to const ENABLE_AUTO_LINT_AFTER_FOOTNOTE = true; // Set to true to enable auto-linting.

I didn’t test this part now, but it should work.

As for the small text on the side, you need to save this to your snippets folder, e.g. wiki-citations.css and enable it.

.callout[data-callout="cite"] {
    margin-top: -1.4em;
    margin-bottom: 0;
    padding: 0;
    display: flex;
    justify-content: flex-end;
    width: 100%;
    font-size: 0.7em;
    background: transparent;
    border: none;
    box-shadow: none;
}

.callout[data-callout="cite"] .callout-title {
    display: none;
}

.callout[data-callout="cite"] .callout-content {
    padding: 0;
    margin: 0;
    background: transparent;
    white-space: nowrap;
}

You can tweak the script and css with AI help.
Using callouts may be an overkill for this but it can be made look good in various ways so it’s an option.
On creating the callouts, the script adds an empty line because you cannot start typing now on the next line, only the one after (you can see what happens if you don’t follow this).

P.S. I edited the title of the thread to reflect what’s in here.