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">
|
<div class="flex-row" style="margin-bottom:12px">
|
||||||
<h2 style="margin:0">DWM Automation Rules</h2>
|
<h2 style="margin:0">DWM Automation Rules</h2>
|
||||||
<div class="spacer"></div>
|
<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>
|
<button class="btn btn-primary" id="btn-add-dwm">+ Add Rule</button>
|
||||||
</div>
|
</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 -->
|
<!-- 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)">
|
<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>
|
<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);
|
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 ─────────────────────────────────────────────────────────
|
// ── Sun-time helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function secsToAmPm(secs) {
|
function secsToAmPm(secs) {
|
||||||
|
|||||||
@@ -84,6 +84,35 @@ class DibbyWemoUiServer extends HomebridgePluginUiServer {
|
|||||||
return { ok: true };
|
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 ───────────────────────────────────────────────────
|
// ── Scheduler heartbeat ───────────────────────────────────────────────────
|
||||||
this.onRequest('/scheduler/status', async () => {
|
this.onRequest('/scheduler/status', async () => {
|
||||||
const hb = this._store.getHeartbeat();
|
const hb = this._store.getHeartbeat();
|
||||||
|
|||||||
Reference in New Issue
Block a user