feat: add Import/Export for DWM rules in Homebridge UI

Export:
- '⬇ Export' button downloads all rules as dwm-rules-YYYY-MM-DD.json
- Compatible with desktop app rule format

Import:
- '⬆ Import' button opens file picker (accepts .json)
- Preview panel shows rule names + types before committing
- Merge mode: adds rules, skips any whose name already exists
- Replace mode: deletes all current rules then imports
- Server strips imported IDs/timestamps — fresh ones are assigned
- Reports imported/skipped count on completion

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
SRS IT
2026-03-28 20:25:12 -04:00
parent 951d4c4eaa
commit 3bcc427683
3 changed files with 143 additions and 0 deletions
@@ -220,9 +220,32 @@
<div class="flex-row" style="margin-bottom:12px">
<h2 style="margin:0">DWM Automation Rules</h2>
<div class="spacer"></div>
<button class="btn btn-ghost btn-sm" id="btn-export-dwm" title="Export all rules to JSON file">⬇ Export</button>
<button class="btn btn-ghost btn-sm" id="btn-import-dwm" title="Import rules from JSON file">⬆ Import</button>
<input type="file" id="dwm-import-file" accept=".json" style="display:none" />
<button class="btn btn-primary" id="btn-add-dwm">+ Add Rule</button>
</div>
<!-- Import preview panel (hidden until file chosen) -->
<div id="dwm-import-panel" style="display:none;margin-bottom:16px;padding:14px 16px;background:rgba(255,255,255,0.05);border:1px solid rgba(255,255,255,0.18);border-radius:8px">
<div class="flex-row" style="margin-bottom:10px">
<strong id="dwm-import-title" style="font-size:0.92rem">Import Rules</strong>
<div class="spacer"></div>
<button class="btn btn-ghost btn-sm" id="btn-import-cancel">✕ Cancel</button>
</div>
<div id="dwm-import-list" style="max-height:180px;overflow-y:auto;margin-bottom:12px;font-size:0.82rem;color:#9ca3af"></div>
<div style="display:flex;align-items:center;gap:16px;margin-bottom:12px;font-size:0.85rem">
<label style="display:flex;align-items:center;gap:6px;cursor:pointer">
<input type="radio" name="dwm-import-mode" value="merge" checked /> Merge <span style="color:#9ca3af;font-size:0.78rem">(skip existing names)</span>
</label>
<label style="display:flex;align-items:center;gap:6px;cursor:pointer">
<input type="radio" name="dwm-import-mode" value="replace" /> Replace <span style="color:#fca5a5;font-size:0.78rem">(delete all current rules first)</span>
</label>
</div>
<div id="dwm-import-status" style="font-size:0.82rem;margin-bottom:8px;min-height:18px"></div>
<button class="btn btn-primary" id="btn-import-confirm">⬆ Import Rules</button>
</div>
<!-- Scheduler heartbeat bar -->
<div id="dwm-heartbeat" style="display:flex;align-items:center;gap:8px;padding:8px 12px;border-radius:6px;margin-bottom:14px;font-size:0.8rem;background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.18)">
<span id="hb-dot" style="width:10px;height:10px;border-radius:50%;background:#6b7280;flex-shrink:0"></span>
@@ -274,6 +274,97 @@ function deleteDwmRule(id) {
setTimeout(() => row.remove(), 5000);
}
// ── Export ────────────────────────────────────────────────────────────────────
document.getElementById('btn-export-dwm').addEventListener('click', async () => {
try {
const rules = await homebridge.request('/rules/export');
if (!rules || !rules.length) { showStatus('dwm-rules-status', 'No rules to export.', 'warn'); return; }
const blob = new Blob([JSON.stringify(rules, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
const date = new Date().toISOString().slice(0, 10);
a.href = url;
a.download = `dwm-rules-${date}.json`;
a.click();
URL.revokeObjectURL(url);
showStatus('dwm-rules-status', `Exported ${rules.length} rule${rules.length !== 1 ? 's' : ''}.`, 'success');
} catch (e) {
showStatus('dwm-rules-status', 'Export failed: ' + e.message, 'error');
}
});
// ── Import ────────────────────────────────────────────────────────────────────
let _importRules = []; // parsed rules waiting for confirmation
document.getElementById('btn-import-dwm').addEventListener('click', () => {
document.getElementById('dwm-import-file').value = ''; // reset so same file can be re-selected
document.getElementById('dwm-import-file').click();
});
document.getElementById('dwm-import-file').addEventListener('change', (e) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
try {
const parsed = JSON.parse(ev.target.result);
const rules = Array.isArray(parsed) ? parsed : parsed.rules ?? [];
if (!rules.length) throw new Error('No rules found in file');
_importRules = rules;
document.getElementById('dwm-import-title').textContent =
`Import ${rules.length} rule${rules.length !== 1 ? 's' : ''} from "${file.name}"`;
// Build preview list
const listEl = document.getElementById('dwm-import-list');
listEl.innerHTML = rules.map((r) =>
`<div style="padding:3px 0;border-bottom:1px solid rgba(255,255,255,0.06)">` +
`<span style="color:#e2e8f0">${esc(r.name ?? '(unnamed)')}</span> ` +
`<span style="color:#6b7280;font-size:0.75rem">${esc(r.type ?? '')}</span></div>`
).join('');
document.getElementById('dwm-import-status').textContent = '';
document.getElementById('dwm-import-panel').style.display = '';
document.getElementById('btn-import-confirm').disabled = false;
} catch (err) {
showStatus('dwm-rules-status', 'Import failed: ' + err.message, 'error');
}
};
reader.readAsText(file);
});
document.getElementById('btn-import-cancel').addEventListener('click', () => {
document.getElementById('dwm-import-panel').style.display = 'none';
_importRules = [];
});
document.getElementById('btn-import-confirm').addEventListener('click', async () => {
if (!_importRules.length) return;
const mode = document.querySelector('input[name="dwm-import-mode"]:checked')?.value ?? 'merge';
const statusEl = document.getElementById('dwm-import-status');
const btn = document.getElementById('btn-import-confirm');
btn.disabled = true;
statusEl.style.color = '#9ca3af';
statusEl.textContent = 'Importing…';
try {
const res = await homebridge.request('/rules/import', { rules: _importRules, mode });
document.getElementById('dwm-import-panel').style.display = 'none';
_importRules = [];
await loadDwmRules();
const msg = mode === 'replace'
? `Replaced all rules — imported ${res.imported}.`
: `Imported ${res.imported} rule${res.imported !== 1 ? 's' : ''}${res.skipped ? `, skipped ${res.skipped} (name already exists)` : ''}.`;
showStatus('dwm-rules-status', msg, 'success');
} catch (e) {
btn.disabled = false;
statusEl.style.color = '#fca5a5';
statusEl.textContent = 'Import failed: ' + e.message;
}
});
// ── Sun-time helpers ─────────────────────────────────────────────────────────
function secsToAmPm(secs) {
@@ -84,6 +84,35 @@ class DibbyWemoUiServer extends HomebridgePluginUiServer {
return { ok: true };
});
this.onRequest('/rules/export', async () => {
return this._store.getDwmRules();
});
this.onRequest('/rules/import', async ({ rules, mode }) => {
if (!Array.isArray(rules) || rules.length === 0) throw new Error('No valid rules found in import data');
if (mode === 'replace') {
for (const r of this._store.getDwmRules()) this._store.deleteDwmRule(r.id);
}
const existing = this._store.getDwmRules();
const existingNames = new Set(existing.map((r) => (r.name ?? '').toLowerCase()));
let imported = 0, skipped = 0;
for (const rule of rules) {
// Strip old identity fields — store will assign fresh id + timestamps
const { id: _id, createdAt: _ca, updatedAt: _ua, ...ruleData } = rule;
if (mode === 'merge' && existingNames.has((ruleData.name ?? '').toLowerCase())) {
skipped++;
continue;
}
this._store.createDwmRule(ruleData);
imported++;
}
return { ok: true, imported, skipped };
});
// ── Scheduler heartbeat ───────────────────────────────────────────────────
this.onRequest('/scheduler/status', async () => {
const hb = this._store.getHeartbeat();