openclacky 1.3.2 → 1.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +28 -0
  3. data/Dockerfile +3 -0
  4. data/README.md +1 -1
  5. data/README_JA.md +237 -0
  6. data/lib/clacky/agent/session_serializer.rb +49 -5
  7. data/lib/clacky/agent/time_machine.rb +247 -26
  8. data/lib/clacky/agent.rb +12 -1
  9. data/lib/clacky/agent_config.rb +14 -2
  10. data/lib/clacky/default_agents/_panels/git/panel.js +201 -0
  11. data/lib/clacky/default_agents/_panels/time_machine/panel.js +640 -0
  12. data/lib/clacky/default_agents/coding/profile.yml +3 -0
  13. data/lib/clacky/default_agents/coding/webui/.gitkeep +0 -0
  14. data/lib/clacky/default_skills/cron-task-creator/SKILL.md +1 -1
  15. data/lib/clacky/default_skills/extend-openclacky/SKILL.md +6 -4
  16. data/lib/clacky/default_skills/media-gen/SKILL.md +30 -6
  17. data/lib/clacky/media/openai_compat.rb +64 -1
  18. data/lib/clacky/media/output_dir.rb +43 -0
  19. data/lib/clacky/message_history.rb +9 -0
  20. data/lib/clacky/server/channel/channel_manager.rb +26 -0
  21. data/lib/clacky/server/git_panel.rb +115 -0
  22. data/lib/clacky/server/http_server.rb +497 -12
  23. data/lib/clacky/server/server_master.rb +6 -4
  24. data/lib/clacky/version.rb +1 -1
  25. data/lib/clacky/web/app.css +473 -60
  26. data/lib/clacky/web/app.js +30 -7
  27. data/lib/clacky/web/components/code-editor.js +197 -0
  28. data/lib/clacky/web/{notify.js → components/notify.js} +1 -1
  29. data/lib/clacky/web/core/aside.js +112 -0
  30. data/lib/clacky/web/core/ext.js +387 -0
  31. data/lib/clacky/web/features/backup/store.js +92 -0
  32. data/lib/clacky/web/features/backup/view.js +94 -0
  33. data/lib/clacky/web/features/billing/store.js +163 -0
  34. data/lib/clacky/web/{billing.js → features/billing/view.js} +132 -240
  35. data/lib/clacky/web/features/brand/store.js +110 -0
  36. data/lib/clacky/web/{brand.js → features/brand/view.js} +49 -199
  37. data/lib/clacky/web/features/channels/store.js +103 -0
  38. data/lib/clacky/web/{channels.js → features/channels/view.js} +50 -127
  39. data/lib/clacky/web/features/creator/store.js +81 -0
  40. data/lib/clacky/web/{creator.js → features/creator/view.js} +53 -102
  41. data/lib/clacky/web/features/mcp/store.js +158 -0
  42. data/lib/clacky/web/{mcp.js → features/mcp/view.js} +57 -134
  43. data/lib/clacky/web/features/model-tester/store.js +77 -0
  44. data/lib/clacky/web/features/model-tester/view.js +7 -0
  45. data/lib/clacky/web/features/profile/store.js +170 -0
  46. data/lib/clacky/web/{profile.js → features/profile/view.js} +94 -144
  47. data/lib/clacky/web/features/share/store.js +145 -0
  48. data/lib/clacky/web/{share.js → features/share/view.js} +66 -202
  49. data/lib/clacky/web/features/skills/store.js +303 -0
  50. data/lib/clacky/web/features/skills/view.js +550 -0
  51. data/lib/clacky/web/features/tasks/store.js +135 -0
  52. data/lib/clacky/web/features/tasks/view.js +241 -0
  53. data/lib/clacky/web/features/trash/store.js +242 -0
  54. data/lib/clacky/web/{trash.js → features/trash/view.js} +102 -293
  55. data/lib/clacky/web/features/version/store.js +165 -0
  56. data/lib/clacky/web/features/version/view.js +323 -0
  57. data/lib/clacky/web/features/workspace/store.js +99 -0
  58. data/lib/clacky/web/features/workspace/view.js +305 -0
  59. data/lib/clacky/web/i18n.js +56 -6
  60. data/lib/clacky/web/index.html +117 -58
  61. data/lib/clacky/web/sessions.js +221 -25
  62. data/lib/clacky/web/settings.js +118 -22
  63. data/lib/clacky/web/skills.js +3 -863
  64. data/lib/clacky/web/vendor/codemirror/codemirror.min.js +29 -0
  65. data/lib/clacky.rb +1 -0
  66. metadata +45 -20
  67. data/lib/clacky/web/backup.js +0 -119
  68. data/lib/clacky/web/model-tester.js +0 -66
  69. data/lib/clacky/web/tasks.js +0 -373
  70. data/lib/clacky/web/version.js +0 -449
  71. data/lib/clacky/web/workspace.js +0 -316
  72. /data/lib/clacky/web/{notify.mp3 → assets/notify.mp3} +0 -0
  73. /data/lib/clacky/web/{datepicker.js → components/datepicker.js} +0 -0
  74. /data/lib/clacky/web/{onboard.js → components/onboard.js} +0 -0
  75. /data/lib/clacky/web/{sidebar.js → components/sidebar.js} +0 -0
  76. /data/lib/clacky/web/{marked.min.js → vendor/marked/marked.min.js} +0 -0
@@ -0,0 +1,387 @@
1
+ // ── Clacky.ext — WebUI extension registry ─────────────────────────────────
2
+ //
3
+ // The single, controlled entry point through which user/AI-authored
4
+ // extensions (loaded from ~/.clacky/webui_ext/) hook into the WebUI.
5
+ //
6
+ // Three capabilities (the whole contract an extension author must learn):
7
+ // Clacky.ext.api.register(name, fn) — register a data source
8
+ // Clacky.ext.subscribe(event, handler) — listen to store events (read-only)
9
+ // Clacky.ext.ui.mount(slot, renderFn) — inject UI into a named slot
10
+ //
11
+ // Safety guarantees enforced here (the "constitution"):
12
+ // - Every extension callback is wrapped in try/catch. A throwing extension is
13
+ // contained: its slot degrades to a placeholder marked
14
+ // data-ext-status="crashed"; it never takes down the host or sibling slots.
15
+ // - In pure mode (?pure=true), the registry becomes a no-op: register/
16
+ // subscribe/mount do nothing, so no extension code can affect the page.
17
+ // - Extensions reach the host ONLY through this object. The enable/disable
18
+ // and safe-mode controls live in the host frame, never inside an extension.
19
+ //
20
+ // Depends on: nothing (loads right after utils.js).
21
+ // ───────────────────────────────────────────────────────────────────────────
22
+
23
+ window.Clacky = window.Clacky || {};
24
+
25
+ Clacky.ext = (() => {
26
+ // Pure mode: detect ?pure=true once. When on, all registration is a no-op and
27
+ // the host must not load any webui_ext scripts (handled server-side / in the
28
+ // loader). This is the ultimate escape hatch back to a clean official UI.
29
+ const PURE = (() => {
30
+ try {
31
+ return new URLSearchParams(window.location.search).get("pure") === "true";
32
+ } catch (_e) {
33
+ return false;
34
+ }
35
+ })();
36
+
37
+ const _dataSources = {}; // name => fn
38
+ const _subscribers = {}; // event => [handler]
39
+ const _slotRenderers = {}; // slot => [{ fn, extId, agents, panel, order, tab }]
40
+ let _currentExtId = null; // set while an extension file is loading
41
+
42
+ // Slots that render as a tabbed container instead of a vertical stack: each
43
+ // renderer becomes one tab (chrome drawn by the host), and only the active
44
+ // tab's body is shown. Renderers in these slots must carry opts.tab.
45
+ const TABBED_SLOTS = { "session.aside": true };
46
+ // slot => active tab id (remembered across re-renders within a page load).
47
+ const _activeTab = {};
48
+
49
+ // Per-extension/panel agent scoping declared at load time:
50
+ // _extAgents[extId] = ["coding", ...] (from <script data-agent=...>, may be multiple)
51
+ // _panelAgents[panel] = ["coding", ...] (agents whose profile.yml references the panel)
52
+ // Resolved by the host before the extension scripts run (see loader markers).
53
+ const _extAgents = {};
54
+ const _panelAgents = {};
55
+ let _currentPanel = null; // set while an official panel file is loading
56
+
57
+ // Current session context, kept in sync by the host on every session switch.
58
+ // Slots are (re)rendered against this so an extension/panel only appears for
59
+ // the agent profiles it was scoped to.
60
+ const context = { agentProfile: null };
61
+
62
+ // Wrap any extension-provided callback so a throw is contained, logged, and
63
+ // attributed to the extension that registered it.
64
+ function _guard(fn, label, extId) {
65
+ return (...args) => {
66
+ try {
67
+ return fn(...args);
68
+ } catch (err) {
69
+ console.error(`[Clacky.ext] extension "${extId || "?"}" failed in ${label}:`, err);
70
+ return undefined;
71
+ }
72
+ };
73
+ }
74
+
75
+ // Bracket an extension's synchronous evaluation so registrations made during
76
+ // it are attributed to `extId`. The host emits _extBegin before the
77
+ // extension's <script src> and _extEnd right after. In pure mode these are
78
+ // no-ops (and the host does not emit extension scripts at all).
79
+ //
80
+ // `agents` scopes a single-agent extension (from agents/<name>/webui/): a list
81
+ // of agent profile names it should appear for. `panel` marks an official
82
+ // panel (from _panels/<id>/) whose agent scope is resolved separately via
83
+ // registerPanelAgents. Either may be omitted for a global extension.
84
+ function _extBegin(extId, agents, panel) {
85
+ if (PURE) return;
86
+ _currentExtId = extId;
87
+ if (Array.isArray(agents) && agents.length) _extAgents[extId] = agents;
88
+ if (panel) _currentPanel = panel;
89
+ }
90
+
91
+ function _extEnd() {
92
+ _currentExtId = null;
93
+ _currentPanel = null;
94
+ }
95
+
96
+ // Record which agent profiles reference an official panel (host computes this
97
+ // from each agent's profile.yml `panels:` declaration). Called once at load.
98
+ function registerPanelAgents(map) {
99
+ if (PURE || !map) return;
100
+ Object.assign(_panelAgents, map);
101
+ }
102
+
103
+ const api = {
104
+ // Register a named data source. Host/extensions can later resolve it.
105
+ register(name, fn) {
106
+ if (PURE || typeof fn !== "function") return;
107
+ _dataSources[name] = _guard(fn, `api.register(${name})`, _currentExtId);
108
+ },
109
+ // Resolve a registered data source by name; undefined if absent.
110
+ resolve(name) {
111
+ return _dataSources[name];
112
+ },
113
+ };
114
+
115
+ // Subscribe to a store event. Read-only: handlers can observe, never mutate
116
+ // core logic. Returns an unsubscribe function.
117
+ function subscribe(event, handler) {
118
+ if (PURE || typeof handler !== "function") return () => {};
119
+ const wrapped = _guard(handler, `subscribe(${event})`, _currentExtId);
120
+ (_subscribers[event] ||= []).push(wrapped);
121
+ return () => {
122
+ const list = _subscribers[event];
123
+ if (!list) return;
124
+ const i = list.indexOf(wrapped);
125
+ if (i >= 0) list.splice(i, 1);
126
+ };
127
+ }
128
+
129
+ // Emit a store event to all subscribers. Called by store-layer code (host),
130
+ // never by extensions. A throwing subscriber is already guarded above.
131
+ function emit(event, payload) {
132
+ const list = _subscribers[event];
133
+ if (!list) return;
134
+ list.forEach((h) => h(payload));
135
+ }
136
+
137
+ const ui = {
138
+ // Register a renderer for a named slot. renderFn(ctx) -> Node | string | null.
139
+ //
140
+ // opts.agents — restrict to these agent profile names (default: all agents).
141
+ // For panels, scope is taken from the panel's registerPanelAgents map and
142
+ // merged with any explicit opts.agents.
143
+ // opts.order — vertical sort weight when several renderers share one slot
144
+ // (lower renders first). Default 100; ties keep registration order.
145
+ // opts.tab — { id, label, badge? } required for tabbed slots: marks this
146
+ // renderer as one tab in the slot's tab bar.
147
+ mount(slot, renderFn, opts) {
148
+ if (PURE || typeof renderFn !== "function") return;
149
+ const explicit = opts && Array.isArray(opts.agents) ? opts.agents : null;
150
+ const panel = (opts && opts.panel) || _currentPanel || null;
151
+ const scoped = explicit || _extAgents[_currentExtId] || null;
152
+ const order = opts && Number.isFinite(opts.order) ? opts.order : 100;
153
+ const tab = (opts && opts.tab) || null;
154
+ (_slotRenderers[slot] ||= []).push({
155
+ fn: _guard(renderFn, `ui.mount(${slot})`, _currentExtId),
156
+ extId: _currentExtId,
157
+ agents: scoped, // explicit/per-extension agent list, or null = global
158
+ panel, // official-panel id, or null
159
+ order,
160
+ tab, // { id, label, badge? } for tabbed slots, else null
161
+ });
162
+ },
163
+
164
+ // Register a host-owned renderer (not attributed to any extension). Used for
165
+ // built-in tabs that must appear for every session regardless of agent
166
+ // scope (e.g. the Files tab). Bypasses PURE so the official UI keeps working
167
+ // in safe mode. agents=null => visible everywhere.
168
+ mountBuiltin(slot, renderFn, opts) {
169
+ if (typeof renderFn !== "function") return;
170
+ const order = opts && Number.isFinite(opts.order) ? opts.order : 100;
171
+ const tab = (opts && opts.tab) || null;
172
+ (_slotRenderers[slot] ||= []).push({
173
+ fn: _guard(renderFn, `ui.mountBuiltin(${slot})`, "host"),
174
+ extId: "host",
175
+ agents: null,
176
+ panel: null,
177
+ order,
178
+ tab,
179
+ });
180
+ },
181
+ };
182
+
183
+ // Decide whether a renderer is visible under the current agent profile.
184
+ // null agents AND null panel => global (always visible). Otherwise the
185
+ // current profile must be in the renderer's agent list, or in the set of
186
+ // agents that reference its panel.
187
+ function _visibleFor(entry, profile) {
188
+ if (!entry.agents && !entry.panel) return true;
189
+ if (entry.agents && entry.agents.includes(profile)) return true;
190
+ if (entry.panel && (_panelAgents[entry.panel] || []).includes(profile)) return true;
191
+ return false;
192
+ }
193
+
194
+ // Render every extension registered for `slot` into `container`, scoped to the
195
+ // current agent profile. Called by the host's view layer wherever it exposes a
196
+ // slot, and re-called on session switch (the container is cleared first so a
197
+ // previous agent's panels don't linger). Each renderer is isolated: if one
198
+ // throws (guarded -> returns undefined) or yields nothing, a degraded
199
+ // placeholder marked data-ext-status="crashed" is shown for it, and sibling
200
+ // renderers / the rest of the page are unaffected.
201
+ function renderSlot(slot, container, ctx) {
202
+ if (!container) return;
203
+ if (TABBED_SLOTS[slot]) {
204
+ renderTabbedSlot(slot, container, ctx);
205
+ return;
206
+ }
207
+ container.replaceChildren(); // clear stale render from a previous agent
208
+ const renderers = _slotRenderers[slot];
209
+ if (!renderers || renderers.length === 0) return;
210
+
211
+ const profile = (ctx && ctx.agentProfile) || context.agentProfile;
212
+ const renderCtx = Object.assign({}, context, ctx || {});
213
+ // Lower order renders first; sort is stable so equal orders keep
214
+ // registration order. slice() avoids mutating the registry.
215
+ const ordered = renderers.slice().sort((a, b) => a.order - b.order);
216
+ ordered.forEach((entry) => {
217
+ if (!_visibleFor(entry, profile)) return;
218
+ const { fn, extId } = entry;
219
+ let node;
220
+ let crashed = false;
221
+ try {
222
+ node = fn(renderCtx);
223
+ if (node === undefined) crashed = true; // guard swallowed a throw
224
+ } catch (_e) {
225
+ crashed = true; // defensive: should already be guarded
226
+ }
227
+
228
+ if (crashed) {
229
+ const ph = document.createElement("div");
230
+ ph.setAttribute("data-ext-status", "crashed");
231
+ ph.setAttribute("data-ext-id", extId || "");
232
+ ph.className = "ext-slot-crashed";
233
+ ph.textContent = "Extension failed to render.";
234
+ container.appendChild(ph);
235
+ return;
236
+ }
237
+
238
+ if (node == null) return; // nothing to render is valid
239
+ if (typeof node === "string") {
240
+ const wrap = document.createElement("div");
241
+ wrap.setAttribute("data-ext-id", extId || "");
242
+ wrap.innerHTML = node;
243
+ container.appendChild(wrap);
244
+ } else {
245
+ container.appendChild(node);
246
+ }
247
+ });
248
+ }
249
+
250
+ // Render a tabbed slot: a tab bar across the top, one body shown at a time.
251
+ // Each visible renderer (carrying entry.tab) becomes a tab; its body is
252
+ // rendered lazily on first activation and cached for the lifetime of this
253
+ // render pass. The host chrome (resize / collapse) lives in the surrounding
254
+ // DOM and is not touched here. Re-rendered wholesale on every session switch.
255
+ function renderTabbedSlot(slot, container, ctx) {
256
+ container.replaceChildren();
257
+ const profile = (ctx && ctx.agentProfile) || context.agentProfile;
258
+ const renderCtx = Object.assign({}, context, ctx || {});
259
+
260
+ const renderers = (_slotRenderers[slot] || [])
261
+ .slice()
262
+ .sort((a, b) => a.order - b.order)
263
+ .filter((e) => e.tab && _visibleFor(e, profile));
264
+
265
+ if (renderers.length === 0) return; // empty slot → host CSS collapses it
266
+
267
+ const tabBar = document.createElement("div");
268
+ tabBar.className = "aside-tabs";
269
+ const bodies = document.createElement("div");
270
+ bodies.className = "aside-bodies";
271
+
272
+ // Restore previously active tab if still present, else first tab.
273
+ const ids = renderers.map((e) => e.tab.id);
274
+ let active = _activeTab[slot];
275
+ if (!active || !ids.includes(active)) active = ids[0];
276
+
277
+ const tabBtns = {};
278
+ const bodyEls = {};
279
+ const rendered = {};
280
+
281
+ function activate(id) {
282
+ _activeTab[slot] = id;
283
+ Object.keys(tabBtns).forEach((k) => tabBtns[k].classList.toggle("active", k === id));
284
+ Object.keys(bodyEls).forEach((k) => bodyEls[k].classList.toggle("active", k === id));
285
+ if (!rendered[id]) {
286
+ rendered[id] = true;
287
+ const entry = renderers.find((e) => e.tab.id === id);
288
+ const body = bodyEls[id];
289
+ const localCtx = Object.assign({}, renderCtx, {
290
+ setBadge: (n) => _setTabBadge(tabBtns[id], n),
291
+ });
292
+ let node, crashed = false;
293
+ try {
294
+ node = entry.fn(localCtx);
295
+ if (node === undefined) crashed = true;
296
+ } catch (_e) { crashed = true; }
297
+ if (crashed) {
298
+ const ph = document.createElement("div");
299
+ ph.className = "ext-slot-crashed";
300
+ ph.textContent = "Extension failed to render.";
301
+ body.appendChild(ph);
302
+ } else if (node != null) {
303
+ if (typeof node === "string") body.innerHTML = node;
304
+ else body.appendChild(node);
305
+ }
306
+ }
307
+ }
308
+
309
+ renderers.forEach((entry) => {
310
+ const id = entry.tab.id;
311
+ const btn = document.createElement("button");
312
+ btn.className = "aside-tab";
313
+ btn.type = "button";
314
+ btn.setAttribute("data-tab", id);
315
+ const label = document.createElement("span");
316
+ label.textContent = entry.tab.label || id;
317
+ btn.appendChild(label);
318
+ if (entry.tab.badge != null) _setTabBadge(btn, entry.tab.badge);
319
+ btn.addEventListener("click", () => activate(id));
320
+ tabBtns[id] = btn;
321
+ tabBar.appendChild(btn);
322
+
323
+ const body = document.createElement("div");
324
+ body.className = "aside-panel";
325
+ body.setAttribute("data-panel", id);
326
+ bodyEls[id] = body;
327
+ bodies.appendChild(body);
328
+ });
329
+
330
+ container.appendChild(tabBar);
331
+ container.appendChild(bodies);
332
+ activate(active);
333
+ }
334
+
335
+ // Set or clear a tab's badge pill. n == null/0 removes it.
336
+ function _setTabBadge(btn, n) {
337
+ if (!btn) return;
338
+ let badge = btn.querySelector(".aside-tab-badge");
339
+ if (n == null || n === 0 || n === "") {
340
+ if (badge) badge.remove();
341
+ return;
342
+ }
343
+ if (!badge) {
344
+ badge = document.createElement("span");
345
+ badge.className = "aside-tab-badge";
346
+ btn.appendChild(badge);
347
+ }
348
+ badge.textContent = String(n);
349
+ }
350
+
351
+ // List slot names that currently have at least one renderer (host/debug use).
352
+ function slots() {
353
+ return Object.keys(_slotRenderers).filter((s) => _slotRenderers[s].length > 0);
354
+ }
355
+
356
+ // Update the current session context (host calls this on every session
357
+ // switch) and re-render all slots so panels match the new agent profile.
358
+ function setContext(next) {
359
+ Object.assign(context, next || {});
360
+ refreshSlots();
361
+ }
362
+
363
+ // Re-render every named slot present in the DOM against the current context.
364
+ // Idempotent: each slot's container is cleared before re-rendering.
365
+ function refreshSlots() {
366
+ if (PURE) return;
367
+ document.querySelectorAll("[data-slot]").forEach((el) => {
368
+ renderSlot(el.getAttribute("data-slot"), el);
369
+ });
370
+ }
371
+
372
+ return {
373
+ get pure() { return PURE; },
374
+ context,
375
+ setContext,
376
+ refreshSlots,
377
+ registerPanelAgents,
378
+ api,
379
+ ui,
380
+ subscribe,
381
+ emit,
382
+ renderSlot,
383
+ slots,
384
+ _extBegin, // used by the loader; not part of the public extension API
385
+ _extEnd, // used by the loader; not part of the public extension API
386
+ };
387
+ })();
@@ -0,0 +1,92 @@
1
+ // ── Backup · store — backup status/config + network ───────────────────────
2
+ //
3
+ // Owns the backup status/config state and the network calls to
4
+ // /api/backup/{status,config,download}. It never renders.
5
+ //
6
+ // `Backup` stays the single public facade.
7
+ //
8
+ // Depends on: Clacky.ext.
9
+ // ───────────────────────────────────────────────────────────────────────────
10
+
11
+ const BackupStore = (() => {
12
+ let _status = null;
13
+ let _saving = false;
14
+
15
+ const _listeners = {};
16
+
17
+ function _on(event, handler) {
18
+ (_listeners[event] ||= []).push(handler);
19
+ return () => {
20
+ const list = _listeners[event];
21
+ const i = list ? list.indexOf(handler) : -1;
22
+ if (i >= 0) list.splice(i, 1);
23
+ };
24
+ }
25
+
26
+ function _emit(event, payload) {
27
+ (_listeners[event] || []).forEach((h) => h(payload));
28
+ if (window.Clacky && Clacky.ext) Clacky.ext.emit(event, payload);
29
+ }
30
+
31
+ const state = {
32
+ get status() { return _status; },
33
+ get config() { return (_status && _status.config) || {}; },
34
+ };
35
+
36
+ const Backup = {
37
+ on: _on,
38
+ state,
39
+
40
+ async load() {
41
+ try {
42
+ const res = await fetch("/api/backup/status");
43
+ _status = await res.json();
44
+ _emit("backup:changed");
45
+ } catch (e) {
46
+ // Backup section is non-critical; fail quietly.
47
+ }
48
+ },
49
+
50
+ async saveConfig(patch) {
51
+ if (_saving) return { ok: false };
52
+ _saving = true;
53
+ try {
54
+ const res = await fetch("/api/backup/config", {
55
+ method: "PATCH",
56
+ headers: { "Content-Type": "application/json" },
57
+ body: JSON.stringify(patch)
58
+ });
59
+ const data = await res.json();
60
+ if (data.ok) { _status = data.status; _emit("backup:changed"); }
61
+ return data;
62
+ } catch (e) {
63
+ return { ok: false, error: e.message };
64
+ } finally {
65
+ _saving = false;
66
+ }
67
+ },
68
+
69
+ /** Fetch a one-off archive. Returns { ok, blob, filename, error }. */
70
+ async fetchArchive() {
71
+ try {
72
+ const res = await fetch("/api/backup/download");
73
+ if (!res.ok) {
74
+ let msg = "failed";
75
+ try { msg = (await res.json()).error || msg; } catch (e) {}
76
+ throw new Error(msg);
77
+ }
78
+ const blob = await res.blob();
79
+ const cd = res.headers.get("Content-Disposition") || "";
80
+ const m = cd.match(/filename="?([^"]+)"?/);
81
+ const filename = (m && m[1]) || "clacky-backup.tar.gz";
82
+ return { ok: true, blob, filename };
83
+ } catch (e) {
84
+ return { ok: false, error: e.message };
85
+ }
86
+ },
87
+ };
88
+
89
+ return Backup;
90
+ })();
91
+
92
+ const Backup = BackupStore;
@@ -0,0 +1,94 @@
1
+ // ── Backup · view — settings rendering, toggles, download UI ──────────────
2
+ //
3
+ // Renders the backup settings panel, wires the toggles + download button, and
4
+ // drives the file download from a blob. Reads through BackupStore.state; all
5
+ // I/O goes through store actions. Re-renders on store change events.
6
+ //
7
+ // Augments the `Backup` facade with load (re-exposing the store action so the
8
+ // existing Settings caller keeps working).
9
+ //
10
+ // Depends on: BackupStore, I18n.
11
+ // ───────────────────────────────────────────────────────────────────────────
12
+
13
+ const BackupView = (() => {
14
+ const $ = (id) => document.getElementById(id);
15
+
16
+ function _fmtDate(iso) {
17
+ if (!iso) return "";
18
+ try { return new Date(iso).toLocaleString(); } catch (e) { return iso; }
19
+ }
20
+
21
+ function _render() {
22
+ const cfg = Backup.state.config;
23
+ if (!Backup.state.status) return;
24
+
25
+ const incl = $("backup-include-sessions");
26
+ if (incl) {
27
+ incl.checked = cfg.include_sessions !== false;
28
+ incl.disabled = !cfg.enabled;
29
+ }
30
+
31
+ const autoToggle = $("backup-auto-toggle");
32
+ if (autoToggle) autoToggle.checked = !!cfg.enabled;
33
+
34
+ _renderLastRun(cfg);
35
+ }
36
+
37
+ function _renderLastRun(cfg) {
38
+ const el = $("backup-status");
39
+ if (!el) return;
40
+ if (!cfg.last_run_at) { el.textContent = ""; el.className = "model-test-result"; return; }
41
+ if (cfg.last_status === "error") {
42
+ el.textContent = I18n.t("settings.backup.lastError", { msg: cfg.last_error || "" });
43
+ el.className = "model-test-result error";
44
+ } else {
45
+ el.textContent = I18n.t("settings.backup.lastOk", { time: _fmtDate(cfg.last_run_at) });
46
+ el.className = "model-test-result success";
47
+ }
48
+ }
49
+
50
+ async function _downloadNow() {
51
+ const btn = $("btn-backup-now");
52
+ const el = $("backup-status");
53
+ if (btn) btn.disabled = true;
54
+ if (el) { el.textContent = I18n.t("settings.backup.running"); el.className = "model-test-result"; }
55
+
56
+ const res = await Backup.fetchArchive();
57
+ if (res.ok) {
58
+ const url = URL.createObjectURL(res.blob);
59
+ const a = document.createElement("a");
60
+ a.href = url;
61
+ a.download = res.filename;
62
+ document.body.appendChild(a);
63
+ a.click();
64
+ a.remove();
65
+ URL.revokeObjectURL(url);
66
+ if (el) { el.textContent = I18n.t("settings.backup.downloaded"); el.className = "model-test-result success"; }
67
+ } else if (el) {
68
+ el.textContent = I18n.t("settings.backup.lastError", { msg: res.error });
69
+ el.className = "model-test-result error";
70
+ }
71
+ if (btn) btn.disabled = false;
72
+ }
73
+
74
+ function _bind() {
75
+ const btn = $("btn-backup-now");
76
+ if (btn) btn.addEventListener("click", _downloadNow);
77
+
78
+ const autoToggle = $("backup-auto-toggle");
79
+ if (autoToggle) autoToggle.addEventListener("change", () => Backup.saveConfig({ enabled: autoToggle.checked }));
80
+
81
+ const incl = $("backup-include-sessions");
82
+ if (incl) incl.addEventListener("change", () => Backup.saveConfig({ include_sessions: incl.checked }));
83
+ }
84
+
85
+ function _subscribe() {
86
+ Backup.on("backup:changed", _render);
87
+ document.addEventListener("DOMContentLoaded", _bind);
88
+ }
89
+
90
+ return { init: _subscribe };
91
+ })();
92
+
93
+ BackupView.init();
94
+ window.Backup = Backup;