Tag Manager

name

Tag Manager

description

Audit, search, merge, and bulk-delete tags. Built for cleaning up Apple Notes / Evernote imports and ongoing tag hygiene.

icon

label_off

instructions

**Run:** Quick-open (Cmd/Ctrl-O) and search for "Open Tag Manager", or type { in any note and choose "Open Tag Manager".


**Triage:** Sort by name (default) or count. Click any tag to expand and see its notes with last-updated date. Paste a comma-separated list — or copy a tag-header row from Amplenote and paste it directly; more_horiz separators are stripped automatically.


**Curate:** Click anywhere on a row to select. Quick-select "1-note tags" or "orphan tags" buttons for bulk selection. Toggle "Orphans only" to see tags that exist in the registry but apply to zero notes.


**Modify:** Selected tags can be deleted (every note loses the tag) or merged into a single target (existing or brand new). Every destructive action offers a backup-first option that writes a markdown note tagged tag-manager/backup listing every affected tag and note for rollback.


**Safety:** Notes themselves are never touched — only tag metadata. Registry-side cleanup is verified via getTags() after each operation; if any tags refuse to fully evict (shared notebooks, hierarchical parents), they're reported by name in the result toast.




repo: https://github.com/kevinJread/amplenote-tag-manager

linkCode block

// Javascript updated 15/05/2026, 10:12:28 by Amplenote Plugin Builder from source code within "https://github.com/kevinJread/amplenote-tag-manager"
{
// ---------- Entry points ----------
 
appOption: {
"Open Tag Manager": async function (app) {
await app.openEmbed();
await app.navigate(
"https://www.amplenote.com/notes/plugins/" + app.context.pluginUUID
);
},
},
 
// ---------- Embed rendering ----------
 
async renderEmbed(app) {
return this._inlined_embed_js_renderEmbedHTML();
},
 
// ---------- RPC handlers (called from embed via window.callAmplenotePlugin) ----------
 
async onEmbedCall(app, action, ...args) {
try {
switch (action) {
case "loadTags":
return await this._loadTags(app);
case "deleteTags":
return await this._deleteTags(app, args[0]);
case "mergeTags":
return await this._mergeTags(app, args[0], args[1]);
case "exportBackup":
return await this._exportBackup(app, args[0]);
case "exportMergeBackup":
return await this._exportMergeBackup(app, args[0], args[1]);
case "openNote":
return await this._openNote(app, args[0]);
default:
throw new Error(`Unknown action: ${action}`);
}
} catch (err) {
console.error("[tag-manager] onEmbedCall error", action, err);
return { error: String(err && err.message ? err.message : err) };
}
},
 
// ---------- Core logic ----------
 
/**
* Loads every note once and aggregates a tag -> notes map client-side.
* Then unions with app.getTags() so orphan tags (in the global registry
* but applied to zero notes) also show up — these are the ghosts that
* accumulate from imports and never get cleaned up.
*
* Returns: [{ name, count, notes: [{ uuid, name, updated, created }] }, ...]
*/
async _loadTags(app) {
const noteHandles = await app.filterNotes({});
const tagMap = new Map();
 
let missingTagsField = false;
for (const nh of noteHandles) {
let tags = nh.tags;
if (!Array.isArray(tags)) {
missingTagsField = true;
tags = [];
}
for (const tag of tags) {
if (!tagMap.has(tag)) tagMap.set(tag, []);
tagMap.get(tag).push({
uuid: nh.uuid,
name: nh.name || "(untitled)",
updated: nh.updated || nh.created || 0,
created: nh.created || 0,
});
}
}
 
// Fallback path: filterNotes didn't include tags. Use getTags + per-tag filterNotes.
if (missingTagsField && tagMap.size === 0) {
console.warn(
"[tag-manager] filterNotes did not return tags; falling back to per-tag enumeration"
);
const tags = this._normalizeTagList(await app.getTags());
for (const name of tags) {
const handles = await app.filterNotes({ tag: name });
tagMap.set(
name,
handles.map((nh) => ({
uuid: nh.uuid,
name: nh.name || "(untitled)",
updated: nh.updated || nh.created || 0,
created: nh.created || 0,
}))
);
}
}
 
// Union with the global tag registry to surface orphan tags (count 0)
try {
const globalTags = this._normalizeTagList(await app.getTags());
for (const tagName of globalTags) {
if (!tagMap.has(tagName)) tagMap.set(tagName, []);
}
} catch (e) {
console.warn("[tag-manager] getTags() unavailable; orphans won't be shown", e);
}
 
const result = [];
for (const [name, notes] of tagMap.entries()) {
notes.sort((a, b) => (b.updated || 0) - (a.updated || 0));
result.push({ name, count: notes.length, notes });
}
return result;
},
 
/** Normalize whatever shape getTags() returns into a plain string[] */
_normalizeTagList(tags) {
if (!Array.isArray(tags)) return [];
return tags
.map((t) => (typeof t === "string" ? t : t && (t.name || t.text)))
.filter((s) => typeof s === "string" && s.length > 0);
},
 
/**
* Sanitize text pasted into the search box. When users copy a tag list from
* Amplenote's UI, Material Icon names come through as plain text — most
* commonly `more_horiz` (the "⋯" separator). This converts those to commas
* and produces a clean CSV string the filter can consume.
*
* Example input:
* "3 more_horiz 4 more_horiz 6495edff more_horiz imported more_horiz"
* Output:
* "3, 4, 6495edff, imported"
*
* Leaves text without `more_horiz` markers untouched.
*
* NOTE: the embed inlines an equivalent function (see `parsePastedTagSearch`
* in embed.js). If you change the parsing here, mirror it there.
*/
_parsePastedTagSearch(text) {
if (typeof text !== "string") return text;
if (!/\bmore_horiz\b/.test(text)) return text;
return text
.replace(/\bmore_horiz\b/g, ",")
.split(",")
.map((s) => s.trim())
.filter(Boolean)
.join(", ");
},
 
/**
* Format whatever Amplenote returns for `updated`/`created` into YYYY-MM-DD.
* Handles: numbers in Unix seconds, numbers in Unix milliseconds, ISO date
* strings, Date instances, and falsy values. Returns "—" on anything
* unrecognized rather than throwing.
*
* Heuristic for number vs ms: any sane Unix timestamp in seconds is < 1e12
* (year 33658). Anything ≥ 1e12 must be milliseconds. This safely
* disambiguates without needing to know Amplenote's exact format.
*/
_formatDate(value) {
if (!value) return "—";
let d;
if (value instanceof Date) {
d = value;
} else if (typeof value === "string") {
d = new Date(value);
} else if (typeof value === "number" && Number.isFinite(value)) {
d = new Date(value < 1e12 ? value * 1000 : value);
} else {
return "—";
}
if (isNaN(d.getTime())) return "—";
try {
return d.toISOString().slice(0, 10);
} catch (_) {
return "—";
}
},
 
/**
* Removes the given tag names from every note that has them, then verifies
* with app.getTags() that the tag is gone from the registry. Tags that
* survive a second remove-pass are reported back as `lingering` so the user
* knows the API couldn't fully clean them up.
*
* Returns: {
* removedTags: how many of the requested tags are confirmed gone,
* removedFromNotes: total (note, tag) removals performed,
* failures: [{tag, note}] where removeNoteTag returned false,
* lingering: tag names still present in app.getTags() after retry,
* }
*/
async _deleteTags(app, tagNames) {
if (!Array.isArray(tagNames) || tagNames.length === 0) {
return { removedTags: 0, removedFromNotes: 0, failures: [], lingering: [] };
}
 
let removedFromNotes = 0;
const failures = [];
 
const removePass = async (tag) => {
const handles = await app.filterNotes({ tag });
for (const nh of handles) {
const ok = await app.removeNoteTag({ uuid: nh.uuid }, tag);
if (ok) removedFromNotes++;
else failures.push({ tag, note: nh.name || nh.uuid });
}
};
 
// Pass 1: remove the tag from every currently-tagged note
for (const tagName of tagNames) {
await removePass(tagName);
}
 
// Verify against the global registry; orphans + race-condition survivors
// get retried in pass 2
let stillRegistered = new Set();
try {
stillRegistered = new Set(this._normalizeTagList(await app.getTags()));
} catch (e) {
console.warn("[tag-manager] getTags() unavailable; skipping verification", e);
return {
removedTags: tagNames.length - failures.length,
removedFromNotes,
failures,
lingering: [],
};
}
 
const survivors = tagNames.filter((t) => stillRegistered.has(t));
 
// Pass 2: retry any tag the registry still knows about
for (const tagName of survivors) {
await removePass(tagName);
}
 
// Final verification
let finalRegistered = new Set();
try {
finalRegistered = new Set(this._normalizeTagList(await app.getTags()));
} catch (e) {
finalRegistered = stillRegistered;
}
const lingering = tagNames.filter((t) => finalRegistered.has(t));
 
return {
removedTags: tagNames.length - lingering.length,
removedFromNotes,
failures,
lingering,
};
},
 
/**
* Writes a backup note containing the given tags + their notes.
* Returns: { uuid, url, name }
*/
async _exportBackup(app, tagsData) {
if (!Array.isArray(tagsData) || tagsData.length === 0) {
throw new Error("No tags to back up");
}
 
const dateStr = new Date().toISOString().slice(0, 19).replace("T", " ");
const dateSlug = new Date().toISOString().slice(0, 10);
const noteName = `Tag Manager Backup ${dateSlug}`;
 
const totalNotes = tagsData.reduce(
(n, t) => n + (t.notes ? t.notes.length : 0),
0
);
 
const lines = [];
lines.push(`> Created: ${dateStr}`);
lines.push(
`> Tags backed up: **${tagsData.length}** — affecting **${totalNotes}** note references`
);
lines.push("");
lines.push(
"If you need to restore a tag, re-apply it to the listed notes. " +
"The `tag-manager/backup` tag on this note will keep all your backups grouped."
);
lines.push("");
 
for (const t of tagsData) {
const notes = t.notes || [];
lines.push(`## \`${t.name}\` (${notes.length})`);
lines.push("");
if (notes.length === 0) {
lines.push("_No notes had this tag._");
lines.push("");
continue;
}
for (const n of notes) {
const updated = this._formatDate(n.updated);
const safeName = (n.name || "(untitled)").replace(/\]/g, "\\]");
lines.push(
`- [${safeName}](https://www.amplenote.com/notes/${n.uuid}) — updated ${updated}`
);
}
lines.push("");
}
 
const noteUUID = await app.createNote(noteName, ["tag-manager/backup"]);
await app.insertNoteContent({ uuid: noteUUID }, lines.join("\n"));
 
return {
uuid: noteUUID,
url: `https://www.amplenote.com/notes/${noteUUID}`,
name: noteName,
};
},
 
async _openNote(app, uuid) {
await app.navigate(`https://www.amplenote.com/notes/${uuid}`);
return true;
},
 
/**
* Merges N source tags into a single target tag. For each note that has any
* source tag, the target tag is added (idempotent) and the source is removed.
* The target can be brand new, an existing tag, or even one of the sources.
*
* Returns: {
* target,
* sourcesProcessed, // count of source tags considered (excludes target-equals-source no-op)
* affectedNotes, // unique note UUIDs touched
* notesTagged, // successful addNoteTag calls
* notesUntagged, // successful removeNoteTag calls
* failures, // [{ note, op: "add"|"remove", tag, error? }]
* lingering, // source tag names still in registry after merge
* }
*/
async _mergeTags(app, sourceTagNames, targetTagName) {
if (!Array.isArray(sourceTagNames) || sourceTagNames.length === 0) {
throw new Error("No source tags selected for merge");
}
if (
typeof targetTagName !== "string" ||
!targetTagName.trim()
) {
throw new Error("Target tag name is required");
}
const target = targetTagName.trim();
 
// Tag-name sanity. Amplenote allows hierarchical tags via "/", but reject
// obvious garbage so we don't write half-broken state.
if (/[\n\r\t]/.test(target) || target.startsWith("/") || target.endsWith("/")) {
throw new Error(`Invalid target tag name: "${target}"`);
}
 
let notesTagged = 0;
let notesUntagged = 0;
const failures = [];
const affectedNoteUUIDs = new Set();
const sourcesActuallyProcessed = sourceTagNames.filter((s) => s !== target);
 
for (const source of sourcesActuallyProcessed) {
const handles = await app.filterNotes({ tag: source });
for (const nh of handles) {
affectedNoteUUIDs.add(nh.uuid);
const handle = { uuid: nh.uuid };
 
// Step 1: tag with target (no-op if note already has it; addNoteTag is idempotent)
try {
const added = await app.addNoteTag(handle, target);
if (added) notesTagged++;
} catch (e) {
failures.push({
note: nh.name || nh.uuid,
op: "add",
tag: target,
error: String(e && e.message ? e.message : e),
});
// If we can't add the target, skip removing the source — better to
// leave the note with the source tag than drop it from the merge entirely.
continue;
}
 
// Step 2: remove source
const removed = await app.removeNoteTag(handle, source);
if (removed) {
notesUntagged++;
} else {
failures.push({ note: nh.name || nh.uuid, op: "remove", tag: source });
}
}
}
 
// Verify: sources should no longer be in the global tag registry
let lingering = [];
try {
const remaining = new Set(this._normalizeTagList(await app.getTags()));
lingering = sourcesActuallyProcessed.filter((t) => remaining.has(t));
} catch (e) {
console.warn("[tag-manager] getTags() unavailable post-merge", e);
}
 
return {
target,
sourcesProcessed: sourcesActuallyProcessed.length,
affectedNotes: affectedNoteUUIDs.size,
notesTagged,
notesUntagged,
failures,
lingering,
};
},
 
/**
* Writes a backup note for a merge operation. Reuses "tag-manager/backup" so all
* Tag Manager backups (delete + merge) stay grouped.
*
* sourcesData: [{ name, count, notes: [{uuid, name, updated}] }]
* targetTagName: the eventual target tag
*/
async _exportMergeBackup(app, sourcesData, targetTagName) {
if (!Array.isArray(sourcesData) || sourcesData.length === 0) {
throw new Error("No source tags to back up");
}
if (typeof targetTagName !== "string" || !targetTagName.trim()) {
throw new Error("Target tag name is required for backup");
}
const target = targetTagName.trim();
 
const dateStr = new Date().toISOString().slice(0, 19).replace("T", " ");
const dateSlug = new Date().toISOString().slice(0, 10);
const noteName = `Tag Merge Backup ${dateSlug}`;
 
const totalNotes = sourcesData.reduce(
(n, t) => n + (t.notes ? t.notes.length : 0),
0
);
 
const lines = [];
lines.push(`> Created: ${dateStr}`);
lines.push(
`> Merge: **${sourcesData.length}** source tag${sourcesData.length === 1 ? "" : "s"} → target \`${target}\``
);
lines.push(`> Affected: **${totalNotes}** note reference${totalNotes === 1 ? "" : "s"}`);
lines.push("");
lines.push(
`After this merge, every listed note carries the tag \`${target}\`. To roll back, ` +
`re-apply each source tag to the notes in its section and remove \`${target}\` ` +
`from those notes (if it wasn't originally there).`
);
lines.push("");
 
for (const t of sourcesData) {
const notes = t.notes || [];
lines.push(`## \`${t.name}\` (${notes.length}) → \`${target}\``);
lines.push("");
if (notes.length === 0) {
lines.push("_No notes had this tag — nothing to roll back._");
lines.push("");
continue;
}
for (const n of notes) {
const updated = this._formatDate(n.updated);
const safeName = (n.name || "(untitled)").replace(/\]/g, "\\]");
lines.push(
`- [${safeName}](https://www.amplenote.com/notes/${n.uuid}) — updated ${updated}`
);
}
lines.push("");
}
 
const noteUUID = await app.createNote(noteName, ["tag-manager/backup"]);
await app.insertNoteContent({ uuid: noteUUID }, lines.join("\n"));
 
return {
uuid: noteUUID,
url: `https://www.amplenote.com/notes/${noteUUID}`,
name: noteName,
};
},
 
// --------------------------------------------------------------------------------------
_inlined_embed_js_renderEmbedHTML() {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Tag Manager</title>
<style>
:root {
--bg: #ffffff;
--bg-elev: #f7f7f8;
--bg-row-hover: #f0f1f3;
--border: #e3e4e8;
--text: #1f2328;
--text-muted: #6a737d;
--accent: #2563eb;
--accent-fg: #ffffff;
--danger: #b42318;
--danger-bg: #fef3f2;
--warn-bg: #fffaeb;
--warn-fg: #b54708;
--ok: #027a48;
--ok-bg: #ecfdf3;
--shadow: 0 1px 2px rgba(0,0,0,0.04), 0 1px 3px rgba(0,0,0,0.06);
--radius: 6px;
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #1a1b1e;
--bg-elev: #25262b;
--bg-row-hover: #2c2e33;
--border: #373a40;
--text: #e6e6e6;
--text-muted: #909296;
--accent: #4c8bf5;
--danger: #ff6b6b;
--danger-bg: #2a1a1a;
--warn-bg: #2a2418;
--warn-fg: #f5b54b;
--ok: #51cf66;
--ok-bg: #1a2a1f;
}
}
* { box-sizing: border-box; }
html, body {
margin: 0; padding: 0;
background: var(--bg);
color: var(--text);
font-family: var(--sans);
font-size: 14px;
line-height: 1.4;
height: 100%;
}
body { display: flex; flex-direction: column; }
.toolbar {
position: sticky; top: 0; z-index: 10;
background: var(--bg);
border-bottom: 1px solid var(--border);
padding: 10px 14px;
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.toolbar h1 {
margin: 0;
font-size: 15px;
font-weight: 600;
margin-right: 8px;
}
.toolbar .spacer { flex: 1; }
.search {
flex: 1;
min-width: 180px;
padding: 6px 10px;
border: 1px solid var(--border);
background: var(--bg-elev);
color: var(--text);
border-radius: var(--radius);
font: inherit;
}
.search:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
select.sort {
padding: 6px 8px;
border: 1px solid var(--border);
background: var(--bg-elev);
color: var(--text);
border-radius: var(--radius);
font: inherit;
}
button {
font: inherit;
padding: 6px 12px;
border-radius: var(--radius);
border: 1px solid var(--border);
background: var(--bg-elev);
color: var(--text);
cursor: pointer;
white-space: nowrap;
}
button:hover:not(:disabled) { background: var(--bg-row-hover); }
button:disabled { opacity: 0.5; cursor: not-allowed; }
button.primary {
background: var(--accent);
color: var(--accent-fg);
border-color: var(--accent);
}
button.primary:hover:not(:disabled) { filter: brightness(0.95); }
button.danger {
background: var(--danger);
color: #fff;
border-color: var(--danger);
}
button.ghost {
background: transparent;
border-color: transparent;
color: var(--text-muted);
padding: 4px 8px;
}
button.ghost:hover { color: var(--text); background: var(--bg-row-hover); }
button.ghost[aria-pressed="true"] {
background: var(--accent);
color: var(--accent-fg);
border-color: var(--accent);
}
button.ghost[aria-pressed="true"]:hover { filter: brightness(0.95); background: var(--accent); color: var(--accent-fg); }
.actionbar {
padding: 8px 14px;
border-bottom: 1px solid var(--border);
background: var(--bg-elev);
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
font-size: 13px;
}
.actionbar .count { color: var(--text-muted); }
.actionbar .count strong { color: var(--text); }
.scroll { flex: 1; overflow: auto; padding: 4px 0; }
.row {
display: grid;
grid-template-columns: 32px 1fr auto 32px;
align-items: center;
padding: 6px 14px;
border-bottom: 1px solid var(--border);
gap: 8px;
cursor: pointer;
user-select: none;
}
.row:hover { background: var(--bg-row-hover); }
.row.selected { background: rgba(37, 99, 235, 0.08); }
@media (prefers-color-scheme: dark) {
.row.selected { background: rgba(76, 139, 245, 0.16); }
}
.row input[type=checkbox] { width: 16px; height: 16px; cursor: pointer; }
.row .tag-name {
font-family: var(--mono);
font-size: 13px;
color: var(--text);
word-break: break-all;
user-select: text;
}
.row .badge {
display: inline-block;
min-width: 24px;
padding: 1px 8px;
border-radius: 10px;
background: var(--bg-elev);
color: var(--text-muted);
font-size: 12px;
text-align: center;
border: 1px solid var(--border);
}
.row .badge.one { color: var(--warn-fg); background: var(--warn-bg); border-color: transparent; }
.row .badge.orphan { color: var(--danger); background: var(--danger-bg); border-color: transparent; }
.row .expand {
background: none; border: none; padding: 2px;
color: var(--text-muted); cursor: pointer; font-size: 14px;
}
.row .expand:hover { color: var(--text); }
.notes-list {
background: var(--bg-elev);
padding: 6px 14px 10px 60px;
border-bottom: 1px solid var(--border);
font-size: 13px;
}
.notes-list .note {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 10px;
padding: 3px 0;
}
.notes-list a {
color: var(--accent);
text-decoration: none;
cursor: pointer;
}
.notes-list a:hover { text-decoration: underline; }
.notes-list .date { color: var(--text-muted); font-size: 12px; white-space: nowrap; }
.empty, .loading {
padding: 40px;
text-align: center;
color: var(--text-muted);
}
.toast {
position: fixed;
bottom: 16px;
left: 50%;
transform: translateX(-50%);
background: var(--text);
color: var(--bg);
padding: 8px 16px;
border-radius: var(--radius);
font-size: 13px;
box-shadow: var(--shadow);
opacity: 0;
transition: opacity 0.2s;
pointer-events: none;
max-width: 90%;
text-align: center;
}
.toast.show { opacity: 0.95; }
.toast.error { background: var(--danger); color: #fff; }
.toast.success { background: var(--ok); color: #fff; }
.modal-bg {
position: fixed; inset: 0;
background: rgba(0,0,0,0.4);
display: none;
align-items: center;
justify-content: center;
z-index: 100;
padding: 20px;
}
.modal-bg.show { display: flex; }
.modal {
background: var(--bg);
border-radius: 8px;
padding: 20px;
max-width: 480px;
width: 100%;
box-shadow: 0 10px 25px rgba(0,0,0,0.3);
border: 1px solid var(--border);
}
.modal h2 { margin: 0 0 8px; font-size: 16px; }
.modal p { margin: 8px 0; color: var(--text-muted); }
.modal .tags-preview {
background: var(--bg-elev);
border-radius: var(--radius);
padding: 8px 10px;
margin: 8px 0;
max-height: 140px;
overflow: auto;
font-family: var(--mono);
font-size: 12px;
border: 1px solid var(--border);
}
.modal .tags-preview .item { padding: 1px 0; }
.modal .actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 14px;
}
.modal .form-label {
margin-top: 12px;
font-size: 13px;
color: var(--text-muted);
font-weight: 500;
}
.modal input[type=text] {
width: 100%;
padding: 7px 10px;
border: 1px solid var(--border);
background: var(--bg-elev);
color: var(--text);
border-radius: var(--radius);
font: inherit;
margin-top: 4px;
font-family: var(--mono);
font-size: 13px;
}
.modal input[type=text]:focus {
outline: 2px solid var(--accent);
outline-offset: -1px;
}
.modal input[type=text].invalid {
border-color: var(--danger);
}
.modal .checkbox-row {
display: block;
margin-top: 12px;
font-size: 13px;
cursor: pointer;
}
.modal .checkbox-row input {
margin-right: 6px;
vertical-align: middle;
}
.merge-warning {
color: var(--danger);
font-size: 12px;
margin-top: 4px;
min-height: 16px;
}
.summary {
padding: 10px 14px;
border-bottom: 1px solid var(--border);
font-size: 12px;
color: var(--text-muted);
}
</style>
</head>
<body>
<div class="toolbar">
<h1>🏷️ Tag Manager</h1>
<input class="search" id="search" placeholder="Filter (or paste tag list)…" autocomplete="off" />
<select class="sort" id="sort">
<option value="name-asc">Name A→Z</option>
<option value="name-desc">Name Z→A</option>
<option value="count-asc">Count ↑ (orphans first)</option>
<option value="count-desc">Count ↓ (busiest first)</option>
</select>
<button id="refresh" title="Reload tags">↻ Refresh</button>
</div>
 
<div class="actionbar">
<label><input type="checkbox" id="check-all" /> Select visible</label>
<button class="ghost" id="select-singletons">Select 1-note</button>
<button class="ghost" id="select-orphans">Select orphans</button>
<button class="ghost" id="toggle-orphans-only" aria-pressed="false">Orphans only</button>
<button class="ghost" id="clear-selection">Clear</button>
<span class="spacer" style="flex:1"></span>
<span class="count"><strong id="sel-count">0</strong> selected (<strong id="sel-notes">0</strong> notes)</span>
<button id="merge-tags" disabled>Merge…</button>
<button class="primary" id="backup-and-delete" disabled>Backup &amp; Delete</button>
<button class="danger" id="delete-only" disabled>Delete</button>
</div>
 
<div id="summary" class="summary"></div>
 
<div class="scroll" id="list">
<div class="loading">Loading tags…</div>
</div>
 
<div class="modal-bg" id="modal">
<div class="modal" role="dialog" aria-labelledby="modal-title">
<h2 id="modal-title">Confirm</h2>
<p id="modal-body"></p>
<div class="tags-preview" id="modal-preview"></div>
<div class="actions">
<button id="modal-cancel">Cancel</button>
<button class="danger" id="modal-confirm">Confirm</button>
</div>
</div>
</div>
 
<div class="modal-bg" id="merge-modal">
<div class="modal" role="dialog" aria-labelledby="merge-modal-title">
<h2 id="merge-modal-title">Merge tags</h2>
<p>All notes carrying any of the source tags below will be tagged with the target. Source tags will then be removed.</p>
<div class="form-label">Source tags:</div>
<div class="tags-preview" id="merge-sources"></div>
<label class="form-label" for="merge-target-input">Target tag (existing or new):</label>
<input type="text" id="merge-target-input" list="merge-target-options" autocomplete="off" placeholder="e.g. food/recipes" spellcheck="false" />
<datalist id="merge-target-options"></datalist>
<div id="merge-target-warning" class="merge-warning"></div>
<label class="checkbox-row"><input type="checkbox" id="merge-backup-checkbox" checked /> Create backup note before merging</label>
<div class="actions">
<button id="merge-cancel">Cancel</button>
<button class="primary" id="merge-confirm">Merge</button>
</div>
</div>
</div>
 
<div class="toast" id="toast"></div>
 
<script>
(function () {
"use strict";
 
// ---------- State ----------
const state = {
tags: [], // [{ name, count, notes: [{uuid,name,updated}] }]
filter: "",
sort: "name-asc",
selected: new Set(), // tag names
expanded: new Set(), // tag names
orphansOnly: false,
busy: false,
};
 
// ---------- DOM refs ----------
const $ = (id) => document.getElementById(id);
const list = $("list");
const summary = $("summary");
const search = $("search");
const sort = $("sort");
const checkAll = $("check-all");
const selCount = $("sel-count");
const selNotes = $("sel-notes");
const btnBackupDelete = $("backup-and-delete");
const btnDelete = $("delete-only");
const btnMerge = $("merge-tags");
const btnRefresh = $("refresh");
const btnSelectSingletons = $("select-singletons");
const btnSelectOrphans = $("select-orphans");
const btnToggleOrphansOnly = $("toggle-orphans-only");
const btnClearSelection = $("clear-selection");
const modal = $("modal");
const modalTitle = $("modal-title");
const modalBody = $("modal-body");
const modalPreview = $("modal-preview");
const modalCancel = $("modal-cancel");
const modalConfirm = $("modal-confirm");
const mergeModal = $("merge-modal");
const mergeSources = $("merge-sources");
const mergeTargetInput = $("merge-target-input");
const mergeTargetOptions = $("merge-target-options");
const mergeTargetWarning = $("merge-target-warning");
const mergeBackupCheckbox = $("merge-backup-checkbox");
const mergeCancel = $("merge-cancel");
const mergeConfirm = $("merge-confirm");
const toast = $("toast");
 
// ---------- RPC ----------
async function call(action, ...args) {
if (typeof window.callAmplenotePlugin !== "function") {
throw new Error("Not running inside Amplenote (callAmplenotePlugin missing)");
}
const res = await window.callAmplenotePlugin(action, ...args);
if (res && typeof res === "object" && res.error) {
throw new Error(res.error);
}
return res;
}
 
// ---------- Toast ----------
let toastTimer = null;
function showToast(msg, kind = "") {
toast.textContent = msg;
toast.className = "toast show " + kind;
if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(() => { toast.className = "toast"; }, 3500);
}
 
// ---------- Confirm dialog ----------
function confirmModal({ title, body, previewItems, confirmLabel = "Confirm", danger = true }) {
return new Promise((resolve) => {
modalTitle.textContent = title;
modalBody.textContent = body;
modalPreview.innerHTML = "";
if (previewItems && previewItems.length) {
modalPreview.style.display = "";
for (const item of previewItems) {
const div = document.createElement("div");
div.className = "item";
div.textContent = item;
modalPreview.appendChild(div);
}
} else {
modalPreview.style.display = "none";
}
modalConfirm.textContent = confirmLabel;
modalConfirm.className = danger ? "danger" : "primary";
modal.classList.add("show");
 
const cleanup = (val) => {
modal.classList.remove("show");
modalCancel.onclick = null;
modalConfirm.onclick = null;
resolve(val);
};
modalCancel.onclick = () => cleanup(false);
modalConfirm.onclick = () => cleanup(true);
});
}
 
// ---------- Filtering & sorting ----------
 
// Mirrors plugin._parsePastedTagSearch — keep in sync if changing.
function parsePastedTagSearch(text) {
if (typeof text !== "string") return text;
if (!/\\bmore_horiz\\b/.test(text)) return text;
return text
.replace(/\\bmore_horiz\\b/g, ",")
.split(",")
.map((s) => s.trim())
.filter(Boolean)
.join(", ");
}
 
function parseFilterTerms(filter) {
return filter
.split(",")
.map((s) => s.trim().toLowerCase())
.filter(Boolean);
}
 
function visibleTags() {
const raw = state.filter.trim();
let rows = state.tags;
if (state.orphansOnly) rows = rows.filter((t) => t.count === 0);
if (raw) {
const terms = parseFilterTerms(raw);
if (terms.length > 1) {
// CSV multi-tag filter: exact match (case-insensitive) against any term
const want = new Set(terms);
rows = rows.filter((t) => want.has(t.name.toLowerCase()));
} else if (terms.length === 1) {
// Single-term: substring match (existing behavior)
rows = rows.filter((t) => t.name.toLowerCase().includes(terms[0]));
}
}
const cmp = {
"count-asc": (a, b) => a.count - b.count || a.name.localeCompare(b.name),
"count-desc": (a, b) => b.count - a.count || a.name.localeCompare(b.name),
"name-asc": (a, b) => a.name.localeCompare(b.name),
"name-desc": (a, b) => b.name.localeCompare(a.name),
}[state.sort];
return [...rows].sort(cmp);
}
 
// ---------- Render ----------
function fmtDate(value) {
if (!value) return "—";
let d;
if (value instanceof Date) {
d = value;
} else if (typeof value === "string") {
d = new Date(value);
} else if (typeof value === "number" && Number.isFinite(value)) {
// Heuristic: any number < 1e12 is seconds-since-epoch (max ~year 33658),
// anything >= 1e12 is milliseconds-since-epoch.
d = new Date(value < 1e12 ? value * 1000 : value);
} else {
return "—";
}
if (isNaN(d.getTime())) return "—";
try {
return d.toISOString().slice(0, 10);
} catch (_) {
return "—";
}
}
 
function escapeHTML(str) {
return String(str).replace(/[&<>"']/g, (c) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"
})[c]);
}
 
function render() {
if (state.busy && state.tags.length === 0) {
list.innerHTML = '<div class="loading">Loading tags…</div>';
summary.textContent = "";
return;
}
 
const visible = visibleTags();
const totalNotes = visible.reduce((n, t) => n + t.count, 0);
const orphanTotal = state.tags.filter((t) => t.count === 0).length;
const terms = state.filter.trim() ? parseFilterTerms(state.filter) : [];
const csvSuffix = terms.length > 1
? \` · filter: \${terms.length} terms, \${visible.length} matched\`
: "";
summary.textContent =
\`\${state.tags.length} tags total · \${orphanTotal} orphan\${orphanTotal === 1 ? "" : "s"} · \${visible.length} shown · \${totalNotes} note references\${csvSuffix}\`;
 
if (visible.length === 0) {
list.innerHTML =
'<div class="empty">' +
(state.orphansOnly && orphanTotal === 0
? "No orphan tags — every tag is applied to at least one note. 🎉"
: "No tags match your filter.") +
'</div>';
updateSelectionUI();
return;
}
 
const frag = document.createDocumentFragment();
for (const t of visible) {
const selected = state.selected.has(t.name);
const expanded = state.expanded.has(t.name);
const badgeClass = t.count === 0 ? "orphan" : t.count === 1 ? "one" : "";
 
const row = document.createElement("div");
row.className = "row" + (selected ? " selected" : "");
row.dataset.tag = t.name;
row.innerHTML = \`
<input type="checkbox" data-act="select" \${selected ? "checked" : ""} aria-label="Select \${escapeHTML(t.name)}" />
<span class="tag-name">\${escapeHTML(t.name)}</span>
<span class="badge \${badgeClass}">\${t.count}</span>
<button class="expand" data-act="expand" aria-expanded="\${expanded}" title="\${expanded ? "Hide notes" : "Show notes"}">\${expanded ? "▾" : "▸"}</button>
\`;
frag.appendChild(row);
 
if (expanded) {
const nl = document.createElement("div");
nl.className = "notes-list";
if (t.notes.length === 0) {
nl.innerHTML = '<div class="note"><em>No notes attached to this tag (orphan tag).</em></div>';
} else {
nl.innerHTML = t.notes.map((n) => \`
<div class="note">
<a data-uuid="\${escapeHTML(n.uuid)}">\${escapeHTML(n.name)}</a>
<span class="date">\${fmtDate(n.updated)}</span>
</div>
\`).join("");
}
frag.appendChild(nl);
}
}
 
list.innerHTML = "";
list.appendChild(frag);
updateSelectionUI();
}
 
function updateSelectionUI() {
const selectedTags = state.tags.filter((t) => state.selected.has(t.name));
const noteCount = selectedTags.reduce((n, t) => n + t.count, 0);
selCount.textContent = selectedTags.length;
selNotes.textContent = noteCount;
const has = selectedTags.length > 0;
btnBackupDelete.disabled = !has || state.busy;
btnDelete.disabled = !has || state.busy;
btnMerge.disabled = !has || state.busy;
 
// Sync the "select visible" checkbox
const visibleNames = visibleTags().map((t) => t.name);
const allChecked =
visibleNames.length > 0 && visibleNames.every((n) => state.selected.has(n));
const someChecked = visibleNames.some((n) => state.selected.has(n));
checkAll.checked = allChecked;
checkAll.indeterminate = !allChecked && someChecked;
}
 
// ---------- Event handlers ----------
list.addEventListener("click", (e) => {
// Note link → open the note
const noteLink = e.target.closest("a[data-uuid]");
if (noteLink) {
e.preventDefault();
const uuid = noteLink.dataset.uuid;
call("openNote", uuid).catch((err) => showToast(err.message, "error"));
return;
}
 
const row = e.target.closest(".row");
if (!row) return;
const tag = row.dataset.tag;
 
// Expand button → toggle note list without affecting selection
if (e.target.closest('[data-act="expand"]')) {
if (state.expanded.has(tag)) state.expanded.delete(tag);
else state.expanded.add(tag);
render();
return;
}
 
// Anywhere else on the row (including the tag name and count badge):
// toggle selection. If the checkbox itself was clicked, the browser
// already flipped its state — sync from it. Otherwise flip both in JS.
const checkbox = row.querySelector('[data-act="select"]');
const clickedCheckbox = e.target === checkbox;
const nowSelected = clickedCheckbox
? checkbox.checked
: !state.selected.has(tag);
if (!clickedCheckbox) checkbox.checked = nowSelected;
if (nowSelected) state.selected.add(tag);
else state.selected.delete(tag);
row.classList.toggle("selected", nowSelected);
updateSelectionUI();
});
 
search.addEventListener("paste", (e) => {
const cd = e.clipboardData || window.clipboardData;
if (!cd) return;
const text = cd.getData("text");
if (!text || !/\bmore_horiz\b/.test(text)) return;
e.preventDefault();
const cleaned = parsePastedTagSearch(text);
// Replace selection / insert at cursor
const start = search.selectionStart || 0;
const end = search.selectionEnd || 0;
const before = search.value.slice(0, start);
const after = search.value.slice(end);
search.value = before + cleaned + after;
const newCursor = before.length + cleaned.length;
search.setSelectionRange(newCursor, newCursor);
state.filter = search.value;
render();
});
 
search.addEventListener("input", (e) => {
state.filter = e.target.value;
render();
});
 
sort.addEventListener("change", (e) => {
state.sort = e.target.value;
render();
});
 
checkAll.addEventListener("change", (e) => {
const visible = visibleTags();
if (e.target.checked) {
for (const t of visible) state.selected.add(t.name);
} else {
for (const t of visible) state.selected.delete(t.name);
}
render();
});
 
btnSelectSingletons.addEventListener("click", () => {
state.selected.clear();
for (const t of state.tags) {
if (t.count === 1) state.selected.add(t.name);
}
render();
});
 
btnSelectOrphans.addEventListener("click", () => {
state.selected.clear();
for (const t of state.tags) {
if (t.count === 0) state.selected.add(t.name);
}
render();
});
 
btnToggleOrphansOnly.addEventListener("click", () => {
state.orphansOnly = !state.orphansOnly;
btnToggleOrphansOnly.setAttribute("aria-pressed", String(state.orphansOnly));
render();
});
 
btnClearSelection.addEventListener("click", () => {
state.selected.clear();
render();
});
 
btnRefresh.addEventListener("click", () => loadTags());
 
btnDelete.addEventListener("click", () => runDelete(false));
btnBackupDelete.addEventListener("click", () => runDelete(true));
btnMerge.addEventListener("click", () => runMerge());
 
// Close modal on escape
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
if (modal.classList.contains("show")) modalCancel.click();
if (mergeModal.classList.contains("show")) mergeCancel.click();
}
});
 
// ---------- Operations ----------
async function loadTags() {
state.busy = true;
render();
try {
const tags = await call("loadTags");
state.tags = tags || [];
// Keep selections only if the tag still exists
const names = new Set(state.tags.map((t) => t.name));
for (const s of [...state.selected]) if (!names.has(s)) state.selected.delete(s);
for (const s of [...state.expanded]) if (!names.has(s)) state.expanded.delete(s);
} catch (err) {
showToast("Failed to load tags: " + err.message, "error");
} finally {
state.busy = false;
render();
}
}
 
async function runDelete(backup) {
const selectedTags = state.tags.filter((t) => state.selected.has(t.name));
if (selectedTags.length === 0) return;
const noteCount = selectedTags.reduce((n, t) => n + t.count, 0);
 
// Skip confirmation when nothing is at risk (all selected tags have 0 notes)
const needsConfirm = noteCount > 0;
 
if (needsConfirm) {
const ok = await confirmModal({
title: backup ? "Backup and delete tags?" : "Delete tags?",
body: backup
? \`A backup note will be created first, then \${selectedTags.length} tag(s) will be removed from \${noteCount} note(s). The notes themselves will not be deleted.\`
: \`This will remove \${selectedTags.length} tag(s) from \${noteCount} note(s). The notes themselves will not be deleted. Continue without a backup?\`,
previewItems: selectedTags.slice(0, 30).map((t) => \`\${t.name} (\${t.count})\`)
.concat(selectedTags.length > 30 ? [\`…and \${selectedTags.length - 30} more\`] : []),
confirmLabel: backup ? "Backup & delete" : "Delete without backup",
danger: true,
});
if (!ok) return;
}
 
state.busy = true;
updateSelectionUI();
 
try {
if (backup && noteCount > 0) {
const backupResult = await call("exportBackup", selectedTags);
showToast(\`Backup saved: \${backupResult.name}\`, "success");
}
const res = await call("deleteTags", selectedTags.map((t) => t.name));
const parts = [
\`Removed \${res.removedTags} of \${selectedTags.length} tag\${selectedTags.length === 1 ? "" : "s"}\`,
\`from \${res.removedFromNotes} note\${res.removedFromNotes === 1 ? "" : "s"}\`,
];
if (res.failures && res.failures.length) {
parts.push(\`(\${res.failures.length} note removal\${res.failures.length === 1 ? "" : "s"} failed)\`);
}
if (res.lingering && res.lingering.length) {
parts.push(\`— \${res.lingering.length} tag\${res.lingering.length === 1 ? "" : "s"} still in registry: \${res.lingering.slice(0, 3).join(", ")}\${res.lingering.length > 3 ? "…" : ""}\`);
}
const kind = (res.lingering && res.lingering.length) || (res.failures && res.failures.length)
? "error"
: "success";
showToast(parts.join(" "), kind);
state.selected.clear();
await loadTags();
} catch (err) {
showToast("Operation failed: " + err.message, "error");
} finally {
state.busy = false;
updateSelectionUI();
}
}
 
// ---------- Boot ----------
loadTags();
 
// ---------- Merge dialog (defined after handlers) ----------
 
function openMergeDialog(selectedTags) {
return new Promise((resolve) => {
mergeSources.innerHTML = "";
const previewItems = selectedTags.slice(0, 30).map((t) => \`\${t.name} (\${t.count})\`);
if (selectedTags.length > 30) previewItems.push(\`…and \${selectedTags.length - 30} more\`);
for (const item of previewItems) {
const div = document.createElement("div");
div.className = "item";
div.textContent = item;
mergeSources.appendChild(div);
}
 
// Datalist of all known tag names (offer existing ones as autocomplete,
// but the user can also type a brand new name).
mergeTargetOptions.innerHTML = "";
const selectedSet = new Set(selectedTags.map((t) => t.name));
for (const t of state.tags) {
// Skip sources from the suggestion list to avoid accidental no-op merges
if (selectedSet.has(t.name)) continue;
const opt = document.createElement("option");
opt.value = t.name;
mergeTargetOptions.appendChild(opt);
}
 
mergeTargetInput.value = "";
mergeTargetInput.classList.remove("invalid");
mergeTargetWarning.textContent = "";
mergeBackupCheckbox.checked = true;
 
mergeModal.classList.add("show");
setTimeout(() => mergeTargetInput.focus(), 50);
 
const validate = () => {
const t = mergeTargetInput.value.trim();
if (!t) {
mergeTargetWarning.textContent = "Target tag name is required.";
return null;
}
if (/[\\n\\r\\t]/.test(t) || t.startsWith("/") || t.endsWith("/")) {
mergeTargetWarning.textContent = "Invalid tag name.";
return null;
}
if (selectedSet.has(t) && selectedSet.size === 1) {
mergeTargetWarning.textContent = "Target is the only source — nothing would change.";
return null;
}
// Warning (not an error) when target overlaps with one of the sources —
// that's a valid "merge into existing" case.
if (selectedSet.has(t)) {
mergeTargetWarning.textContent = \`Note: "\${t}" is one of the selected sources — it will keep its notes and absorb the others.\`;
} else if (state.tags.some((x) => x.name === t)) {
mergeTargetWarning.textContent = \`Existing tag — its current notes will be preserved.\`;
} else {
mergeTargetWarning.textContent = \`New tag will be created.\`;
}
return t;
};
 
mergeTargetInput.oninput = () => {
mergeTargetInput.classList.remove("invalid");
validate();
};
 
const cleanup = (result) => {
mergeModal.classList.remove("show");
mergeCancel.onclick = null;
mergeConfirm.onclick = null;
mergeTargetInput.onkeydown = null;
mergeTargetInput.oninput = null;
resolve(result);
};
 
mergeCancel.onclick = () => cleanup(null);
mergeConfirm.onclick = () => {
const t = validate();
if (!t) {
mergeTargetInput.classList.add("invalid");
mergeTargetInput.focus();
return;
}
cleanup({ target: t, backup: mergeBackupCheckbox.checked });
};
mergeTargetInput.onkeydown = (e) => {
if (e.key === "Enter") {
e.preventDefault();
mergeConfirm.click();
}
};
});
}
 
async function runMerge() {
const selectedTags = state.tags.filter((t) => state.selected.has(t.name));
if (selectedTags.length === 0) return;
 
const choice = await openMergeDialog(selectedTags);
if (!choice) return;
 
const { target, backup } = choice;
 
state.busy = true;
updateSelectionUI();
 
try {
if (backup) {
const backupResult = await call("exportMergeBackup", selectedTags, target);
showToast(\`Backup saved: \${backupResult.name}\`, "success");
}
const res = await call("mergeTags", selectedTags.map((t) => t.name), target);
const parts = [
\`Merged \${res.sourcesProcessed} tag\${res.sourcesProcessed === 1 ? "" : "s"}\`,
\`→ "\${res.target}"\`,
\`(\${res.affectedNotes} note\${res.affectedNotes === 1 ? "" : "s"} affected)\`,
];
if (res.failures && res.failures.length) {
parts.push(\`— \${res.failures.length} operation\${res.failures.length === 1 ? "" : "s"} failed\`);
}
if (res.lingering && res.lingering.length) {
parts.push(\`— \${res.lingering.length} source\${res.lingering.length === 1 ? "" : "s"} still in registry: \${res.lingering.slice(0, 3).join(", ")}\${res.lingering.length > 3 ? "…" : ""}\`);
}
const kind =
(res.lingering && res.lingering.length) ||
(res.failures && res.failures.length)
? "error"
: "success";
showToast(parts.join(" "), kind);
state.selected.clear();
await loadTags();
} catch (err) {
showToast("Merge failed: " + err.message, "error");
} finally {
state.busy = false;
updateSelectionUI();
}
}
})();
</script>
</body>
</html>`;
},
}