mirror of
https://github.com/ricolandia/TriliumNext-Toolkit.git
synced 2026-05-17 10:55:40 -04:00
v2.0: rewrite as JS Frontend — deletion now persists
This commit is contained in:
parent
ac0e49d004
commit
7ddcdc933c
3 changed files with 411 additions and 62 deletions
Binary file not shown.
|
|
@ -1,107 +1,103 @@
|
||||||
# Attribute GC — TriliumNext Maintenance Tool
|
# 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
|
## Features
|
||||||
|
|
||||||
- **Full scan** — Reads all notes via `froca` (TriliumNext) or `api.runOnBackend` (classic Trilium). Classifies every label and relation by usage count and health.
|
- **Full scan** — SQL-backed via `api.sql.getRows`. Classifies every label and relation by usage count and health.
|
||||||
- **Broken relation detection** — Finds relations pointing to deleted or missing target notes.
|
- **Broken relation detection** — Relations pointing to deleted or missing target notes.
|
||||||
- **Rare attribute flagging** — Highlights attributes used ≤2 times, plus temp/draft/test patterns.
|
- **Rare attribute flagging** — 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`).
|
- **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.
|
- **Dry run mode** — Toggle on to preview what would be deleted without making changes.
|
||||||
- **Batch selection** — Auto-select all problematic attributes with one click.
|
- **Batch deletion** — Auto-select all problematic attributes, then delete in one click.
|
||||||
- **Scoped CSS** — All styles are prefixed under `#attrgc-root` — nothing leaks into the Trilium UI.
|
- **Protected attributes** — 50+ system/internal labels and relations are locked from deletion.
|
||||||
|
- **Theme-aware** — Uses Trilium CSS variables, matches your theme automatically.
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
1. In TriliumNext, import the .zip file (the plugin) into any root note of your choice (e.g., Tools, Plugins, or Add-ons).
|
1. Create a **JS Frontend** code note in TriliumNext
|
||||||
|
2. Paste the full content of `attribute-gc.js`
|
||||||
2. The import contains two notes: a Render note and an HTML code note. Simply click on the Render note to view the panel.
|
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
|
## Usage
|
||||||
|
|
||||||
1. Click **Escanear** — the tool reads all notes and shows a dashboard of attribute stats.
|
1. Click **Escanear** — stats dashboard appears with attribute counts
|
||||||
2. Use the filter tabs (**Quebrados**, **Raros**, **Sem uso**, **Sistema**) and search bar to narrow down.
|
2. Filter by status (**Quebrados**, **Raros**, **Sem uso**, **Sistema**, **Saudáveis**) or search by name
|
||||||
3. Click **Auto-selecionar problemáticos** to check all non-system problematic attributes, or tick individual checkboxes.
|
3. Click **Auto-selecionar** to check all non-system problematic attributes, or tick individual rows
|
||||||
4. **Toggle Dry Run OFF** (the yellow banner disappears).
|
4. **Toggle Dry Run OFF** (yellow banner disappears)
|
||||||
5. Click **Executar limpeza** → confirm. The tool attempts to delete and runs a re-scan.
|
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
|
## Compatibility
|
||||||
|
|
||||||
| Environment | Scan | Delete | Notes |
|
| Environment | Scan | Delete |
|
||||||
|---|---|---|---|
|
|---|---|---|
|
||||||
| **Classic Trilium** | `api.sql.getRows` | `note.removeLabel` / `note.removeRelation` | Full end-to-end. |
|
| **TriliumNext** | `api.sql.getRows` | `getNotesWithLabel` / `removeLabel` |
|
||||||
| **TriliumNext** | `froca.notes` | `attr.update({ isDeleted: true })` | Detects everything. Delete works in UI but **not persisted**. |
|
| **Classic Trilium** | `api.sql.getRows` | `getNotesWithLabel` / `removeLabel` |
|
||||||
| **Browser (demo)** | Mock data | Simulated (noop) | For testing outside Trilium. |
|
|
||||||
|
|
||||||
## ⚠ 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.
|
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.
|
||||||
- `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.
|
|
||||||
|
|
||||||
### 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.
|
- `api.runOnBackend()` — execute backend code
|
||||||
- `attr.update()` and `note.update()` are synchronous and local-only (no backend RPC).
|
- `api.sql.getRows()` — SQL queries for fast aggregation
|
||||||
- `note.executeScript()` only works on script-type notes, not on arbitrary text/HTML notes.
|
- `api.getNotesWithLabel()` / `api.getNotesWithRelation()` — find notes by attribute
|
||||||
- `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).
|
- `note.removeLabel()` / `note.removeRelation()` — delete through the entity lifecycle
|
||||||
- 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.
|
|
||||||
|
|
||||||
### Workaround ideas (for contributors)
|
No hacks, no workarounds. The same pattern used by every other Trilium plugin.
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
### Scan (TriliumNext — froca path)
|
### Scan
|
||||||
|
|
||||||
```
|
```js
|
||||||
glob.getActiveContextNote() → froca → froca.notes / froca.attributes
|
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)
|
```js
|
||||||
|
api.runOnBackend((names, types) => {
|
||||||
If `api.runOnBackend` is detected, the tool runs SQL aggregation queries directly for maximum performance.
|
for (const name of names) {
|
||||||
|
const notes = api.getNotesWithLabel(name); // or getNotesWithRelation
|
||||||
### Delete (Classic Trilium)
|
for (const note of notes) {
|
||||||
|
note.removeLabel(name); // goes through Becca → persisted to DB
|
||||||
```
|
}
|
||||||
api.runOnBackend → note.removeLabel() / note.removeRelation()
|
}
|
||||||
|
}, [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
|
## 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`
|
`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
|
## License
|
||||||
|
|
||||||
MIT
|
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
|
### Images
|
||||||
|
|
|
||||||
353
Attribute-Garbage-Collector/attribute-gc.js
Normal file
353
Attribute-Garbage-Collector/attribute-gc.js
Normal 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);
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue