Add ignore mentions, alias management, and demo assets

Ignore individual mentions or exclude notes from results entirely.
Manage frontmatter aliases from the dashboard with uniqueness checks.
Add before/after screenshots and demo GIF to README.
This commit is contained in:
Jacob Bak Holm 2026-04-12 22:17:51 +02:00
parent 2137f8fb79
commit b2c569ef41
12 changed files with 340 additions and 4 deletions

View file

@ -4,6 +4,11 @@ Find every unlinked mention in your vault and convert them to wikilinks in one c
![Link Plus demo](docs/demo.gif)
## Before vs after
![Before and after linking](docs/before.png)
![Vault fully linked](docs/after.png)
## What it does
- **Vault-wide scan** — finds every piece of text that matches a note title (or alias) but isn't linked
@ -11,6 +16,8 @@ Find every unlinked mention in your vault and convert them to wikilinks in one c
- **One-click linking** — convert a single mention to a `[[wikilink]]` instantly
- **Batch linking** — link all mentions of a note, or every unlinked mention in the vault at once
- **Smart matching** — case-insensitive, whole-word only, respects frontmatter aliases
- **Alias management** — add or remove search aliases from the dashboard, no manual YAML editing needed
- **Ignore mentions** — dismiss individual mentions or exclude a note from results entirely
- **Skips what it should** — ignores existing links, code blocks, frontmatter, and self-references
Every setting is toggleable. Disable case-insensitive matching, exclude folders, filter out short titles — you're in control.
@ -43,14 +50,18 @@ Obsidian's core backlinks pane shows unlinked mentions, but only for the note yo
- **Context snippet** with the matched text highlighted
- **Link** — converts the mention to a wikilink
- **Open** — opens the source file and scrolls to the mention
- **Ignore** — dismiss this mention so it won't appear again
- Each group header has:
- **Edit aliases** (pencil icon) — open a modal to view, add, or remove the search terms for that note
- **Link all** — batch-convert all mentions for that note
- **Ignore all** (x icon) — exclude that note from future scans
- **Link all** at the top converts every unlinked mention in the vault (with confirmation by default)
- **+ button** in the header — pick any note in the vault and add aliases for it, even if it has no current matches
The dashboard auto-refreshes a couple of seconds after you edit a note so results stay current while you work.
## Settings
![Link Plus settings](docs/settings.png)
Open **Settings → Community plugins → Link Plus**. You can:
- Toggle case-insensitive matching
@ -61,6 +72,7 @@ Open **Settings → Community plugins → Link Plus**. You can:
- Require confirmation before batch linking
- Disable auto-rescan on vault changes
- Open the dashboard automatically on startup
- Clear all ignored mentions to reset dismissals
## How it works

BIN
docs/after.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 823 KiB

BIN
docs/before.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 690 KiB

BIN
docs/demo.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

View file

@ -1,6 +1,6 @@
import { App, TFile, CachedMetadata } from "obsidian";
import { UnlinkedMention } from "./types";
import { LinkPlusSettings, parseCommaSeparated } from "./settings";
import { LinkPlusSettings, ignoreKey, parseCommaSeparated } from "./settings";
interface TitleEntry {
title: string;
@ -19,6 +19,7 @@ export async function scanVault(
);
const titleEntries = buildTitleMap(app, files, settings, excludedNotes);
const ignoredSet = new Set(settings.ignoredMentions);
const mentions: UnlinkedMention[] = [];
let count = 0;
@ -33,7 +34,11 @@ export async function scanVault(
titleEntries,
exclusionZones
);
mentions.push(...fileMentions);
for (const m of fileMentions) {
if (!ignoredSet.has(ignoreKey(m.sourceFile.path, m.targetFile.basename))) {
mentions.push(m);
}
}
count++;
if (count % 50 === 0) {
@ -231,3 +236,26 @@ function extractContext(
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
export function findAliasOwner(
app: App,
alias: string,
excludeFile?: TFile
): TFile | null {
const lower = alias.toLowerCase();
for (const file of app.vault.getMarkdownFiles()) {
if (excludeFile && file.path === excludeFile.path) continue;
if (file.basename.toLowerCase() === lower) return file;
const cache = app.metadataCache.getFileCache(file);
if (!cache?.frontmatter) continue;
const aliases: unknown = cache.frontmatter["aliases"];
if (Array.isArray(aliases)) {
for (const a of aliases) {
if (typeof a === "string" && a.toLowerCase() === lower) return file;
}
} else if (typeof aliases === "string" && aliases.toLowerCase() === lower) {
return file;
}
}
return null;
}

View file

@ -10,6 +10,8 @@ export interface LinkPlusSettings {
confirmBatchLink: boolean;
autoRescanOnChange: boolean;
openDashboardOnStart: boolean;
/** "sourcePath::targetBasename" pairs the user dismissed */
ignoredMentions: string[];
}
export const DEFAULT_SETTINGS: LinkPlusSettings = {
@ -21,8 +23,13 @@ export const DEFAULT_SETTINGS: LinkPlusSettings = {
confirmBatchLink: true,
autoRescanOnChange: true,
openDashboardOnStart: false,
ignoredMentions: [],
};
export function ignoreKey(sourcePath: string, targetBasename: string): string {
return `${sourcePath}::${targetBasename}`;
}
export function parseCommaSeparated(value: string): string[] {
return value
.split(",")
@ -159,6 +166,25 @@ export class LinkPlusSettingTab extends PluginSettingTab {
await this.plugin.saveSettings();
})
);
const ignoredCount = this.plugin.settings.ignoredMentions.length;
if (ignoredCount > 0) {
new Setting(containerEl)
.setName("Clear ignored mentions")
.setDesc(
`${ignoredCount} mention${ignoredCount === 1 ? "" : "s"} currently ignored.`
)
.addButton((b) =>
b
.setButtonText("Clear all")
.setWarning()
.onClick(async () => {
this.plugin.settings.ignoredMentions = [];
await this.saveAndInvalidate();
this.display();
})
);
}
}
private async saveAndInvalidate(): Promise<void> {

View file

@ -4,6 +4,7 @@ import { ScanResults, groupByTarget, uniqueSourceCount } from "../types";
import { linkAllMentions } from "../linker";
import { renderGroupSection } from "./group-section";
import { ConfirmModal } from "./confirm-modal";
import { NotePickerModal } from "./note-picker-modal";
import LinkPlusPlugin from "../main";
export class LinkPlusView extends ItemView {
@ -76,6 +77,14 @@ export class LinkPlusView extends ItemView {
header.createEl("h2", { cls: "lp-title", text: PLUGIN_NAME });
const headerActions = header.createDiv({ cls: "lp-header-actions" });
// Add aliases button
const addAliasBtn = headerActions.createEl("button", { cls: "lp-action" });
addAliasBtn.ariaLabel = "Add aliases to a note";
setIcon(addAliasBtn, "plus");
addAliasBtn.addEventListener("click", () => {
new NotePickerModal(this.plugin).open();
});
// Rescan button
const rescan = headerActions.createEl("button", { cls: "lp-action" });
rescan.ariaLabel = "Rescan vault";

134
src/ui/alias-modal.ts Normal file
View file

@ -0,0 +1,134 @@
import { Modal, Notice, TFile, setIcon } from "obsidian";
import { findAliasOwner } from "../scanner";
import LinkPlusPlugin from "../main";
export class AliasModal extends Modal {
private aliasListEl!: HTMLElement;
constructor(
private plugin: LinkPlusPlugin,
private file: TFile
) {
super(plugin.app);
}
onOpen(): void {
const { contentEl } = this;
contentEl.addClass("lp-alias-modal");
contentEl.createEl("h3", { text: `Aliases for ${this.file.basename}` });
contentEl.createEl("p", {
cls: "lp-alias-desc",
text: "Text matching any of these terms will be suggested as a link to this note.",
});
this.aliasListEl = contentEl.createDiv({ cls: "lp-alias-list" });
this.renderChips();
// Add alias input row
const inputRow = contentEl.createDiv({ cls: "lp-alias-input" });
const input = inputRow.createEl("input", {
type: "text",
placeholder: "New alias...",
});
const addBtn = inputRow.createEl("button", { text: "Add" });
const doAdd = (): void => {
const value = input.value.trim();
if (!value) return;
void this.addAlias(value, input);
};
addBtn.addEventListener("click", doAdd);
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
doAdd();
}
});
}
onClose(): void {
this.contentEl.empty();
}
private renderChips(): void {
this.aliasListEl.empty();
const aliases = this.getCurrentAliases();
if (aliases.length === 0) {
this.aliasListEl.createSpan({
cls: "lp-alias-empty",
text: "No aliases yet.",
});
return;
}
for (const alias of aliases) {
const chip = this.aliasListEl.createDiv({ cls: "lp-alias-chip" });
chip.createSpan({ text: alias });
const removeBtn = chip.createSpan({ cls: "lp-alias-chip-remove" });
setIcon(removeBtn, "x");
removeBtn.addEventListener("click", () => {
void this.removeAlias(alias);
});
}
}
private getCurrentAliases(): string[] {
const cache = this.app.metadataCache.getFileCache(this.file);
if (!cache?.frontmatter) return [];
const raw: unknown = cache.frontmatter["aliases"];
if (Array.isArray(raw)) {
return raw.filter((a): a is string => typeof a === "string" && a.length > 0);
}
if (typeof raw === "string" && raw.length > 0) return [raw];
return [];
}
private async addAlias(alias: string, input: HTMLInputElement): Promise<void> {
// Check if already an alias of this note
const current = this.getCurrentAliases();
if (current.some((a) => a.toLowerCase() === alias.toLowerCase())) {
new Notice(`"${alias}" is already an alias of this note.`);
return;
}
// Check uniqueness across vault
const owner = findAliasOwner(this.app, alias, this.file);
if (owner) {
new Notice(
`"${alias}" is already used by "${owner.basename}".`
);
return;
}
await this.app.fileManager.processFrontMatter(this.file, (fm: Record<string, unknown>) => {
if (!Array.isArray(fm["aliases"])) {
fm["aliases"] = [];
}
(fm["aliases"] as string[]).push(alias);
});
input.value = "";
this.renderChips();
this.plugin.orchestrator.invalidate();
void this.plugin.orchestrator.scan();
}
private async removeAlias(alias: string): Promise<void> {
await this.app.fileManager.processFrontMatter(this.file, (fm: Record<string, unknown>) => {
if (!Array.isArray(fm["aliases"])) return;
const arr = fm["aliases"] as string[];
const idx = arr.findIndex(
(a) => a.toLowerCase() === alias.toLowerCase()
);
if (idx !== -1) arr.splice(idx, 1);
if (arr.length === 0) delete fm["aliases"];
});
this.renderChips();
this.plugin.orchestrator.invalidate();
void this.plugin.orchestrator.scan();
}
}

View file

@ -1,8 +1,10 @@
import { Notice, setIcon } from "obsidian";
import { MentionGroup } from "../types";
import { parseCommaSeparated } from "../settings";
import { linkAllMentions } from "../linker";
import { renderMentionRow } from "./mention-row";
import { ConfirmModal } from "./confirm-modal";
import { AliasModal } from "./alias-modal";
import LinkPlusPlugin from "../main";
export function renderGroupSection(
@ -33,6 +35,15 @@ export function renderGroupSection(
text: String(group.mentions.length),
});
// Edit aliases
const editBtn = header.createEl("button", { cls: "lp-action" });
editBtn.ariaLabel = `Edit aliases for ${group.targetFile.basename}`;
setIcon(editBtn, "pencil");
editBtn.addEventListener("click", (e) => {
e.stopPropagation();
new AliasModal(plugin, group.targetFile).open();
});
// Link all for this note
const linkAllBtn = header.createEl("button", { cls: "lp-action lp-group-link-all" });
linkAllBtn.ariaLabel = `Link all mentions of ${group.targetFile.basename}`;
@ -61,6 +72,25 @@ export function renderGroupSection(
}
});
// Ignore all — adds target note to excluded notes
const ignoreAllBtn = header.createEl("button", { cls: "lp-action" });
ignoreAllBtn.ariaLabel = `Ignore all mentions of ${group.targetFile.basename}`;
setIcon(ignoreAllBtn, "x");
ignoreAllBtn.addEventListener("click", (e) => {
e.stopPropagation();
const title = group.targetFile.basename;
const current = parseCommaSeparated(plugin.settings.excludedNotes);
if (!current.some((n) => n.toLowerCase() === title.toLowerCase())) {
current.push(title);
plugin.settings.excludedNotes = current.join(", ");
}
void (async () => {
await plugin.saveSettings();
plugin.orchestrator.invalidate();
void plugin.orchestrator.scan();
})();
});
const body = section.createDiv({ cls: "lp-group-body" });
for (const mention of group.mentions) {
renderMentionRow(body, mention, plugin);

View file

@ -1,5 +1,6 @@
import { MarkdownView, setIcon } from "obsidian";
import { UnlinkedMention } from "../types";
import { ignoreKey } from "../settings";
import { linkSingleMention } from "../linker";
import LinkPlusPlugin from "../main";
@ -63,6 +64,27 @@ export function renderMentionRow(
}
})();
});
// Ignore button — dismiss this mention (source + target pair)
const ignoreBtn = actions.createEl("button", { cls: "lp-action" });
ignoreBtn.ariaLabel = "Ignore this mention";
setIcon(ignoreBtn, "x");
ignoreBtn.addEventListener("click", () => {
void (async () => {
const key = ignoreKey(
mention.sourceFile.path,
mention.targetFile.basename
);
if (!plugin.settings.ignoredMentions.includes(key)) {
plugin.settings.ignoredMentions.push(key);
await plugin.saveSettings();
}
plugin.orchestrator.removeMention(
mention.sourceFile.path,
mention.offset
);
})();
});
}
function renderContextSnippet(

View file

@ -0,0 +1,22 @@
import { FuzzySuggestModal, TFile } from "obsidian";
import LinkPlusPlugin from "../main";
import { AliasModal } from "./alias-modal";
export class NotePickerModal extends FuzzySuggestModal<TFile> {
constructor(private plugin: LinkPlusPlugin) {
super(plugin.app);
this.setPlaceholder("Pick a note to manage aliases...");
}
getItems(): TFile[] {
return this.app.vault.getMarkdownFiles();
}
getItemText(file: TFile): string {
return file.basename;
}
onChooseItem(file: TFile): void {
new AliasModal(this.plugin, file).open();
}
}

View file

@ -178,3 +178,56 @@
font-weight: 600;
color: var(--text-accent);
}
/* Alias modal */
.lp-alias-modal .lp-alias-desc {
color: var(--text-muted);
font-size: var(--font-ui-smaller);
margin-bottom: 8px;
}
.lp-alias-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 12px;
min-height: 28px;
}
.lp-alias-empty {
color: var(--text-faint);
font-size: var(--font-ui-smaller);
}
.lp-alias-chip {
display: inline-flex;
align-items: center;
gap: 4px;
background: var(--background-modifier-border);
border-radius: 12px;
padding: 2px 6px 2px 10px;
font-size: var(--font-ui-smaller);
}
.lp-alias-chip-remove {
display: inline-flex;
cursor: pointer;
color: var(--text-muted);
border-radius: 50%;
padding: 1px;
}
.lp-alias-chip-remove:hover {
color: var(--text-error);
background: var(--background-modifier-hover);
}
.lp-alias-input {
display: flex;
gap: 6px;
}
.lp-alias-input input {
flex: 1;
}