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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user