/** * 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;i3 && 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 = $('
').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($('
').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(''+this._selected.size+' selecionados · '+this._visible.length+' 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 = $('
').css({ display:'flex',alignItems:'center',gap:'10px',padding:isWidget?'6px 8px':'10px 14px',borderBottom:'1px solid var(--main-border-color)',flexShrink:0 }); const $btnScan = $('