openclacky 1.3.1 → 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 (78) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +44 -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 +65 -11
  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/brand_config.rb +1 -1
  11. data/lib/clacky/default_agents/_panels/git/panel.js +201 -0
  12. data/lib/clacky/default_agents/_panels/time_machine/panel.js +640 -0
  13. data/lib/clacky/default_agents/coding/profile.yml +3 -0
  14. data/lib/clacky/default_agents/coding/webui/.gitkeep +0 -0
  15. data/lib/clacky/default_skills/cron-task-creator/SKILL.md +1 -1
  16. data/lib/clacky/default_skills/extend-openclacky/SKILL.md +6 -4
  17. data/lib/clacky/default_skills/media-gen/SKILL.md +30 -6
  18. data/lib/clacky/media/openai_compat.rb +64 -1
  19. data/lib/clacky/media/output_dir.rb +43 -0
  20. data/lib/clacky/message_history.rb +9 -0
  21. data/lib/clacky/server/channel/channel_manager.rb +26 -0
  22. data/lib/clacky/server/git_panel.rb +115 -0
  23. data/lib/clacky/server/http_server.rb +521 -13
  24. data/lib/clacky/server/server_master.rb +6 -4
  25. data/lib/clacky/utils/environment_detector.rb +16 -0
  26. data/lib/clacky/version.rb +1 -1
  27. data/lib/clacky/web/app.css +512 -60
  28. data/lib/clacky/web/app.js +30 -7
  29. data/lib/clacky/web/components/code-editor.js +197 -0
  30. data/lib/clacky/web/{notify.js → components/notify.js} +1 -1
  31. data/lib/clacky/web/core/aside.js +112 -0
  32. data/lib/clacky/web/core/ext.js +387 -0
  33. data/lib/clacky/web/features/backup/store.js +92 -0
  34. data/lib/clacky/web/features/backup/view.js +94 -0
  35. data/lib/clacky/web/features/billing/store.js +163 -0
  36. data/lib/clacky/web/{billing.js → features/billing/view.js} +134 -242
  37. data/lib/clacky/web/features/brand/store.js +110 -0
  38. data/lib/clacky/web/{brand.js → features/brand/view.js} +49 -199
  39. data/lib/clacky/web/features/channels/store.js +103 -0
  40. data/lib/clacky/web/{channels.js → features/channels/view.js} +50 -127
  41. data/lib/clacky/web/features/creator/store.js +81 -0
  42. data/lib/clacky/web/{creator.js → features/creator/view.js} +53 -102
  43. data/lib/clacky/web/features/mcp/store.js +158 -0
  44. data/lib/clacky/web/{mcp.js → features/mcp/view.js} +57 -134
  45. data/lib/clacky/web/features/model-tester/store.js +77 -0
  46. data/lib/clacky/web/features/model-tester/view.js +7 -0
  47. data/lib/clacky/web/features/profile/store.js +170 -0
  48. data/lib/clacky/web/{profile.js → features/profile/view.js} +94 -144
  49. data/lib/clacky/web/features/share/store.js +145 -0
  50. data/lib/clacky/web/{share.js → features/share/view.js} +66 -202
  51. data/lib/clacky/web/features/skills/store.js +303 -0
  52. data/lib/clacky/web/features/skills/view.js +550 -0
  53. data/lib/clacky/web/features/tasks/store.js +135 -0
  54. data/lib/clacky/web/features/tasks/view.js +241 -0
  55. data/lib/clacky/web/features/trash/store.js +242 -0
  56. data/lib/clacky/web/{trash.js → features/trash/view.js} +102 -293
  57. data/lib/clacky/web/features/version/store.js +165 -0
  58. data/lib/clacky/web/features/version/view.js +323 -0
  59. data/lib/clacky/web/features/workspace/store.js +99 -0
  60. data/lib/clacky/web/features/workspace/view.js +305 -0
  61. data/lib/clacky/web/i18n.js +60 -6
  62. data/lib/clacky/web/index.html +117 -57
  63. data/lib/clacky/web/sessions.js +221 -25
  64. data/lib/clacky/web/settings.js +121 -25
  65. data/lib/clacky/web/skills.js +3 -821
  66. data/lib/clacky/web/vendor/codemirror/codemirror.min.js +29 -0
  67. data/lib/clacky.rb +1 -0
  68. metadata +45 -20
  69. data/lib/clacky/web/backup.js +0 -119
  70. data/lib/clacky/web/model-tester.js +0 -66
  71. data/lib/clacky/web/tasks.js +0 -365
  72. data/lib/clacky/web/version.js +0 -449
  73. data/lib/clacky/web/workspace.js +0 -212
  74. /data/lib/clacky/web/{notify.mp3 → assets/notify.mp3} +0 -0
  75. /data/lib/clacky/web/{datepicker.js → components/datepicker.js} +0 -0
  76. /data/lib/clacky/web/{onboard.js → components/onboard.js} +0 -0
  77. /data/lib/clacky/web/{sidebar.js → components/sidebar.js} +0 -0
  78. /data/lib/clacky/web/{marked.min.js → vendor/marked/marked.min.js} +0 -0
@@ -0,0 +1,241 @@
1
+ // ── Tasks · view — rendering, DOM event wiring ─────────────────────────────
2
+ //
3
+ // Owns everything that touches the DOM: the task table, sidebar label, panel
4
+ // wiring. Reads data through TasksStore.state and reacts to store events via
5
+ // Tasks.on(...). Calls store actions; never fetches data itself.
6
+ //
7
+ // Augments the `Tasks` facade with the UI methods other modules invoke
8
+ // (onPanelShow / renderSection / renderTable).
9
+ //
10
+ // Depends on: TasksStore, I18n/Router, global $ / escapeHtml helpers.
11
+ // ───────────────────────────────────────────────────────────────────────────
12
+
13
+ const TasksView = (() => {
14
+
15
+ function _humanCron(cron) {
16
+ if (!cron) return cron;
17
+ const parts = cron.trim().split(/\s+/);
18
+ if (parts.length !== 5) return cron;
19
+ const [min, hour, dom, month, dow] = parts;
20
+
21
+ const isAny = v => v === "*";
22
+ const isNum = v => /^\d+$/.test(v);
23
+ const pad = n => String(n).padStart(2, "0");
24
+
25
+ const lang = (typeof I18n !== "undefined" && I18n.lang()) || "zh";
26
+ const isZh = lang === "zh";
27
+
28
+ const dowNames = isZh
29
+ ? ["周日","周一","周二","周三","周四","周五","周六"]
30
+ : ["Sun","Mon","Tue","Wed","Thu","Fri","Sat"];
31
+
32
+ const monthNames = isZh
33
+ ? ["1月","2月","3月","4月","5月","6月","7月","8月","9月","10月","11月","12月"]
34
+ : ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
35
+
36
+ const timeStr = (isNum(hour) && isNum(min)) ? `${pad(hour)}:${pad(min)}` : null;
37
+
38
+ if (min.startsWith("*/") && isAny(hour) && isAny(dom) && isAny(month) && isAny(dow)) {
39
+ const n = min.slice(2);
40
+ return isZh ? `每 ${n} 分钟` : `Every ${n} min`;
41
+ }
42
+ if ((isAny(min) || isNum(min)) && hour.startsWith("*/") && isAny(dom) && isAny(month) && isAny(dow)) {
43
+ const n = hour.slice(2);
44
+ return isZh ? `每 ${n} 小时` : `Every ${n} hr`;
45
+ }
46
+ if (isAny(min) && isAny(hour) && isAny(dom) && isAny(month) && isAny(dow)) {
47
+ return isZh ? "每分钟" : "Every minute";
48
+ }
49
+ if (isNum(min) && isAny(hour) && isAny(dom) && isAny(month) && isAny(dow)) {
50
+ return isZh ? `每小时 :${pad(min)}` : `Hourly at :${pad(min)}`;
51
+ }
52
+ if (timeStr && isAny(dom) && isAny(month) && isNum(dow)) {
53
+ const d = dowNames[parseInt(dow, 10)] || dow;
54
+ return isZh ? `每${d} ${timeStr}` : `${d} ${timeStr}`;
55
+ }
56
+ if (timeStr && isAny(dom) && isAny(month) && dow === "1-5") {
57
+ return isZh ? `工作日 ${timeStr}` : `Weekdays ${timeStr}`;
58
+ }
59
+ if (timeStr && isAny(dom) && isAny(month) && (dow === "0,6" || dow === "6,0")) {
60
+ return isZh ? `周末 ${timeStr}` : `Weekends ${timeStr}`;
61
+ }
62
+ if (timeStr && isAny(dom) && isAny(month) && isAny(dow)) {
63
+ return isZh ? `每天 ${timeStr}` : `Daily ${timeStr}`;
64
+ }
65
+ if (timeStr && isNum(dom) && isAny(month) && isAny(dow)) {
66
+ return isZh ? `每月 ${dom} 日 ${timeStr}` : `Monthly day ${dom} ${timeStr}`;
67
+ }
68
+ if (timeStr && isNum(dom) && isNum(month) && isAny(dow)) {
69
+ const m = monthNames[parseInt(month, 10) - 1] || month;
70
+ return isZh ? `${m}${dom}日 ${timeStr}` : `${m} ${dom} ${timeStr}`;
71
+ }
72
+
73
+ return cron;
74
+ }
75
+
76
+ function _renderTaskRow(t) {
77
+ const row = document.createElement("div");
78
+ row.className = "task-card";
79
+ row.dataset.name = t.name;
80
+
81
+ const isPaused = t.scheduled && t.enabled === false;
82
+ row.classList.toggle("task-card-paused", isPaused);
83
+
84
+ const schedLabel = t.scheduled
85
+ ? `<span class="task-card-cron" title="${escapeHtml(t.cron)}">${escapeHtml(_humanCron(t.cron))}</span>`
86
+ : `<span class="task-card-cron task-card-cron-manual">${I18n.t("tasks.manual")}</span>`;
87
+
88
+ const pausedBadge = isPaused
89
+ ? `<span class="task-card-badge task-card-badge-paused">${I18n.t("tasks.paused")}</span>`
90
+ : "";
91
+
92
+ const content = t.content || "";
93
+ const isTruncated = content.trim().length > 0;
94
+ const previewText = escapeHtml(content.replace(/\s+/g, " ").trim()) || escapeHtml(I18n.t("tasks.empty"));
95
+
96
+ const toggleBtnHtml = t.scheduled ? (isPaused
97
+ ? `<button class="task-action-btn task-btn-toggle task-btn-resume">
98
+ <svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
99
+ <polygon points="6 3 20 12 6 21 6 3"/>
100
+ </svg>
101
+ <span>${I18n.t("tasks.btn.resume")}</span>
102
+ </button>`
103
+ : `<button class="task-action-btn task-btn-toggle task-btn-pause">
104
+ <svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
105
+ <rect x="6" y="4" width="4" height="16" rx="1"/>
106
+ <rect x="14" y="4" width="4" height="16" rx="1"/>
107
+ </svg>
108
+ <span>${I18n.t("tasks.btn.pause")}</span>
109
+ </button>`
110
+ ) : "";
111
+
112
+ row.innerHTML = `
113
+ <div class="task-card-main">
114
+ <div class="task-card-icon">
115
+ <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
116
+ <circle cx="12" cy="12" r="10"/>
117
+ <polyline points="12 6 12 12 16 14"/>
118
+ </svg>
119
+ </div>
120
+ <div class="task-card-info">
121
+ <div class="task-card-title-row">
122
+ <span class="task-card-name">${escapeHtml(t.name)}</span>
123
+ ${pausedBadge}
124
+ ${schedLabel}
125
+ </div>
126
+ <div class="task-card-preview${isTruncated ? " task-card-preview-expandable" : ""}">${previewText}</div>
127
+ </div>
128
+ <div class="task-card-actions">
129
+ <button class="task-run-btn task-btn-run" title="${I18n.t("tasks.btn.run")}">
130
+ <svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
131
+ <polygon points="6 3 20 12 6 21 6 3"/>
132
+ </svg>
133
+ <span>${I18n.t("tasks.btn.run")}</span>
134
+ </button>
135
+ ${toggleBtnHtml}
136
+ <button class="task-action-btn task-btn-edit">
137
+ <svg xmlns="http://www.w3.org/2000/svg" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
138
+ <path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/>
139
+ <path d="m15 5 4 4"/>
140
+ </svg>
141
+ <span>${I18n.t("tasks.btn.edit")}</span>
142
+ </button>
143
+ <button class="task-action-btn task-btn-del">
144
+ <svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
145
+ <path d="M3 6h18"/><path d="M19 6l-1 14H6L5 6"/><path d="M8 6V4h8v2"/>
146
+ </svg>
147
+ <span>${I18n.t("tasks.btn.delete")}</span>
148
+ </button>
149
+ </div>
150
+ </div>
151
+ ${isTruncated ? `<div class="task-card-detail" hidden><pre class="task-card-detail-content">${escapeHtml(content)}</pre></div>` : ""}`;
152
+
153
+ row.querySelector(".task-btn-run").addEventListener("click", e => {
154
+ e.stopPropagation();
155
+ Tasks.run(t.name);
156
+ });
157
+
158
+ if (isTruncated) {
159
+ const previewEl = row.querySelector(".task-card-preview");
160
+ const detailEl = row.querySelector(".task-card-detail");
161
+ previewEl.addEventListener("click", e => {
162
+ e.stopPropagation();
163
+ const expanded = !detailEl.hidden;
164
+ detailEl.hidden = expanded;
165
+ row.classList.toggle("task-card-expanded", !expanded);
166
+ });
167
+ }
168
+ const toggleBtn = row.querySelector(".task-btn-toggle");
169
+ if (toggleBtn) {
170
+ toggleBtn.addEventListener("click", e => {
171
+ e.stopPropagation();
172
+ Tasks.toggleEnabled(t.name, isPaused);
173
+ });
174
+ }
175
+ row.querySelector(".task-btn-edit").addEventListener("click", e => {
176
+ e.stopPropagation();
177
+ Tasks.editInSession(t.name);
178
+ });
179
+ row.querySelector(".task-btn-del").addEventListener("click", e => {
180
+ e.stopPropagation();
181
+ Tasks.delete(t.name);
182
+ });
183
+
184
+ return row;
185
+ }
186
+
187
+ function _renderTable() {
188
+ const table = $("task-list-table");
189
+ if (!table) return;
190
+ table.innerHTML = "";
191
+
192
+ const tasks = TasksStore.state.tasks;
193
+ if (tasks.length === 0) {
194
+ const empty = document.createElement("div");
195
+ empty.className = "task-table-empty";
196
+ empty.innerHTML = `
197
+ <p>${I18n.t("tasks.noScheduled")}</p>
198
+ <button class="task-create-btn" id="btn-create-task-empty">
199
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon-sm">
200
+ <path d="M5 12h14"/>
201
+ <path d="M12 5v14"/>
202
+ </svg> ${I18n.t("tasks.btn.createTask")}
203
+ </button>`;
204
+ table.appendChild(empty);
205
+ const btn = table.querySelector("#btn-create-task-empty");
206
+ if (btn) btn.addEventListener("click", () => Tasks.createInSession());
207
+ return;
208
+ }
209
+
210
+ tasks.forEach(t => table.appendChild(_renderTaskRow(t)));
211
+ }
212
+
213
+ function _renderSection() {
214
+ const labelEl = $("tasks-sidebar-label");
215
+ if (!labelEl) return;
216
+ labelEl.textContent = I18n.t("sidebar.tasks");
217
+ }
218
+
219
+ function _subscribe() {
220
+ Tasks.on("tasks:changed", () => {
221
+ _renderSection();
222
+ if (Router.current === "tasks") _renderTable();
223
+ });
224
+ }
225
+
226
+ const viewApi = {
227
+ renderSection: _renderSection,
228
+ renderTable: _renderTable,
229
+
230
+ onPanelShow() {
231
+ Tasks.load();
232
+ const btn = $("btn-create-task");
233
+ if (btn) btn.onclick = () => Tasks.createInSession();
234
+ },
235
+ };
236
+
237
+ return { init: _subscribe, api: viewApi };
238
+ })();
239
+
240
+ Object.assign(Tasks, TasksView.api);
241
+ TasksView.init();
@@ -0,0 +1,242 @@
1
+ // ── Trash · store — recycle-bin data (files + sessions) + network ──────────
2
+ //
3
+ // Owns the file-trash and session-trash lists/totals plus every network call:
4
+ // load, restore, delete, and bulk empty (by age / all / orphans). Mutates local
5
+ // state optimistically and emits change events. It never renders.
6
+ //
7
+ // The orphan heuristic lives here (state classification, not presentation).
8
+ // Emits mirror to the extension bus via Clacky.ext.emit.
9
+ //
10
+ // `Trash` stays the single public facade.
11
+ //
12
+ // Depends on: Sessions, Clacky.ext.
13
+ // ───────────────────────────────────────────────────────────────────────────
14
+
15
+ const TrashStore = (() => {
16
+ let _files = [];
17
+ let _totals = { count: 0, size: 0 };
18
+ let _sessions = [];
19
+ let _sessionTotals = { count: 0, size: 0 };
20
+ let _loading = false;
21
+
22
+ const _listeners = {};
23
+
24
+ function _on(event, handler) {
25
+ (_listeners[event] ||= []).push(handler);
26
+ return () => {
27
+ const list = _listeners[event];
28
+ const i = list ? list.indexOf(handler) : -1;
29
+ if (i >= 0) list.splice(i, 1);
30
+ };
31
+ }
32
+
33
+ function _emit(event, payload) {
34
+ (_listeners[event] || []).forEach((h) => h(payload));
35
+ if (window.Clacky && Clacky.ext) Clacky.ext.emit(event, payload);
36
+ }
37
+
38
+ function _isOrphanRoot(root) {
39
+ root = root || "";
40
+ return /^\/(?:var\/folders|tmp|private\/var\/folders)\b/.test(root) ||
41
+ /\/d\d{8}-\d+-[a-z0-9]+(?:\/|$)/.test(root);
42
+ }
43
+
44
+ const state = {
45
+ get files() { return _files; },
46
+ get totals() { return _totals; },
47
+ get sessions() { return _sessions; },
48
+ get sessionTotals() { return _sessionTotals; },
49
+ orphanCount() { return _files.filter(f => _isOrphanRoot(f.project_root)).length; },
50
+ isOrphan(file) { return _isOrphanRoot(file.project_root); },
51
+ };
52
+
53
+ const Trash = {
54
+ on: _on,
55
+ state,
56
+
57
+ async loadFiles() {
58
+ if (_loading) return;
59
+ _loading = true;
60
+ _emit("trash:filesLoading");
61
+ try {
62
+ const res = await fetch("/api/trash");
63
+ const data = await res.json();
64
+ if (!res.ok) throw new Error(data.error || "Load failed");
65
+ _files = data.files || [];
66
+ _totals = { count: data.total_count || 0, size: data.total_size || 0 };
67
+ _emit("trash:filesChanged");
68
+ } catch (e) {
69
+ console.error("[Trash] load files failed", e);
70
+ _emit("trash:filesError", { message: e.message });
71
+ } finally {
72
+ _loading = false;
73
+ }
74
+ },
75
+
76
+ async loadSessions() {
77
+ if (_loading) return;
78
+ _loading = true;
79
+ _emit("trash:sessionsLoading");
80
+ try {
81
+ const res = await fetch("/api/trash/sessions");
82
+ const data = await res.json();
83
+ if (!res.ok) throw new Error(data.error || "Load failed");
84
+ _sessions = data.sessions || [];
85
+ _sessionTotals = { count: data.count || 0, size: data.total_size || 0 };
86
+ _emit("trash:sessionsChanged");
87
+ } catch (e) {
88
+ console.error("[Trash] load sessions failed", e);
89
+ _emit("trash:sessionsError", { message: e.message });
90
+ } finally {
91
+ _loading = false;
92
+ }
93
+ },
94
+
95
+ async restoreFile(file) {
96
+ try {
97
+ const res = await fetch("/api/trash/restore", {
98
+ method: "POST",
99
+ headers: { "Content-Type": "application/json" },
100
+ body: JSON.stringify({
101
+ project_root: file.project_root,
102
+ original_path: file.original_path
103
+ })
104
+ });
105
+ const data = await res.json();
106
+ if (!res.ok || !data.ok) return { ok: false, error: data.error || res.statusText };
107
+ _removeFileLocal(file);
108
+ _emit("trash:filesChanged");
109
+ return { ok: true };
110
+ } catch (e) {
111
+ return { ok: false, error: e.message };
112
+ }
113
+ },
114
+
115
+ async deleteFile(file) {
116
+ const url = "/api/trash?" + new URLSearchParams({
117
+ project: file.project_root,
118
+ file: file.original_path
119
+ }).toString();
120
+ try {
121
+ const res = await fetch(url, { method: "DELETE" });
122
+ const data = await res.json();
123
+ if (!res.ok || !data.ok) return { ok: false, error: data.error || res.statusText };
124
+ _removeFileLocal(file);
125
+ _emit("trash:filesChanged");
126
+ return { ok: true };
127
+ } catch (e) {
128
+ return { ok: false, error: e.message };
129
+ }
130
+ },
131
+
132
+ async restoreSession(session) {
133
+ try {
134
+ const res = await fetch("/api/trash/sessions/restore", {
135
+ method: "POST",
136
+ headers: { "Content-Type": "application/json" },
137
+ body: JSON.stringify({ session_id: session.session_id })
138
+ });
139
+ const data = await res.json();
140
+ if (!res.ok || !data.ok) return { ok: false, error: data.error || res.statusText };
141
+ _removeSessionLocal(session);
142
+ _emit("trash:sessionsChanged");
143
+ const restored = data.session;
144
+ if (restored && typeof Sessions !== "undefined") {
145
+ Sessions.add(restored);
146
+ Sessions.renderList();
147
+ }
148
+ return { ok: true, session: restored };
149
+ } catch (e) {
150
+ return { ok: false, error: e.message };
151
+ }
152
+ },
153
+
154
+ async deleteSession(session) {
155
+ try {
156
+ const res = await fetch(`/api/trash/sessions/${encodeURIComponent(session.session_id)}`, { method: "DELETE" });
157
+ const data = await res.json();
158
+ if (!res.ok || !data.ok) return { ok: false, error: data.error || res.statusText };
159
+ _removeSessionLocal(session);
160
+ _emit("trash:sessionsChanged");
161
+ return { ok: true };
162
+ } catch (e) {
163
+ return { ok: false, error: e.message };
164
+ }
165
+ },
166
+
167
+ countMatching(items, daysOld) {
168
+ if (!Array.isArray(items)) return 0;
169
+ if (!daysOld || daysOld <= 0) return items.length;
170
+ const cutoff = Date.now() - daysOld * 86400000;
171
+ return items.filter(it => {
172
+ const t = Date.parse(it.deleted_at || "");
173
+ return !isNaN(t) && t < cutoff;
174
+ }).length;
175
+ },
176
+
177
+ async emptyFilesBulk(daysOld) {
178
+ const url = "/api/trash?" + new URLSearchParams({ days_old: String(daysOld) }).toString();
179
+ try {
180
+ const res = await fetch(url, { method: "DELETE" });
181
+ const data = await res.json();
182
+ if (!res.ok || !data.ok) return { ok: false, error: data.error || res.statusText };
183
+ await Trash.loadFiles();
184
+ return { ok: true, deleted_count: data.deleted_count || 0, freed_size: data.freed_size || 0 };
185
+ } catch (e) {
186
+ return { ok: false, error: e.message };
187
+ }
188
+ },
189
+
190
+ async emptySessionsBulk(daysOld) {
191
+ const url = "/api/trash/sessions?" + new URLSearchParams({ days_old: String(daysOld) }).toString();
192
+ try {
193
+ const res = await fetch(url, { method: "DELETE" });
194
+ const data = await res.json();
195
+ if (!res.ok || !data.ok) return { ok: false, error: data.error || res.statusText };
196
+ await Trash.loadSessions();
197
+ return { ok: true, deleted_count: data.deleted_count || 0 };
198
+ } catch (e) {
199
+ return { ok: false, error: e.message };
200
+ }
201
+ },
202
+
203
+ orphans() {
204
+ return _files.filter(f => _isOrphanRoot(f.project_root));
205
+ },
206
+
207
+ async deleteOneFileRaw(file) {
208
+ const url = "/api/trash?" + new URLSearchParams({
209
+ project: file.project_root,
210
+ file: file.original_path
211
+ }).toString();
212
+ try {
213
+ const r = await fetch(url, { method: "DELETE" });
214
+ const d = await r.json();
215
+ return { ok: r.ok && !!d.ok, freed_size: d.freed_size || 0 };
216
+ } catch (_e) {
217
+ return { ok: false, freed_size: 0 };
218
+ }
219
+ },
220
+ };
221
+
222
+ function _removeFileLocal(file) {
223
+ _files = _files.filter(f =>
224
+ !(f.project_root === file.project_root && f.original_path === file.original_path));
225
+ _totals = {
226
+ count: Math.max(0, _totals.count - 1),
227
+ size: Math.max(0, _totals.size - (file.file_size || 0))
228
+ };
229
+ }
230
+
231
+ function _removeSessionLocal(session) {
232
+ _sessions = _sessions.filter(s => s.session_id !== session.session_id);
233
+ _sessionTotals = {
234
+ count: Math.max(0, _sessionTotals.count - 1),
235
+ size: Math.max(0, _sessionTotals.size - (session.file_size || 0))
236
+ };
237
+ }
238
+
239
+ return Trash;
240
+ })();
241
+
242
+ const Trash = TrashStore;