v2.0: rewrite as JS Frontend — deletion now persists

This commit is contained in:
Ricardo Graca 2026-05-15 23:02:28 -03:00
parent ac0e49d004
commit 7ddcdc933c
3 changed files with 411 additions and 62 deletions

View file

@ -1,107 +1,103 @@
# Attribute GC — TriliumNext Maintenance Tool
A garbage collector for TriliumNext attributes. Scans all notes to find broken relations, unused labels, rare attributes, and near-duplicate names. Lets you preview them in a dashboard — with batch delete on classic Trilium.
Scans all notes in your TriliumNext database for broken relations, unused labels, rare attributes, and near-duplicate names. Preview and batch-delete them — **deletions persist**.
> **⚠ Status (TriliumNext)**: Detection works fully — all 83 notes, 66+ attribute groups scanned via `froca`. Deletion in the UI succeeds (cache is updated, re-scan confirms), but **changes are lost on page reload** because TriliumNext's frontend froca cache doesn't sync writes back to the server in the Render Note sandbox. Classic Trilium (`api.runOnBackend`) path works end-to-end. See [Persistence issue](#-persistence-issue-triliumnext).
> **v2.0** — Rewritten as a JS Frontend note with full `api.runOnBackend()` access. Scan and delete both work end-to-end on TriliumNext. The previous HTML Render Note approach (v1.x) could only detect — deletion did not persist. See [Why this works](#why-this-works).
## Features
- **Full scan**Reads all notes via `froca` (TriliumNext) or `api.runOnBackend` (classic Trilium). Classifies every label and relation by usage count and health.
- **Broken relation detection**Finds relations pointing to deleted or missing target notes.
- **Rare attribute flagging**Highlights attributes used ≤2 times, plus temp/draft/test patterns.
- **Semantic duplicate finder** Uses Levenshtein distance to surface near-identical names (`pipeline` vs `pipelne`, `autor` vs `autores`).
- **Full scan**SQL-backed via `api.sql.getRows`. Classifies every label and relation by usage count and health.
- **Broken relation detection**Relations pointing to deleted or missing target notes.
- **Rare attribute flagging**Attributes used ≤2 times, plus temp/draft/test patterns.
- **Semantic duplicate finder** — Levenshtein distance to surface near-identical names (`pipeline` vs `pipelne`, `autor` vs `autores`).
- **Dry run mode** — Toggle on to preview what would be deleted without making changes.
- **Batch selection** — Auto-select all problematic attributes with one click.
- **Scoped CSS** — All styles are prefixed under `#attrgc-root` — nothing leaks into the Trilium UI.
- **Batch deletion** — Auto-select all problematic attributes, then delete in one click.
- **Protected attributes** — 50+ system/internal labels and relations are locked from deletion.
- **Theme-aware** — Uses Trilium CSS variables, matches your theme automatically.
## Installation
1. In TriliumNext, import the .zip file (the plugin) into any root note of your choice (e.g., Tools, Plugins, or Add-ons).
2. The import contains two notes: a Render note and an HTML code note. Simply click on the Render note to view the panel.
1. Create a **JS Frontend** code note in TriliumNext
2. Paste the full content of `attribute-gc.js`
3. Choose your mode:
- **Right panel widget**: add label `#widget`, reload (Ctrl+R), open any note
- **Full-page Render Note**: create a Render Note, add relation `~renderNote` → JS Frontend, open the Render Note
## Usage
1. Click **Escanear**the tool reads all notes and shows a dashboard of attribute stats.
2. Use the filter tabs (**Quebrados**, **Raros**, **Sem uso**, **Sistema**) and search bar to narrow down.
3. Click **Auto-selecionar problemáticos** to check all non-system problematic attributes, or tick individual checkboxes.
4. **Toggle Dry Run OFF** (the yellow banner disappears).
5. Click **Executar limpeza** → confirm. The tool attempts to delete and runs a re-scan.
1. Click **Escanear**stats dashboard appears with attribute counts
2. Filter by status (**Quebrados**, **Raros**, **Sem uso**, **Sistema**, **Saudáveis**) or search by name
3. Click **Auto-selecionar** to check all non-system problematic attributes, or tick individual rows
4. **Toggle Dry Run OFF** (yellow banner disappears)
5. Click **Executar limpeza** → confirm — attributes are permanently removed
> In classic Trilium, deletion persists to the database. In TriliumNext, the re-scan will show the attributes gone from the cache, but they reappear on reload (see below).
The individual **remover** button on each row works the same way (skips the batch dialog).
## Compatibility
| Environment | Scan | Delete | Notes |
|---|---|---|---|
| **Classic Trilium** | `api.sql.getRows` | `note.removeLabel` / `note.removeRelation` | Full end-to-end. |
| **TriliumNext** | `froca.notes` | `attr.update({ isDeleted: true })` | Detects everything. Delete works in UI but **not persisted**. |
| **Browser (demo)** | Mock data | Simulated (noop) | For testing outside Trilium. |
| Environment | Scan | Delete |
|---|---|---|
| **TriliumNext** | `api.sql.getRows` | `getNotesWithLabel` / `removeLabel` |
| **Classic Trilium** | `api.sql.getRows` | `getNotesWithLabel` / `removeLabel` |
## ⚠ Persistence issue (TriliumNext)
Both use the same backend path — `api.runOnBackend()` with the standard entity API. Becca stays in sync, changes persist.
### What works
## Why this works
- The froca path scans all 83 notes and 66+ attribute groups correctly.
- `note.getOwnedAttributes(type, name)` returns proper `FAttribute` objects.
- `attr.update({ isDeleted: true })` marks attributes deleted in the frontend cache.
- The re-scan confirms the deletions (groups count drops).
- `_clw.confirmSaveRelations()` exists and returns a Promise (talks to backend), but is scope-limited to the relations panel and doesn't propagate general attribute deletions.
v1.x used an **HTML Render Note** with inline `<script>` tags. TriliumNext sandboxes HTML notes — `api` is not available, `fetch()` calls return 401, froca mutations are local-only, and all deletion attempts failed to persist.
### What doesn't
v2.0 is a **JS Frontend note** — the standard Trilium extension format (same as Canvas Linker, Word Count, Task Planner). It has direct access to:
- On page reload, the froca cache is rebuilt from the server — deleted attributes reappear.
- `attr.update()` and `note.update()` are synchronous and local-only (no backend RPC).
- `note.executeScript()` only works on script-type notes, not on arbitrary text/HTML notes.
- `api.runOnBackend` is not exposed in the Render Note sandbox (it's available to JS Frontend notes via `frontend_script_api-*.js`, but not to `<script>` tags in HTML Render Notes).
- The REST API (`/api/notes`, `/api/tree`) returns 401 — `glob.getHeaders()` returns an empty object, and session cookies + CSRF token don't authenticate from the sandbox.
- `api.runOnBackend()` — execute backend code
- `api.sql.getRows()` — SQL queries for fast aggregation
- `api.getNotesWithLabel()` / `api.getNotesWithRelation()` — find notes by attribute
- `note.removeLabel()` / `note.removeRelation()` — delete through the entity lifecycle
### Workaround ideas (for contributors)
1. **JS Backend note approach**: Create a temporary JS Backend note, put the deletion script in it, execute via `note.executeScript()`, then delete the temp note.
2. **Proxy note mutations**: Find how the TriliumNext UI (e.g. relation map) communicates attribute changes to the server and replicate that protocol.
3. **Module import**: If dynamic `import()` is available from Render Notes, try importing `frontend_script_api-*.js` to access `runOnBackend` directly.
Pull requests welcome for any of these approaches.
No hacks, no workarounds. The same pattern used by every other Trilium plugin.
## How it works
### Scan (TriliumNext — froca path)
### Scan
```
glob.getActiveContextNote() → froca → froca.notes / froca.attributes
```js
api.runOnBackend(() => {
// SQL aggregation — one query, instant results
api.sql.getRows(`SELECT name, type, COUNT(*) ... GROUP BY name, type`);
// Broken relation detection
api.sql.getRows(`SELECT ... LEFT JOIN ... WHERE target IS NULL`);
});
```
The Render Note accesses TriliumNext's frontend object cache (`froca`) via the active context note. All notes and their attributes are already in memory — no backend calls needed. Broken relations are detected by building a `Set` of all valid `noteId`s and checking whether each relation's target exists.
### Delete
### Scan (Classic Trilium — SQL path)
If `api.runOnBackend` is detected, the tool runs SQL aggregation queries directly for maximum performance.
### Delete (Classic Trilium)
```
api.runOnBackend → note.removeLabel() / note.removeRelation()
```js
api.runOnBackend((names, types) => {
for (const name of names) {
const notes = api.getNotesWithLabel(name); // or getNotesWithRelation
for (const note of notes) {
note.removeLabel(name); // goes through Becca → persisted to DB
}
}
}, [names, types]);
```
Uses the high-level entity API so Becca stays in sync.
### Context detection
The same JS file auto-detects whether it's loaded as a widget (`#widget` label) or a render note dependency (`~renderNote` relation), adjusting layout and sizes accordingly.
## Protected attributes
These system/internal attributes are locked and cannot be deleted:
`template`, `workspace`, `iconClass`, `cssClass`, `run`, `runOnInstance`, `runAtStartup`, `shareAlias`, `shareHiddenFromTree`, `archived`, `pinned`, `bookmarked`, `weight`, `color`, `renderNote`, `child`, `runOnNoteCreation`, `noteType`, `mime`, `shareCss`, `shareJs`, `shareRaw`, `shareDisallowRobotIndexing`, `keyboardShortcut`, `label`, `relation`, `promoted`, `multiplicity`, `labelDefinition`, `relationDefinition`, `toc`, `readOnly`, `excludeFromExport`, `appCss`, `appTheme`, `sorted`, `sortDirection`, `sortFoldersFirst`, `top`, `hide`, `hidePromotedAttributes`, `disableVersioning`, `calendarRoot`, `dateNote`, `datePattern`, `inbox`, `sqlConsole`, `searchHome`, `hoistedNote`, `similarNotes`, `versioningLimit`, `mapRootNoteId`, `system`, `root`
---
🌐 **Idioma / Language**: A interface está em português brasileiro (PT-BR). Para traduzir, abra o arquivo `attribute-gc.js` no Trilium e substitua as strings de texto.
## License
MIT
## 🌐 Language / Idioma
**Note on language:** Since I am from Brazil, the interface and text within this tool are currently in **Brazilian Portuguese (PT-BR)**.
However, you can easily translate them to English or your preferred language by simply opening the code files inside Trilium and replacing the text strings.
---
### Images

View file

@ -0,0 +1,353 @@
/**
* Attribute GC TriliumNext
* Scans all notes for broken/rare/unused labels & relations.
*
* Two modes:
* A) Render Note: JS Frontend (no #widget) ~renderNote Render Note (full page)
* B) Widget: JS Frontend + #widget label right panel
*/
const PROT = new Set([
'template','workspace','iconClass','cssClass','run','runOnInstance','runAtStartup',
'shareAlias','shareHiddenFromTree','archived','pinned','bookmarked','weight','color',
'renderNote','child','runOnNoteCreation','noteType','mime','shareCss','shareJs',
'shareRaw','shareDisallowRobotIndexing','keyboardShortcut','label','relation',
'promoted','multiplicity','labelDefinition','relationDefinition','toc','readOnly',
'excludeFromExport','appCss','appTheme','sorted','sortDirection','sortFoldersFirst',
'top','hide','hidePromotedAttributes','disableVersioning','calendarRoot','dateNote',
'datePattern','inbox','sqlConsole','searchHome','hoistedNote','similarNotes',
'versioningLimit','mapRootNoteId','system','root',
]);
const TEMP_RE = /^(temp|tmp|teste?|test|rascunho|draft|bak|backup|lixo|trash|xxx|zzz|abc|foo|bar|qwe|asd|asdf)[_\-0-9]*$/i;
function classify(name, type, count, bc) {
if (!name) return 'ok';
if (PROT.has(name) || PROT.has(name.toLowerCase())) return 'sys';
if (type === 'relation' && bc > 0 && bc >= count) return 'broken';
if (count === 0) return 'unused';
if (count <= 2 || (count <= 5 && TEMP_RE.test(name))) return 'rare';
return 'ok';
}
function lev(a, b) {
const m = a.length, n = b.length;
const dp = Array.from({length: m+1}, (_,i) => Array.from({length: n+1}, (_,j) => i===0 ? j : j===0 ? i : 0));
for (let i=1;i<=m;i++) for (let j=1;j<=n;j++)
dp[i][j] = a[i-1]===b[j-1] ? dp[i-1][j-1] : 1+Math.min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]);
return dp[m][n];
}
function findDupes(attrs) {
const names = attrs.filter(a => a.status !== 'sys').map(a => a.name);
const groups = [], visited = new Set();
for (let i=0;i<names.length;i++) {
if (visited.has(names[i])) continue;
const g = [names[i]], na = names[i].toLowerCase().replace(/[_\-s]/g,'');
for (let j=i+1;j<names.length;j++) {
if (visited.has(names[j])) continue;
const nb = names[j].toLowerCase().replace(/[_\-s]/g,'');
const d = lev(na, nb);
if (d<=2 && Math.max(na.length,nb.length)>3 && d < Math.max(na.length,nb.length)*0.45) {
g.push(names[j]); visited.add(names[j]);
}
}
if (g.length>1) { visited.add(names[i]); groups.push(g); }
if (groups.length>=15) break;
}
return groups;
}
/* ── Detect context: widget vs render note ────────── */
const IS_RENDER = typeof $container !== 'undefined' && $container;
if (IS_RENDER) {
/*
MODE A: Render Note (full page via ~renderNote)
*/
(async function () {
const $root = $container;
$root.css({ display:'flex',flexDirection:'column',height:'100%',fontFamily:'var(--detail-font-family,"Segoe UI",sans-serif)',fontSize:'12px',color:'var(--main-text-color)',background:'var(--main-background-color)',overflow:'hidden' });
const state = { _allAttrs:[],_visible:[],_selected:new Set(),_sortKey:'count',_sortDir:1,_curFilter:'all',_dryRun:true,_scanning:false };
buildUI($root, state);
})();
} else {
/*
MODE B: Right-panel widget (#widget)
*/
class AttributeGCWidget extends api.RightPanelWidget {
get position() { return 2; }
get parentWidget() { return 'right-pane'; }
get widgetTitle() { return 'Attribute GC'; }
isEnabled() { return super.isEnabled() && !!this.note; }
_allAttrs = []; _visible = []; _selected = new Set();
_sortKey = 'count'; _sortDir = 1; _curFilter = 'all';
_dryRun = true; _scanning = false;
doRenderBody() {
this.$body.empty().css({ padding:0,overflow:'hidden',display:'flex',flexDirection:'column',height:'100%' });
const $root = $('<div>').css({ display:'flex',flexDirection:'column',height:'100%',fontSize:'11.5px',fontFamily:'var(--detail-font-family,"Segoe UI",sans-serif)',color:'var(--main-text-color)',background:'var(--main-background-color)' });
buildUI($root, this);
this.$body.append($root);
}
_log(msg, t) { this._widgetLog.show().append($('<div>').text('['+new Date().toLocaleTimeString()+'] '+msg).css({color:{ok:'#68a87c',warn:'#c9984a',err:'#d97070',info:'var(--muted-text-color)'}[t||'info'],marginBottom:'1px'})); this._widgetLog.scrollTop(this._widgetLog[0].scrollHeight); }
_updateFooter() {
this._$selInfo.html('<strong>'+this._selected.size+'</strong> selecionados · <strong>'+this._visible.length+'</strong> visíveis');
this._$execBtn.prop('disabled', this._selected.size === 0);
}
}
module.exports = new AttributeGCWidget();
}
/*
SHARED UI BUILDER
*/
function buildUI($root, ctx) {
const isWidget = !IS_RENDER;
const fs = isWidget ? '11.5px' : '13px';
const $toolbar = $('<div>').css({ display:'flex',alignItems:'center',gap:'10px',padding:isWidget?'6px 8px':'10px 14px',borderBottom:'1px solid var(--main-border-color)',flexShrink:0 });
const $btnScan = $('<button>').text('▶ Escanear').css({ padding:isWidget?'4px 10px':'6px 16px',cursor:'pointer',borderRadius:'4px',fontSize:isWidget?'11px':fs,background:'var(--accented-background-color)',color:'var(--main-text-color)',border:'1px solid var(--main-border-color)',fontWeight:500 });
const $lblDry = $('<label>').css({ display:'flex',alignItems:'center',gap:'5px',cursor:'pointer',fontSize:isWidget?'11px':fs,color:'var(--muted-text-color)' });
const $chkDry = $('<input type="checkbox">').prop('checked', true);
$lblDry.append($chkDry, ' Dry Run');
$toolbar.append($btnScan, $lblDry, $('<span>').css({flex:1}));
const $banner = $('<div>').css({ padding:isWidget?'4px 8px':'6px 14px',margin:isWidget?'4px 6px':'6px 14px',borderRadius:'4px',background:'rgba(201,152,74,.08)',border:'1px solid rgba(201,152,74,.2)',color:'#c9984a',fontSize:isWidget?'10px':'11px',flexShrink:0 }).html('<strong>Dry Run ativo.</strong> Desative para executar limpeza real.');
const $stats = $('<div>').css({ display:'none',gridTemplateColumns:'repeat(5,1fr)',gap:isWidget?'3px':'6px',padding:isWidget?'4px 6px':'10px 14px',flexShrink:0 });
['total','broken','rare','unused','dupes'].forEach((k,i) => {
const cols = ['#4a9','#d97070','#c9984a','#9b7ec8','#6b95c4'];
const labels = ['Atributos','Quebrados','Raros','Sem uso','Similares'];
const $s = $('<div>').css({ textAlign:'center',padding:isWidget?'5px 2px':'8px 4px',borderRadius:'4px',background:'var(--accented-background-color)',borderTop:'2px solid '+cols[i] });
$s.append($('<div>').css({fontSize:isWidget?'18px':'24px',fontWeight:600}).attr('id','ags-'+k), $('<div>').css({fontSize:isWidget?'8px':'9px',color:'var(--muted-text-color)',textTransform:'uppercase',letterSpacing:'.04em'}).text(labels[i]));
$stats.append($s);
});
const $fbar = $('<div>').css({ display:'none',gap:isWidget?'3px':'5px',padding:isWidget?'0 6px 4px':'0 14px 6px',flexWrap:'wrap',flexShrink:0 });
['all','broken','rare','unused','sys','ok'].forEach(f => {
const lb = {all:'Todos',broken:'Quebrados',rare:'Raros',unused:'Sem uso',sys:'Sistema',ok:'Saudáveis'};
const $b = $('<button>').text(lb[f]).css({ padding:isWidget?'2px 7px':'3px 10px',borderRadius:isWidget?'10px':'14px',border:'1px solid var(--main-border-color)',background:f==='all'?'var(--accented-background-color)':'transparent',color:f==='all'?'var(--main-text-color)':'var(--muted-text-color)',cursor:'pointer',fontSize:isWidget?'9.5px':'10.5px' });
$b.on('click', () => { ctx._curFilter = f; applyUIFilters(); });
$fbar.append($b);
});
const $fsearch = $('<input type="text" placeholder="Buscar…">').css({ marginLeft:'auto',padding:isWidget?'2px 6px':'3px 8px',border:'1px solid var(--main-border-color)',borderRadius:'4px',background:'var(--accented-background-color)',color:'var(--main-text-color)',fontSize:isWidget?'10px':'11px',width:isWidget?'100px':'140px',outline:'none' }).on('input', applyUIFilters);
$fbar.append($fsearch);
const $tblWrap = $('<div>').css({ display:'none',overflow:'auto',flex:1,padding:isWidget?'0 6px':'0 14px' });
const $table = $('<table>').css({ width:'100%',borderCollapse:'collapse' });
const $thead = $('<thead>').css({ position:'sticky',top:0,zIndex:1,background:'var(--main-background-color)' });
$thead.append($('<tr>').append(
$('<th style="width:20px">').append($('<input type="checkbox" id="agc-selall">')),
$('<th>').text('Nome').css({cursor:'pointer',fontSize:isWidget?'9px':'10px',color:'var(--muted-text-color)',textTransform:'uppercase',letterSpacing:'.06em',padding:isWidget?'5px 6px':'6px 8px'}).on('click',()=>{ctx._sortKey='name';applyUIFilters();}),
$('<th>').text('Tipo').css({fontSize:isWidget?'9px':'10px',color:'var(--muted-text-color)',textTransform:'uppercase',letterSpacing:'.06em',padding:isWidget?'5px 6px':'6px 8px'}),
$('<th style="text-align:right">').text('Usos').css({cursor:'pointer',fontSize:isWidget?'9px':'10px',color:'var(--muted-text-color)',textTransform:'uppercase',letterSpacing:'.06em',padding:isWidget?'5px 6px':'6px 8px'}).on('click',()=>{ctx._sortKey='count';applyUIFilters();}),
$('<th>').text('Status').css({fontSize:isWidget?'9px':'10px',color:'var(--muted-text-color)',textTransform:'uppercase',letterSpacing:'.06em',padding:isWidget?'5px 6px':'6px 8px'}),
$('<th style="text-align:right">').text('Ações').css({fontSize:isWidget?'9px':'10px',color:'var(--muted-text-color)',textTransform:'uppercase',letterSpacing:'.06em',padding:isWidget?'5px 6px':'6px 8px'}),
));
$table.append($thead, $('<tbody id="agc-tbody">'));
$tblWrap.append($table);
const $footer = $('<div>').css({ display:'none',alignItems:'center',gap:isWidget?'6px':'10px',padding:isWidget?'5px 6px':'8px 14px',borderTop:'1px solid var(--main-border-color)',flexShrink:0 });
const $selInfo = $('<span>').css({ fontSize:isWidget?'10px':'11px',color:'var(--muted-text-color)',flex:1 });
const $execBtn = $('<button>').text('Executar limpeza').css({ padding:isWidget?'3px 10px':'5px 14px',cursor:'pointer',borderRadius:'4px',fontSize:isWidget?'10px':'12px',fontWeight:500,background:'rgba(217,112,112,.1)',color:'#d97070',border:'1px solid rgba(217,112,112,.25)' }).prop('disabled',true);
$footer.append(
$selInfo,
$('<button>').text('Auto-selecionar').css({ padding:isWidget?'3px 8px':'5px 12px',cursor:'pointer',borderRadius:'4px',fontSize:isWidget?'10px':'12px',background:'var(--accented-background-color)',color:'var(--muted-text-color)',border:'1px solid var(--main-border-color)' }).on('click', selectSuggested),
$execBtn,
);
const $dupes = $('<div>').css({ display:'none',padding:isWidget?'0 6px 6px':'0 14px 8px',flexShrink:0 });
const $log = $('<div>').css({ display:'none',maxHeight:isWidget?'90px':'120px',overflowY:'auto',padding:isWidget?'3px 6px':'4px 14px',borderTop:'1px solid var(--main-border-color)',fontSize:isWidget?'9.5px':'10px',color:'var(--muted-text-color)',flexShrink:0 });
$root.append($toolbar, $banner, $stats, $fbar, $tblWrap, $footer, $dupes, $log);
// Store refs on context
ctx._$btnScan = $btnScan; ctx._$chkDry = $chkDry; ctx._$banner = $banner;
ctx._$stats = $stats; ctx._$fbar = $fbar; ctx._$fsearch = $fsearch;
ctx._$tblWrap = $tblWrap; ctx._$selInfo = $selInfo; ctx._$execBtn = $execBtn;
ctx._$dupes = $dupes; ctx._$log = $log;
ctx._$footer = $footer;
// Events
$btnScan.on('click', () => runScan());
$chkDry.on('change', function() { ctx._dryRun = this.checked; $banner.toggle(ctx._dryRun); renderTable(); });
$('#agc-selall').on('change', function() {
$('#agc-tbody input[type="checkbox"]').each((_,cb) => {
const k = $(cb).data('key');
this.checked ? ctx._selected.add(k) : ctx._selected.delete(k);
cb.checked = this.checked;
});
updateFooter();
});
/* ── LOG ──────────────────────────────────── */
function log(msg, t) {
t = t || 'info';
const colors = { ok:'#68a87c', warn:'#c9984a', err:'#d97070', info:'var(--muted-text-color)' };
$log.show().append($('<div>').text('['+new Date().toLocaleTimeString()+'] '+msg).css({ color:colors[t], marginBottom:'1px' }));
$log.scrollTop($log[0].scrollHeight);
}
/* ── SCAN ─────────────────────────────────── */
async function runScan() {
if (ctx._scanning) return;
ctx._scanning = true;
$btnScan.text('…').prop('disabled', true);
$log.empty().show();
log('Escaneando…');
try {
const data = await api.runOnBackend(() => {
const groups = api.sql.getRows(`SELECT a.name, a.type, COUNT(*) AS count FROM attributes a INNER JOIN notes n ON a.noteId = n.noteId WHERE a.isDeleted = 0 AND n.isDeleted = 0 GROUP BY a.name, a.type ORDER BY count ASC, a.name`);
const broken = api.sql.getRows(`SELECT a.name, COUNT(*) AS bc FROM attributes a INNER JOIN notes n ON a.noteId = n.noteId LEFT JOIN notes t ON a.value = t.noteId WHERE a.type = 'relation' AND a.isDeleted = 0 AND n.isDeleted = 0 AND (t.noteId IS NULL OR t.isDeleted = 1) GROUP BY a.name`);
const bMap = {}; for (const r of broken) bMap[r.name] = r.bc;
return { groups, bMap };
});
ctx._allAttrs = data.groups.map(r => ({
name: r.name, type: r.type, count: r.count,
bc: data.bMap[r.name] || 0,
status: classify(r.name, r.type, r.count, data.bMap[r.name] || 0),
prot: PROT.has(r.name) || PROT.has(r.name.toLowerCase()),
}));
const b = ctx._allAttrs.filter(a => a.status === 'broken').length;
const r = ctx._allAttrs.filter(a => a.status === 'rare').length;
const u = ctx._allAttrs.filter(a => a.status === 'unused').length;
const d = findDupes(ctx._allAttrs);
$('#ags-total').text(ctx._allAttrs.length); $('#ags-broken').text(b);
$('#ags-rare').text(r); $('#ags-unused').text(u); $('#ags-dupes').text(d.length);
$stats.show(); $fbar.show(); $tblWrap.show(); $footer.show();
$dupes.empty();
if (d.length) {
$dupes.show().append($('<div>').css({ fontSize:isWidget?'9px':'10px',color:'var(--muted-text-color)',fontWeight:600,marginBottom:'3px' }).text('Grupos Similares'));
d.forEach(g => {
const $dg = $('<div>').css({ display:'flex',gap:'3px',flexWrap:'wrap',marginBottom:'2px' });
g.forEach((n,i) => { $dg.append($('<span>').text(n).css({ background:'var(--accented-background-color)',padding:'1px 4px',borderRadius:'2px',fontSize:isWidget?'9px':'10px',border:'1px solid var(--main-border-color)' })); if (i<g.length-1) $dg.append($('<span>').text('≈').css({color:'var(--muted-text-color)',fontSize:isWidget?'9px':'10px'})); });
$dupes.append($dg);
});
} else $dupes.hide();
ctx._curFilter = 'all'; ctx._sortKey = 'count'; ctx._sortDir = 1; applyUIFilters();
log('Scan: '+ctx._allAttrs.length+' grupos.', 'ok');
if (b+r+u>0) log(b+' quebrados, '+r+' raros, '+u+' sem uso.', 'warn');
else log('Base saudável!', 'ok');
} catch(err) { log('Erro: '+err.message, 'err'); }
finally { ctx._scanning = false; $btnScan.text('▶ Escanear').prop('disabled', false); }
}
/* ── FILTERS ──────────────────────────────── */
function applyUIFilters() {
const srch = ($fsearch.val() || '').toLowerCase();
ctx._visible = ctx._allAttrs.filter(a => {
if (srch && !a.name.toLowerCase().includes(srch)) return false;
return ctx._curFilter === 'all' ? true : a.status === ctx._curFilter;
});
ctx._visible.sort((a,b)=>{
let va=a[ctx._sortKey],vb=b[ctx._sortKey];
if(typeof va==='string'){va=va.toLowerCase();vb=vb.toLowerCase();}
return ctx._sortDir*(va<vb?-1:va>vb?1:0);
});
$fbar.find('button').each(function(){
const t=$(this).text().toLowerCase();
const m=ctx._curFilter==='all'?t==='todos':t.includes(ctx._curFilter==='ok'?'saud':ctx._curFilter);
$(this).css({background:m?'var(--accented-background-color)':'transparent',color:m?'var(--main-text-color)':'var(--muted-text-color)'});
});
renderTable(); updateFooter();
}
/* ── TABLE ────────────────────────────────── */
function renderTable() {
if (!ctx._visible || !ctx._visible.length) return;
const $tbody = $('#agc-tbody').empty();
const pills = { sys:'sistema',broken:'quebrado',rare:'raro',unused:'sem uso',ok:'saudável' };
const cntC = { unused:'#9b7ec8',rare:'#c9984a' };
const pColors = { broken:['rgba(217,112,112,.1)','#d97070','rgba(217,112,112,.25)'],rare:['rgba(201,152,74,.1)','#c9984a','rgba(201,152,74,.25)'],unused:['rgba(155,126,200,.1)','#9b7ec8','rgba(155,126,200,.25)'],sys:['rgba(107,149,196,.1)','#6b95c4','rgba(107,149,196,.25)'],ok:['rgba(104,168,124,.1)','#68a87c','rgba(104,168,124,.25)'] };
ctx._visible.forEach(a => {
const key = a.type+'::'+a.name, chk = ctx._selected.has(key);
const $tr = $('<tr>').css({borderBottom:'1px solid var(--main-border-color)'}).hover(function(){$(this).css({background:'var(--accented-background-color)'});},function(){$(this).css({background:'transparent'});});
$tr.append(
$('<td>').append(a.prot?'':$('<input type="checkbox">').data('key',key).prop('checked',chk).on('change',function(){this.checked?ctx._selected.add(key):ctx._selected.delete(key);updateFooter();})),
$('<td>').css({padding:isWidget?'4px 6px':'5px 8px'}).append($('<span>').css({display:'inline-block',fontSize:isWidget?'8px':'9px',padding:'1px 3px',borderRadius:'2px',marginRight:'4px',background:a.type==='label'?'rgba(107,149,196,.15)':'rgba(155,126,200,.15)',color:a.type==='label'?'#6b95c4':'#9b7ec8'}).text(a.type==='label'?'# label':'~ rel'),a.name,a.bc>0?$('<span>').css({color:'#d97070',fontSize:isWidget?'8px':'9px'}).text(' ('+a.bc+' quebradas)'):''),
$('<td>').text(a.type).css({color:'var(--muted-text-color)',padding:isWidget?'4px 6px':'5px 8px'}),
$('<td>').text(a.count).css({textAlign:'right',fontWeight:600,color:cntC[a.status]||'#68a87c',padding:isWidget?'4px 6px':'5px 8px'}),
$('<td>').css({padding:isWidget?'4px 6px':'5px 8px'}).append($('<span>').text(pills[a.status]).css({fontSize:isWidget?'8px':'9px',padding:'1px 5px',borderRadius:'8px',background:(pColors[a.status]||pColors.ok)[0],color:(pColors[a.status]||pColors.ok)[1],border:'1px solid '+(pColors[a.status]||pColors.ok)[2]})),
);
const $act = $('<td>').css({textAlign:'right',padding:isWidget?'4px 6px':'5px 8px'});
if (a.prot) {
$act.append($('<button>').text('protegido').css({padding:'1px 4px',fontSize:isWidget?'8px':'9px',opacity:.4,background:'none',border:'1px solid var(--main-border-color)',borderRadius:'2px',color:'var(--muted-text-color)'}).prop('disabled',true));
} else {
$act.append(
$('<button>').text(ctx._dryRun?'preview':'remover').css({padding:isWidget?'1px 5px':'2px 7px',cursor:'pointer',fontSize:isWidget?'8px':'9px',fontWeight:500,background:'rgba(217,112,112,.1)',color:'#d97070',border:'1px solid rgba(217,112,112,.25)',borderRadius:'2px',marginRight:'3px'}).on('click',()=>delSingle(a.name,a.type)),
$('<button>').text('manter').css({padding:isWidget?'1px 5px':'2px 7px',cursor:'pointer',fontSize:isWidget?'8px':'9px',background:'transparent',color:'var(--muted-text-color)',border:'1px solid var(--main-border-color)',borderRadius:'2px'}).on('click',()=>{ctx._selected.delete(key);renderTable();updateFooter();}),
);
}
$tr.append($act); $tbody.append($tr);
});
}
function updateFooter() {
$selInfo.html('<strong>'+ctx._selected.size+'</strong> selecionados · <strong>'+ctx._visible.length+'</strong> visíveis');
$execBtn.prop('disabled', ctx._selected.size === 0);
}
function selectSuggested() {
ctx._allAttrs.forEach(a=>{if(!a.prot&&['broken','unused','rare'].includes(a.status))ctx._selected.add(a.type+'::'+a.name);});
renderTable(); updateFooter();
}
/* ── DELETE ───────────────────────────────── */
async function delSingle(name, type) {
if (ctx._dryRun) { log('[DRY RUN] Removeria: '+type+'::'+name,'warn'); return; }
await doDelete([[name, type]]);
}
async function doDelete(pairs) {
$execBtn.prop('disabled',true).text('…');
const names = pairs.map(p=>p[0]), types = pairs.map(p=>p[1]);
log('Removendo '+pairs.length+' atributo(s)…');
try {
const result = await api.runOnBackend((names, types) => {
const bLog = []; let count = 0;
for (let i=0;i<names.length;i++) {
const name=names[i],type=types[i];
try {
if (type==='relation') {
const notes=api.getNotesWithRelation(name);
bLog.push('rel "'+name+'": '+notes.length+' notas');
for(const n of notes){if(!n||n.isDeleted)continue;try{n.removeRelation(name);count++;}catch(e){bLog.push(' ✗: '+e.message);}}
} else {
const notes=api.getNotesWithLabel(name);
bLog.push('label "'+name+'": '+notes.length+' notas');
for(const n of notes){if(!n||n.isDeleted)continue;try{n.removeLabel(name);count++;}catch(e){bLog.push(' ✗: '+e.message);}}
}
} catch(e){bLog.push('✗ "'+name+'": '+e.message);}
}
return {count,log:bLog};
}, [names, types]);
log(result.count+' instância(s) removidas.','ok');
(result.log||[]).forEach(l=>log(' '+l));
ctx._selected.clear();
await runScan();
} catch(err) { log('Erro: '+err.message,'err'); }
finally { $execBtn.prop('disabled',ctx._selected.size===0).text('Executar limpeza'); }
}
// Expose execCleanup for widget mode compatibility
ctx._execCleanup = function() {
if (!ctx._selected.size) return;
if (ctx._dryRun) { log('[DRY RUN] '+ctx._selected.size+' atributo(s) seriam removidos.','warn'); return; }
const pairs = [...ctx._selected].map(k=>{const i=k.indexOf('::');return[k.slice(i+2),k.slice(0,i)];});
if (!confirm('Remover PERMANENTEMENTE '+pairs.length+' atributo(s)?\n\nEsta operação modifica o banco de dados.')) return;
doDelete(pairs);
};
$execBtn.on('click', ctx._execCleanup);
}