mirror of
https://github.com/jabaho9523/obsidian-link-plus.git
synced 2026-07-22 06:08:23 +00:00
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:
parent
2137f8fb79
commit
b2c569ef41
12 changed files with 340 additions and 4 deletions
16
README.md
16
README.md
|
|
@ -4,6 +4,11 @@ Find every unlinked mention in your vault and convert them to wikilinks in one c
|
|||
|
||||

|
||||
|
||||
## Before vs after
|
||||
|
||||

|
||||

|
||||
|
||||
## 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
|
||||
|
||||

|
||||
|
||||
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
BIN
docs/after.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 823 KiB |
BIN
docs/before.png
Normal file
BIN
docs/before.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 690 KiB |
BIN
docs/demo.gif
Normal file
BIN
docs/demo.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.1 MiB |
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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
134
src/ui/alias-modal.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
22
src/ui/note-picker-modal.ts
Normal file
22
src/ui/note-picker-modal.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
53
styles.css
53
styles.css
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue