// ==UserScript== // @name TRMNL Editor Backups // @namespace https://github.com/ExcuseMi/trmnl-userscripts // @version 1.3.4 // @description Automatically snapshots the plugin archive before and after every save. View per-file diffs and restore any backup. // @author ExcuseMi // @match https://trmnl.com/plugin_settings/* // @match https://trmnl.com/account* // @icon https://raw.githubusercontent.com/ExcuseMi/trmnl-userscripts/refs/heads/main/images/trmnl.svg // @require https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js // @downloadURL https://raw.githubusercontent.com/ExcuseMi/trmnl-userscripts/main/editor-backups.user.js // @updateURL https://raw.githubusercontent.com/ExcuseMi/trmnl-userscripts/main/editor-backups.user.js // @grant none // @run-at document-body // ==/UserScript== //Deprecated: Use Github Sync over this one. //Leaving this one here as an example if you'd want to do the same. //No further support on this one. (function () { 'use strict'; const LOG_PREFIX = '[TRMNL Backups]'; const log = (...a) => console.log(LOG_PREFIX, ...a); const warn = (...a) => console.warn(LOG_PREFIX, ...a); const PANEL_ID = 'trmnl-backups-panel'; const OVERLAY_ID = 'trmnl-backups-overlay'; const BTN_ID = 'trmnl-backups-btn'; const ACCT_BTN_ID = 'trmnl-backups-account-btn'; const STYLE_ID = 'trmnl-backups-style'; const API_KEY_KEY = 'trmnl_backup_api_key'; const CONFIG_KEY = 'trmnl_backup_config'; const PENDING_KEY = 'trmnl_pending_backup'; // sessionStorage — survives full-page reload const DEFAULT_CFG = { maxBackups: 15, maxAgeHours: 0 }; // Preferred display order for archive files const FILE_ORDER = [ 'shared.liquid', 'full.liquid', 'half_horizontal.liquid', 'half_vertical.liquid', 'quadrant.liquid', 'settings.yml', 'transform.js', ]; // --------------------------------------------------------------------------- // URL helpers // --------------------------------------------------------------------------- function getPluginId() { const m = window.location.pathname.match(/\/plugin_settings\/(\d+)/); return m ? m[1] : null; } function isAccountPage() { return window.location.pathname.startsWith('/account'); } // --------------------------------------------------------------------------- // Config // --------------------------------------------------------------------------- function getConfig() { try { const s = JSON.parse(localStorage.getItem(CONFIG_KEY) || '{}'); return { maxBackups: Math.max(1, Math.min(100, parseInt(s.maxBackups) || DEFAULT_CFG.maxBackups)), maxAgeHours: Math.max(0, parseInt(s.maxAgeHours) ?? DEFAULT_CFG.maxAgeHours), }; } catch { return { ...DEFAULT_CFG }; } } function saveConfig(cfg) { localStorage.setItem(CONFIG_KEY, JSON.stringify(cfg)); } // --------------------------------------------------------------------------- // LocalStorage: backup entries per plugin // --------------------------------------------------------------------------- function bkKey(pluginId) { return `trmnl_backups_${pluginId}`; } function getBackups(pluginId) { try { let backups = JSON.parse(localStorage.getItem(bkKey(pluginId)) || '[]'); const { maxAgeHours } = getConfig(); if (maxAgeHours > 0) { const cutoff = Date.now() - maxAgeHours * 3_600_000; backups = backups.filter(e => e.timestamp >= cutoff); } return backups; } catch { return []; } } function saveBackups(pluginId, backups) { try { localStorage.setItem(bkKey(pluginId), JSON.stringify(backups)); } catch (e) { warn('localStorage error:', e.message); } } function addEntry(pluginId, beforeFiles) { const { maxBackups } = getConfig(); const entry = { id: Date.now(), timestamp: Date.now(), before: beforeFiles, after: null }; const backups = getBackups(pluginId); backups.unshift(entry); if (backups.length > maxBackups) backups.length = maxBackups; saveBackups(pluginId, backups); refreshButtonCount(); return entry.id; } function setEntryAfter(pluginId, entryId, afterFiles) { const backups = getBackups(pluginId); const idx = backups.findIndex(e => e.id === entryId); if (idx === -1) return; const entry = backups[idx]; entry.after = afterFiles; // Drop the entry entirely if nothing changed — no point keeping it if (changedFileNames(entry).length === 0) { log('No changes detected, discarding entry', entryId); backups.splice(idx, 1); } saveBackups(pluginId, backups); refreshButtonCount(); } // --------------------------------------------------------------------------- // SessionStorage: pending "after" snapshot (survives full-page reload in tab) // --------------------------------------------------------------------------- function setPending(pluginId, entryId) { sessionStorage.setItem(PENDING_KEY, JSON.stringify({ pluginId, entryId, ts: Date.now() })); } function clearPending() { sessionStorage.removeItem(PENDING_KEY); } function getPending() { try { const p = JSON.parse(sessionStorage.getItem(PENDING_KEY) || 'null'); if (p && Date.now() - p.ts < 30_000) return p; } catch {} return null; } // --------------------------------------------------------------------------- // Archive: fetch and build via JSZip // --------------------------------------------------------------------------- async function fetchArchive(pluginId) { const res = await fetch(`https://trmnl.com/api/plugin_settings/${pluginId}/archive`); if (!res.ok) throw new Error(`Archive fetch failed: ${res.status}`); const zip = await JSZip.loadAsync(await res.arrayBuffer()); const files = {}; await Promise.all( Object.entries(zip.files) .filter(([, entry]) => !entry.dir) .map(async ([name, entry]) => { files[name] = await entry.async('text'); }) ); return files; } async function buildZip(files) { const zip = new JSZip(); for (const [name, content] of Object.entries(files)) { if (content) zip.file(name, content); } const blob = await zip.generateAsync({ type: 'blob', compression: 'DEFLATE' }); return new Blob([blob], { type: 'application/zip' }); } // --------------------------------------------------------------------------- // File list helpers (dynamic — includes transform.js, settings.yml, etc.) // --------------------------------------------------------------------------- function getEntryFiles(entry) { const keys = new Set([ ...Object.keys(entry.before), ...(entry.after ? Object.keys(entry.after) : []), ]); return [...keys].sort((a, b) => { const ai = FILE_ORDER.indexOf(a), bi = FILE_ORDER.indexOf(b); if (ai === -1 && bi === -1) return a.localeCompare(b); if (ai === -1) return 1; if (bi === -1) return -1; return ai - bi; }); } function changedFileNames(entry) { if (!entry.after) return []; return getEntryFiles(entry).filter(f => (entry.before[f] ?? '') !== (entry.after[f] ?? '')); } // --------------------------------------------------------------------------- // Form-submit interception // --------------------------------------------------------------------------- function findSaveForm() { const settingsForm = document.querySelector('form[id^="edit_plugin_setting"]'); if (settingsForm) return settingsForm; const saveBtn = document.querySelector('[data-markup-target="enabledSaveButton"]'); return saveBtn?.closest('form') ?? null; } function attachFormInterceptor(pluginId) { const form = findSaveForm(); if (!form || form.dataset.backupAttached) return; form.dataset.backupAttached = 'true'; let skipNext = false; form.addEventListener('submit', async (e) => { if (skipNext) { skipNext = false; return; } e.preventDefault(); const submitter = e.submitter ?? null; try { const beforeFiles = await fetchArchive(pluginId); const entryId = addEntry(pluginId, beforeFiles); setPending(pluginId, entryId); log('Backup snapshot created, entryId:', entryId); } catch (err) { warn('Pre-save snapshot failed:', err.message); /* never block the save */ } skipNext = true; form.requestSubmit(submitter); }); } async function checkPendingBackup() { const p = getPending(); if (!p) return; clearPending(); try { const afterFiles = await fetchArchive(p.pluginId); setEntryAfter(p.pluginId, p.entryId, afterFiles); log('After-snapshot stored for entry:', p.entryId); } catch (err) { warn('After-snapshot failed:', err.message); } } // --------------------------------------------------------------------------- // Diff: LCS-based, line level // --------------------------------------------------------------------------- function diffLines(a, b) { const aL = a ? a.replace(/\r/g, '').split('\n') : []; const bL = b ? b.replace(/\r/g, '').split('\n') : []; const m = Math.min(aL.length, 800); const n = Math.min(bL.length, 800); const dp = Array.from({ length: m + 1 }, () => new Int16Array(n + 1)); for (let i = m - 1; i >= 0; i--) for (let j = n - 1; j >= 0; j--) dp[i][j] = aL[i] === bL[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]); const out = []; let i = 0, j = 0; while (i < m || j < n) { if (i < m && j < n && aL[i] === bL[j]) { out.push({ t: 'eq', l: aL[i] }); i++; j++; } // Prefer del before add (conventional order, enables intra-line pairing) else if (i < m && (j >= n || dp[i + 1][j] >= dp[i][j + 1])) { out.push({ t: 'del', l: aL[i] }); i++; } else { out.push({ t: 'add', l: bL[j] }); j++; } } return out; } function buildDiffEl(oldText, newText) { if (oldText === newText) { const p = mk('p', 'text-xs text-gray-400 dark:text-gray-500 italic py-1'); p.textContent = 'No changes in this file.'; return p; } const CONTEXT = 3; const diff = diffLines(oldText, newText); const show = new Uint8Array(diff.length); for (let i = 0; i < diff.length; i++) if (diff[i].t !== 'eq') for (let k = Math.max(0, i - CONTEXT); k <= Math.min(diff.length - 1, i + CONTEXT); k++) show[k] = 1; const pre = mk('pre', 'bk-diff'); let lastShown = -1; const rendered = new Uint8Array(diff.length); diff.forEach((d, idx) => { if (!show[idx] || rendered[idx]) return; if (lastShown !== -1 && idx > lastShown + 1) pre.appendChild(mk('div', 'bk-diff-skip', '···')); if (d.t === 'del') { // Collect the full consecutive run of dels then adds — pair them in order let k = idx; const dels = [], adds = []; while (k < diff.length && diff[k].t === 'del' && !rendered[k]) dels.push(k++); while (k < diff.length && diff[k].t === 'add' && !rendered[k]) adds.push(k++); dels.forEach(i => rendered[i] = 1); adds.forEach(i => rendered[i] = 1); const pairs = Math.min(dels.length, adds.length); for (let p = 0; p < pairs; p++) { const ol = diff[dels[p]].l, nl = diff[adds[p]].l; const dr = mk('div', 'bk-diff-del'); appendIntraLine(dr, '− ', ol, nl, 'bk-hl-del'); pre.appendChild(dr); const ar = mk('div', 'bk-diff-add'); appendIntraLine(ar, '+ ', nl, ol, 'bk-hl-add'); pre.appendChild(ar); } for (let p = pairs; p < dels.length; p++) { const row = mk('div', 'bk-diff-del'); row.textContent = '− ' + diff[dels[p]].l; pre.appendChild(row); } for (let p = pairs; p < adds.length; p++) { const row = mk('div', 'bk-diff-add'); row.textContent = '+ ' + diff[adds[p]].l; pre.appendChild(row); } lastShown = k - 1; } else { const row = mk('div', `bk-diff-${d.t}`); row.textContent = (d.t === 'add' ? '+ ' : ' ') + d.l; pre.appendChild(row); lastShown = idx; } }); function appendIntraLine(row, marker, line, other, hlCls) { let p = 0; while (p < line.length && p < other.length && line[p] === other[p]) p++; let lEnd = line.length, oEnd = other.length; while (lEnd > p && oEnd > p && line[lEnd - 1] === other[oEnd - 1]) { lEnd--; oEnd--; } row.appendChild(document.createTextNode(marker + line.slice(0, p))); if (lEnd > p) row.appendChild(mk('mark', hlCls, line.slice(p, lEnd))); if (lEnd < line.length) row.appendChild(document.createTextNode(line.slice(lEnd))); } // Start expanded so diffs are immediately readable let expanded = true; pre.style.maxHeight = 'none'; pre.style.overflowY = 'visible'; const expandBtn = document.createElement('button'); expandBtn.type = 'button'; expandBtn.className = 'bk-expand-btn w-full flex items-center justify-center gap-1.5 py-1.5 text-xs font-medium ' + 'text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 ' + 'border-t border-gray-200 dark:border-gray-700 cursor-pointer ' + 'bg-gray-50 dark:bg-gray-800 transition-colors'; const chevronDown = ``; const chevronUp = ``; expandBtn.innerHTML = `${chevronUp}Collapse`; expandBtn.addEventListener('click', (e) => { e.stopPropagation(); expanded = !expanded; pre.style.maxHeight = expanded ? 'none' : '12rem'; pre.style.overflowY = expanded ? 'visible' : 'auto'; expandBtn.innerHTML = expanded ? `${chevronUp}Collapse` : `${chevronDown}Show full diff`; }); const wrap = mk('div', 'rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden mt-1'); wrap.append(pre, expandBtn); return wrap; } // --------------------------------------------------------------------------- // DOM helpers // --------------------------------------------------------------------------- function mk(tag, cls, text) { const el = document.createElement(tag); if (cls) el.className = cls; if (text !== undefined) el.textContent = text; return el; } function fmtDate(ts) { return new Date(ts).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit', }); } // --------------------------------------------------------------------------- // Panel: restore // --------------------------------------------------------------------------- async function doRestore(pluginId, files, btn) { const apiKey = localStorage.getItem(API_KEY_KEY) || ''; if (!apiKey) { alert('Enter your API key in the Backups panel first.'); return; } const prev = btn.textContent; btn.textContent = 'Uploading…'; btn.disabled = true; try { const blob = await buildZip(files); const fd = new FormData(); fd.append('file', blob, 'archive.zip'); const res = await fetch(`https://trmnl.com/api/plugin_settings/${pluginId}/archive`, { method: 'POST', headers: { Authorization: `Bearer ${apiKey}` }, body: fd, }); if (!res.ok) { const body = await res.text().catch(() => ''); throw new Error(`HTTP ${res.status}${body ? ' — ' + body.slice(0, 300) : ''}`); } log('Restore successful, redirecting…'); btn.textContent = 'Done — redirecting…'; setTimeout(() => { if (window.Turbo?.cache) window.Turbo.cache.clear(); window.location.href = `https://trmnl.com/plugin_settings/${pluginId}/edit`; }, 800); } catch (e) { btn.textContent = prev; btn.disabled = false; alert(`Restore failed: ${e.message}`); } } // --------------------------------------------------------------------------- // Panel: single backup entry // --------------------------------------------------------------------------- function buildEntryEl(entry, pluginId) { const changed = changedFileNames(entry); const allFiles = getEntryFiles(entry); const wrap = mk('div', 'border-b border-gray-100 dark:border-gray-700/60'); // Header row const header = mk('div', 'flex items-center gap-2 px-4 py-2.5 cursor-pointer select-none ' + 'hover:bg-gray-50 dark:hover:bg-gray-800/60 transition-colors' ); const arrow = mk('span', 'text-gray-400 dark:text-gray-600 text-xs w-3 flex-shrink-0', '▸'); const ts = mk('span', 'text-xs flex-1 font-medium text-gray-700 dark:text-gray-300', fmtDate(entry.timestamp)); let badgeCls, badgeTxt; if (entry.imported) { badgeCls = 'bk-badge-imported'; badgeTxt = 'imported'; } else if (!entry.after) { badgeCls = 'bk-badge-pending'; badgeTxt = 'pending…'; } else if (!changed.length) { badgeCls = 'bk-badge-same'; badgeTxt = 'no changes'; } else { badgeCls = 'bk-badge-changed'; badgeTxt = `${changed.length} file${changed.length > 1 ? 's' : ''} changed`; } header.append(arrow, ts, mk('span', 'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium flex-shrink-0 ' + badgeCls, badgeTxt) ); wrap.appendChild(header); // Body (collapsed) const body = mk('div', 'px-4 pb-3 pt-1'); body.style.display = 'none'; // Restore buttons const actions = mk('div', 'flex gap-2 mb-3 flex-wrap'); const restorePairs = entry.imported ? [['Restore', entry.after]] : [['Before', entry.before], ['After', entry.after]]; restorePairs.forEach(([label, files]) => { if (!files) return; const btn = mk('button', 'cursor-pointer font-medium rounded-lg text-xs px-3 py-1.5 inline-flex items-center ' + 'transition duration-150 justify-center gap-1.5 whitespace-nowrap ' + 'border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 ' + 'bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50', entry.imported ? 'Restore this import' : `Restore "${label}"` ); btn.type = 'button'; btn.addEventListener('click', () => doRestore(pluginId, files, btn)); actions.appendChild(btn); }); body.appendChild(actions); // File tabs + diff views const tabs = mk('div', 'flex gap-1 flex-wrap mb-2'); const views = mk('div'); const activeBase = 'bg-gray-900 text-white border-gray-900 dark:bg-primary-600 dark:text-white dark:border-primary-500'; const changedBase = 'border-emerald-400 dark:border-emerald-600 text-emerald-700 dark:text-emerald-400 bg-transparent hover:bg-emerald-50 dark:hover:bg-emerald-900/30'; const defaultBase = 'border-gray-200 dark:border-gray-600 text-gray-500 dark:text-gray-400 bg-transparent hover:bg-gray-100 dark:hover:bg-gray-700'; // Determine which tab to show first: first changed file, or first file if none const defaultIdx = changed.length > 0 ? allFiles.indexOf(changed[0]) : 0; const tabEls = []; const viewEls = []; allFiles.forEach((name, i) => { const isChanged = changed.includes(name); const inactiveBase = isChanged ? changedBase : defaultBase; const label = name.replace('.liquid', ''); const tab = mk('button', `px-2 py-0.5 text-xs font-mono border rounded-md cursor-pointer transition-colors ${inactiveBase}`, label ); tab.type = 'button'; tab.dataset.inactiveBase = inactiveBase; const view = mk('div'); view.style.display = 'none'; view.appendChild( !entry.after ? mk('p', 'text-xs text-gray-400 dark:text-gray-500 italic py-1', 'No "after" snapshot yet.') : buildDiffEl(entry.before[name] ?? '', entry.after[name] ?? '') ); tab.addEventListener('click', () => { tabEls.forEach(t => { t.className = `px-2 py-0.5 text-xs font-mono border rounded-md cursor-pointer transition-colors ${t.dataset.inactiveBase}`; }); viewEls.forEach(v => { v.style.display = 'none'; }); tab.className = `px-2 py-0.5 text-xs font-mono border rounded-md cursor-pointer transition-colors ${activeBase}`; view.style.display = ''; }); tabEls.push(tab); viewEls.push(view); tabs.appendChild(tab); views.appendChild(view); }); // Activate the default tab if (tabEls[defaultIdx]) { tabEls[defaultIdx].className = `px-2 py-0.5 text-xs font-mono border rounded-md cursor-pointer transition-colors ${activeBase}`; viewEls[defaultIdx].style.display = ''; } body.append(tabs, views); wrap.appendChild(body); let open = false; header.addEventListener('click', () => { open = !open; arrow.textContent = open ? '▾' : '▸'; body.style.display = open ? '' : 'none'; }); return wrap; } // --------------------------------------------------------------------------- // Panel: stepper control [−] N [+] with hold-to-repeat // --------------------------------------------------------------------------- function buildStepper(value, min, max, onChange) { const wrap = mk('div', 'inline-flex items-center rounded-lg border border-gray-300 dark:border-gray-600 ' + 'overflow-hidden bg-white dark:bg-gray-800' ); function mkStepBtn(label) { const b = mk('button', 'w-8 h-8 flex items-center justify-center text-sm font-medium ' + 'text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 ' + 'transition-colors select-none flex-shrink-0', label ); b.type = 'button'; return b; } const dec = mkStepBtn('−'); // Editable number input in the centre — type any value directly const inp = document.createElement('input'); inp.type = 'number'; inp.value = String(value); inp.min = String(min); inp.max = String(max); inp.className = 'w-14 text-center text-sm font-medium tabular-nums bg-transparent ' + 'text-gray-900 dark:text-gray-100 border-0 focus:outline-none focus:ring-0 ' + 'border-x border-gray-300 dark:border-gray-600 py-1 px-1 ' + '[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none'; const inc = mkStepBtn('+'); let current = value; function commit(v) { current = Math.max(min, Math.min(max, isNaN(v) ? current : v)); inp.value = String(current); onChange(current); } inp.addEventListener('change', () => commit(parseInt(inp.value, 10))); inp.addEventListener('blur', () => commit(parseInt(inp.value, 10))); let timer, interval; function update(delta) { commit(current + delta); } function startRepeat(delta) { update(delta); timer = setTimeout(() => { interval = setInterval(() => update(delta), 80); }, 400); } function stopRepeat() { clearTimeout(timer); clearInterval(interval); } dec.addEventListener('mousedown', () => startRepeat(-1)); inc.addEventListener('mousedown', () => startRepeat(+1)); [dec, inc].forEach(b => { b.addEventListener('mouseup', stopRepeat); b.addEventListener('mouseleave', stopRepeat); }); wrap.append(dec, inp, inc); return wrap; } // --------------------------------------------------------------------------- // Import from ZIP // --------------------------------------------------------------------------- async function importFromZip(pluginId, listEl) { const input = document.createElement('input'); input.type = 'file'; input.accept = '.zip,application/zip'; input.addEventListener('change', async () => { const file = input.files[0]; if (!file) return; log('Import: reading file:', file.name, 'size:', file.size, 'bytes'); try { const arrayBuffer = await file.arrayBuffer(); log('Import: parsing ZIP…'); const zip = await JSZip.loadAsync(arrayBuffer); const zipEntries = Object.entries(zip.files).filter(([, e]) => !e.dir); log('Import: ZIP entries found:', zipEntries.map(([n]) => n)); const files = {}; await Promise.all( zipEntries.map(async ([name, e]) => { // Strip directory prefix (e.g. "plugin_256335/shared.liquid" → "shared.liquid") const basename = name.split('/').pop(); if (basename) { files[basename] = await e.async('text'); log(`Import: extracted "${basename}" — ${files[basename].length} chars`); } }) ); if (!Object.keys(files).length) { alert('ZIP contains no files.'); return; } log('Import: all files extracted:', Object.keys(files)); // Check settings.yml id against current plugin const settingsYml = files['settings.yml'] || ''; const idMatch = settingsYml.match(/^id:\s*(\d+)/m); if (idMatch && idMatch[1] !== pluginId) { const otherId = idMatch[1]; const nameMatch = settingsYml.match(/^name:\s*(.+)/m); const nameNote = nameMatch ? ` ("${nameMatch[1].trim()}")` : ''; const confirmed = confirm( `This ZIP belongs to plugin ${otherId}${nameNote}, but you're on plugin ${pluginId}.\n\n` + `Import anyway? The id in settings.yml will be updated to ${pluginId}.` ); if (!confirmed) return; files['settings.yml'] = settingsYml.replace(/^id:\s*\d+/m, `id: ${pluginId}`); log(`Import: settings.yml id updated from ${otherId} to ${pluginId}`); } // Save to backup list const { maxBackups } = getConfig(); const entry = { id: Date.now(), timestamp: Date.now(), before: {}, after: files, imported: true, }; const backups = getBackups(pluginId); backups.unshift(entry); if (backups.length > maxBackups) backups.length = maxBackups; saveBackups(pluginId, backups); refreshButtonCount(); const emptyMsg = listEl.querySelector('p'); if (emptyMsg) emptyMsg.remove(); listEl.prepend(buildEntryEl(entry, pluginId)); log('Import: saved to backup list:', file.name, Object.keys(files)); // Upload to TRMNL const apiKey = localStorage.getItem(API_KEY_KEY) || ''; if (!apiKey) { alert('ZIP saved to backup list.\n\nSet your API key in the panel to upload it to TRMNL.'); return; } log('Import: building ZIP for upload…'); const blob = await buildZip(files); log('Import: ZIP blob size:', blob.size, 'bytes'); const fd = new FormData(); fd.append('file', blob, 'archive.zip'); log(`Import: uploading to /api/plugin_settings/${pluginId}/archive…`); const res = await fetch(`https://trmnl.com/api/plugin_settings/${pluginId}/archive`, { method: 'POST', headers: { Authorization: `Bearer ${apiKey}` }, body: fd, }); log('Import: upload response status:', res.status); if (!res.ok) { const body = await res.text().catch(() => ''); warn('Import: upload failed body:', body); throw new Error(`HTTP ${res.status}${body ? ' — ' + body.slice(0, 300) : ''}`); } log('Import: upload successful, redirecting…'); setTimeout(() => { if (window.Turbo?.cache) window.Turbo.cache.clear(); window.location.href = `https://trmnl.com/plugin_settings/${pluginId}/edit`; }, 800); } catch (e) { warn('Import: failed:', e.message); alert(`Import failed: ${e.message}`); } }); input.click(); } // --------------------------------------------------------------------------- // Panel: build, open, close // --------------------------------------------------------------------------- function buildPanel(pluginId) { const overlay = mk('div', ''); overlay.id = OVERLAY_ID; overlay.addEventListener('click', closePanel); const panel = mk('div', ''); panel.id = PANEL_ID; // ── Header ────────────────────────────────────────────────────────────── const hdr = mk('div', 'flex items-center justify-between px-5 py-4 border-b border-gray-200 dark:border-gray-700 flex-shrink-0' ); const iconBtnCls = 'w-8 h-8 flex items-center justify-center rounded-lg text-gray-400 ' + 'hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors'; // Expand / restore panel width toggle let panelExpanded = false; const expandPanelBtn = mk('button', iconBtnCls); expandPanelBtn.type = 'button'; expandPanelBtn.title = 'Expand panel'; expandPanelBtn.innerHTML = `` + `` + ``; expandPanelBtn.addEventListener('click', () => { panelExpanded = !panelExpanded; panel.style.width = panelExpanded ? 'min(95vw, 1100px)' : ''; expandPanelBtn.title = panelExpanded ? 'Restore panel size' : 'Expand panel'; expandPanelBtn.innerHTML = panelExpanded ? `` : ``; }); const close = mk('button', iconBtnCls, '✕'); close.type = 'button'; close.addEventListener('click', closePanel); const hdrBtns = mk('div', 'flex items-center gap-1 ml-auto'); hdrBtns.append(expandPanelBtn, close); hdr.append(mk('span', 'text-sm font-semibold text-gray-900 dark:text-gray-100', 'Plugin Backups'), hdrBtns); panel.appendChild(hdr); // ── API Key ────────────────────────────────────────────────────────────── const apiKey = localStorage.getItem(API_KEY_KEY) || ''; const apiSec = mk('div', 'px-5 py-4 border-b border-gray-200 dark:border-gray-700 flex-shrink-0' ); apiSec.appendChild(mk('p', 'text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2', 'API Key' )); const apiRow = mk('div', 'flex items-center gap-2 flex-wrap'); if (apiKey) { const chip = mk('span', 'inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bk-chip-set', '✓ API key saved' ); const changeBtn = mk('button', 'text-xs text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 ' + 'underline cursor-pointer bg-transparent border-0', 'Change' ); changeBtn.type = 'button'; const inputWrap = mk('div', 'w-full mt-2 hidden'); const apiIn = mk('input', 'w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg ' + 'bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-gray-100 ' + 'focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-500 focus:border-blue-500' ); apiIn.type = 'password'; apiIn.placeholder = 'user_xxxxx'; apiIn.value = apiKey; apiIn.style.boxSizing = 'border-box'; apiIn.addEventListener('change', () => { const v = apiIn.value.trim(); v ? localStorage.setItem(API_KEY_KEY, v) : localStorage.removeItem(API_KEY_KEY); }); inputWrap.appendChild(apiIn); changeBtn.addEventListener('click', () => { inputWrap.classList.toggle('hidden'); if (!inputWrap.classList.contains('hidden')) apiIn.focus(); }); apiRow.append(chip, changeBtn); apiSec.append(apiRow, inputWrap); } else { const notSet = mk('span', 'inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bk-chip-unset', 'Not set' ); const setBtn = mk('button', 'cursor-pointer font-medium rounded-lg text-xs px-3 py-1.5 inline-flex items-center ' + 'transition duration-150 justify-center gap-1.5 border-0 ' + 'text-white bg-primary-500 dark:bg-primary-600 hover:bg-primary-600 dark:hover:bg-primary-500 ' + 'focus:outline-none', 'Set API Key' ); setBtn.type = 'button'; const inputWrap = mk('div', 'w-full mt-2 hidden'); const apiIn = mk('input', 'w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg ' + 'bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-gray-100 ' + 'focus:ring-2 focus:ring-blue-500 focus:border-blue-500' ); apiIn.type = 'password'; apiIn.placeholder = 'user_xxxxx'; apiIn.style.boxSizing = 'border-box'; const saveKeyBtn = mk('button', 'mt-2 cursor-pointer font-medium rounded-lg text-xs px-3 py-1.5 inline-flex items-center ' + 'transition duration-150 gap-1.5 border-0 ' + 'text-white bg-primary-500 dark:bg-primary-600 hover:bg-primary-600 dark:hover:bg-primary-500 ' + 'focus:outline-none', 'Save' ); saveKeyBtn.type = 'button'; saveKeyBtn.addEventListener('click', () => { const v = apiIn.value.trim(); if (!v) return; localStorage.setItem(API_KEY_KEY, v); notSet.className = 'inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bk-chip-set'; notSet.textContent = '✓ API key saved'; inputWrap.classList.add('hidden'); }); inputWrap.append(apiIn, saveKeyBtn); setBtn.addEventListener('click', () => { inputWrap.classList.toggle('hidden'); if (!inputWrap.classList.contains('hidden')) apiIn.focus(); }); apiRow.append(notSet, setBtn); apiSec.append(apiRow, inputWrap); } apiSec.appendChild(mk('p', 'text-xs text-gray-400 dark:text-gray-500 mt-2', 'Required only for restoring. Stored in localStorage.' )); panel.appendChild(apiSec); // ── Backup Settings ────────────────────────────────────────────────────── const cfg = getConfig(); const cfgSec = mk('div', 'px-5 py-4 border-b border-gray-200 dark:border-gray-700 flex-shrink-0' ); cfgSec.appendChild(mk('p', 'text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-3', 'Backup Settings' )); const cfgRow = mk('div', 'flex items-start gap-6 flex-wrap'); const maxWrap = mk('div', 'flex flex-col gap-1.5'); maxWrap.appendChild(mk('label', 'text-xs font-medium text-gray-600 dark:text-gray-400', 'Max backups')); maxWrap.appendChild(buildStepper(cfg.maxBackups, 1, 100, v => saveConfig({ ...getConfig(), maxBackups: v }) )); const ageWrap = mk('div', 'flex flex-col gap-1.5'); ageWrap.appendChild(mk('label', 'text-xs font-medium text-gray-600 dark:text-gray-400', 'Keep (hours)')); ageWrap.appendChild(buildStepper(cfg.maxAgeHours, 0, 8760, v => saveConfig({ ...getConfig(), maxAgeHours: v }) )); ageWrap.appendChild(mk('p', 'text-xs text-gray-400 dark:text-gray-500', '0 = keep forever')); // Storage sizes function formatBytes(b) { if (b < 1024) return `${b} B`; if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} KB`; return `${(b / (1024 * 1024)).toFixed(2)} MB`; } const pluginRaw = localStorage.getItem(bkKey(pluginId)) || ''; let totalRaw = ''; for (let i = 0; i < localStorage.length; i++) { const k = localStorage.key(i); if (k && k.startsWith('trmnl_backups_')) totalRaw += localStorage.getItem(k) || ''; } const sizeInfo = mk('p', 'text-xs text-gray-400 dark:text-gray-500 mt-3', `This plugin: ${formatBytes(new Blob([pluginRaw]).size)} · All plugins: ${formatBytes(new Blob([totalRaw]).size)}` ); cfgRow.append(maxWrap, ageWrap); cfgSec.append(cfgRow, sizeInfo); panel.appendChild(cfgSec); // ── List header ────────────────────────────────────────────────────────── const listHdr = mk('div', 'flex items-center justify-between px-5 py-2 text-xs font-semibold uppercase ' + 'tracking-wider text-gray-500 dark:text-gray-400 flex-shrink-0' ); const importB = mk('button', 'text-xs text-primary-500 dark:text-primary-400 hover:underline bg-transparent border-0 cursor-pointer', '↑ Import ZIP' ); importB.type = 'button'; importB.addEventListener('click', () => importFromZip(pluginId, listEl)); const clearB = mk('button', 'text-xs text-red-500 dark:text-red-400 hover:underline bg-transparent border-0 cursor-pointer', 'Clear all' ); clearB.type = 'button'; clearB.addEventListener('click', () => { if (!confirm('Delete all backups for this plugin?')) return; localStorage.removeItem(bkKey(pluginId)); listEl.replaceChildren( mk('p', 'text-xs text-gray-400 dark:text-gray-500 text-center py-8', 'No backups yet. Save your plugin to create one.') ); refreshButtonCount(); }); const listActions = mk('div', 'flex items-center gap-3'); listActions.append(importB, clearB); listHdr.append(mk('span', '', `Plugin ${pluginId}`), listActions); panel.appendChild(listHdr); // ── Backup list ────────────────────────────────────────────────────────── const listEl = mk('div', 'flex-1 overflow-y-auto'); const backups = getBackups(pluginId); if (!backups.length) { listEl.appendChild( mk('p', 'text-xs text-gray-400 dark:text-gray-500 text-center py-8', 'No backups yet. Save your plugin to create one.') ); } else { backups.forEach(e => listEl.appendChild(buildEntryEl(e, pluginId))); } panel.appendChild(listEl); document.body.append(overlay, panel); requestAnimationFrame(() => { overlay.classList.add('bk-visible'); panel.classList.add('bk-visible'); }); } function closePanel() { [PANEL_ID, OVERLAY_ID].forEach(id => { const el = document.getElementById(id); if (!el) return; el.classList.remove('bk-visible'); setTimeout(() => el.remove(), 300); }); } function openPanel() { if (document.getElementById(PANEL_ID)) { closePanel(); return; } const pluginId = getPluginId(); if (!pluginId) return; buildPanel(pluginId); } // --------------------------------------------------------------------------- // Header button (page title row) — all plugin_settings pages // --------------------------------------------------------------------------- function refreshButtonCount() { const btn = document.getElementById(BTN_ID); if (!btn) return; const pluginId = getPluginId(); const count = pluginId ? getBackups(pluginId).length : 0; const span = btn.querySelector('[data-bk-count]'); if (span) span.textContent = String(count); btn.style.display = count > 0 ? '' : 'none'; } function injectButton() { if (document.getElementById(BTN_ID)) return true; // Only show on specific plugin pages (not the list page) const pluginId = getPluginId(); if (!pluginId) return true; // nothing to inject; stop observing const h2 = document.querySelector('h2.font-heading'); if (!h2) return false; const headerFlex = h2.parentElement; if (!headerFlex) return false; const count = getBackups(pluginId).length; const btn = mk('button', 'inline-flex items-center gap-1.5 px-2.5 py-1 flex-shrink-0 ' + 'text-xs font-medium rounded-full border cursor-pointer transition-colors ' + 'border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 ' + 'text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700 ' + 'focus:outline-none focus:ring-2 focus:ring-primary-500' ); btn.id = BTN_ID; btn.type = 'button'; btn.title = 'Plugin Backups'; btn.style.display = count > 0 ? '' : 'none'; btn.innerHTML = `${count}`; btn.addEventListener('click', openPanel); // Place button inline with the h2 title const h2Row = mk('div', 'flex items-center gap-2'); h2.parentNode.insertBefore(h2Row, h2); h2Row.appendChild(h2); h2Row.appendChild(btn); log('Button injected for plugin', pluginId); return true; } // --------------------------------------------------------------------------- // Account page: "Use in Backup Script" button next to API key // --------------------------------------------------------------------------- function injectAccountButton() { if (document.getElementById(ACCT_BTN_ID)) return true; const apiInput = document.querySelector('input[id$="-apiKey-copy-btn"]'); if (!apiInput) return false; const apiValue = apiInput.value.trim(); if (!apiValue) return false; const isAlreadySaved = localStorage.getItem(API_KEY_KEY) === apiValue; const baseBtnCls = 'inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-full border ' + 'cursor-pointer transition-colors focus:outline-none focus:ring-2 focus:ring-primary-500 mt-3 '; const clsDefault = 'border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 ' + 'text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700'; const clsSaved = 'text-primary-500 bg-primary-100 dark:bg-primary-900 ' + 'border-primary-300 dark:border-primary-700'; const btn = mk('button', baseBtnCls + (isAlreadySaved ? clsSaved : clsDefault), isAlreadySaved ? '✓ Saved in Backups' : '↓ Use in Backup Script' ); btn.id = ACCT_BTN_ID; btn.type = 'button'; btn.addEventListener('click', () => { localStorage.setItem(API_KEY_KEY, apiValue); btn.textContent = '✓ Saved in Backups'; btn.className = baseBtnCls + clsSaved; }); // Insert below the API key section, after the docs paragraph const flexRow = apiInput.closest('.flex.items-center'); if (!flexRow) return false; const container = flexRow.closest('.p-6') || flexRow.parentElement; const docsP = container.querySelector('a[href*="help.trmnl.com"]')?.closest('p'); if (docsP) { docsP.insertAdjacentElement('afterend', btn); } else { container.appendChild(btn); } return true; } // --------------------------------------------------------------------------- // Styles — minimal: only what Tailwind can't handle // --------------------------------------------------------------------------- function injectStyle() { if (document.getElementById(STYLE_ID)) return; const s = mk('style'); s.id = STYLE_ID; s.textContent = ` /* Overlay */ #${OVERLAY_ID} { position:fixed; inset:0; background:rgba(0,0,0,.45); z-index:9998; opacity:0; transition:opacity .25s; } #${OVERLAY_ID}.bk-visible { opacity:1; } /* Slide-in panel */ /* Slide-in panel */ #${PANEL_ID} { position:fixed; top:0; right:0; bottom:0; width:min(580px,95vw); background:#fff; z-index:9999; box-shadow:-4px 0 40px rgba(0,0,0,.15); display:flex; flex-direction:column; transform:translateX(100%); transition:transform .3s cubic-bezier(.4,0,.2,1); font-family:ui-sans-serif,system-ui,sans-serif; font-size:13px; color:#111827; } #${PANEL_ID}.bk-visible { transform:translateX(0); } .dark #${PANEL_ID} { background:#111827; color:#e5e7eb; } /* Backup entry hover fix */ #${PANEL_ID} .hover\\:bg-gray-50:hover, #${PANEL_ID} .dark\\:hover\\:bg-gray-800\\/60:hover { color: inherit; } #${PANEL_ID} .hover\\:bg-gray-50:hover .text-gray-700, #${PANEL_ID} .dark\\:hover\\:bg-gray-800\\/60:hover .dark\\:text-gray-300 { color: inherit; } /* Diff */ .bk-diff { font-family:ui-monospace,'Cascadia Code',monospace; font-size:11px; line-height:1.6; margin:0; white-space:pre-wrap; word-break:break-all; overflow-wrap:anywhere; padding:8px 10px; max-height:12rem; overflow-y:auto; background:#f8fafc; } .dark .bk-diff { background:#0f172a; } .bk-diff-add { background:#dcfce7; color:#15803d; display:block; } .bk-diff-del { background:#fee2e2; color:#dc2626; display:block; } .bk-diff-eq { color:#9ca3af; display:block; } .bk-diff-skip { color:#d1d5db; font-style:italic; display:block; padding-left:1.5rem; } .dark .bk-diff-add { background:#14532d; color:#86efac; } .dark .bk-diff-del { background:#7f1d1d; color:#fca5a5; } .dark .bk-diff-eq { color:#374151; } .dark .bk-diff-skip { color:#4b5563; } .bk-hl-del, .bk-hl-add { border-radius:2px; padding:0 1px; color:inherit; } .bk-hl-del { background:rgba(185,28,28,0.25); } .bk-hl-add { background:rgba(21,128,61,0.25); } .dark .bk-hl-del { background:rgba(252,165,165,0.3); } .dark .bk-hl-add { background:rgba(134,239,172,0.3); } /* Expand button */ .bk-expand-btn { display:flex; border:0; font-family:inherit; } /* Badges */ .bk-badge-changed { background:#dcfce7; color:#15803d; } .bk-badge-same { background:#f3f4f6; color:#6b7280; } .bk-badge-pending { background:#fef9c3; color:#854d0e; } .bk-badge-imported { background:#ede9fe; color:#6d28d9; } .dark .bk-badge-changed { background:#14532d; color:#86efac; } .dark .bk-badge-same { background:#1f2937; color:#9ca3af; } .dark .bk-badge-pending { background:#422006; color:#fde68a; } .dark .bk-badge-imported { background:#2e1065; color:#c4b5fd; } /* API key chip — uses custom CSS because Tailwind opacity utilities (/30) may not be compiled into the site's stylesheet */ .bk-chip-set { background:#f0fdf4; color:#15803d; border:1px solid #bbf7d0; } .dark .bk-chip-set { background:rgba(6,78,59,.25); color:#6ee7b7; border:1px solid rgba(52,211,153,.3); } .bk-chip-unset { background:#f3f4f6; color:#6b7280; border:1px solid #e5e7eb; } .dark .bk-chip-unset { background:#1f2937; color:#9ca3af; border:1px solid #374151; } `; document.head.appendChild(s); } // --------------------------------------------------------------------------- // Init // --------------------------------------------------------------------------- function setup() { injectStyle(); checkPendingBackup(); if (isAccountPage()) { if (!injectAccountButton()) { const obs = new MutationObserver(() => { if (injectAccountButton()) obs.disconnect(); }); obs.observe(document.body, { childList: true, subtree: true }); setTimeout(() => obs.disconnect(), 15_000); } return; } const pluginId = getPluginId(); if (!pluginId) return; function tryAttachForm() { attachFormInterceptor(pluginId); return !!document.querySelector('form[data-backup-attached]'); } if (!tryAttachForm()) { const obs = new MutationObserver(() => { if (tryAttachForm()) obs.disconnect(); }); obs.observe(document.body, { childList: true, subtree: true }); setTimeout(() => obs.disconnect(), 60_000); } if (!injectButton()) { const obs = new MutationObserver(() => { if (injectButton()) obs.disconnect(); }); obs.observe(document.body, { childList: true, subtree: true }); } } document.addEventListener('turbo:load', () => { checkPendingBackup(); closePanel(); if (isAccountPage()) { injectAccountButton(); return; } const pluginId = getPluginId(); if (pluginId) attachFormInterceptor(pluginId); injectButton(); refreshButtonCount(); }); log('Script loaded.'); setup(); })();