TriliumNext-Toolkit/Attribute-Garbage-Collector/attribute-gc.js

354 lines
24 KiB
JavaScript
Raw Normal View History

/**
* 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);
}