openclacky 1.3.5 → 1.3.6

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.
@@ -13,9 +13,7 @@ const Sidebar = (() => {
13
13
  function init() {
14
14
  // Settings button toggles between "settings" and "welcome" view.
15
15
  document.getElementById("btn-settings").addEventListener("click", () => {
16
- if (Router.current === "settings") {
17
- Router.navigate("welcome");
18
- } else {
16
+ if (Router.current !== "settings") {
19
17
  Router.navigate("settings");
20
18
  }
21
19
  });
@@ -66,6 +66,29 @@ const BackupStore = (() => {
66
66
  }
67
67
  },
68
68
 
69
+ async openFolder() {
70
+ try {
71
+ await fetch("/api/backup/open-folder", { method: "POST" });
72
+ } catch (e) {
73
+ // Non-critical, fail quietly.
74
+ }
75
+ },
76
+
77
+ /** Upload a tar.gz archive to restore ~/.clacky. Returns { ok, error }. */
78
+ async restore(arrayBuffer) {
79
+ try {
80
+ const res = await fetch("/api/backup/restore", {
81
+ method: "POST",
82
+ headers: { "Content-Type": "application/octet-stream" },
83
+ body: arrayBuffer
84
+ });
85
+ const data = await res.json().catch(() => ({}));
86
+ return res.ok ? { ok: true } : { ok: false, error: data.error || "Restore failed" };
87
+ } catch (e) {
88
+ return { ok: false, error: e.message };
89
+ }
90
+ },
91
+
69
92
  /** Fetch a one-off archive. Returns { ok, blob, filename, error }. */
70
93
  async fetchArchive() {
71
94
  try {
@@ -1,17 +1,8 @@
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
- // ───────────────────────────────────────────────────────────────────────────
1
+ // ── Backup · view — settings rendering, toggles, download/restore UI ──────
12
2
 
13
3
  const BackupView = (() => {
14
4
  const $ = (id) => document.getElementById(id);
5
+ let _restoreStatusEl = null;
15
6
 
16
7
  function _fmtDate(iso) {
17
8
  if (!iso) return "";
@@ -19,8 +10,12 @@ const BackupView = (() => {
19
10
  }
20
11
 
21
12
  function _render() {
22
- const cfg = Backup.state.config;
23
- if (!Backup.state.status) return;
13
+ const cfg = Backup.state.config;
14
+ const status = Backup.state.status;
15
+ if (!status) return;
16
+
17
+ const autoToggle = $("backup-auto-toggle");
18
+ if (autoToggle) autoToggle.checked = !!cfg.enabled;
24
19
 
25
20
  const incl = $("backup-include-sessions");
26
21
  if (incl) {
@@ -28,8 +23,8 @@ const BackupView = (() => {
28
23
  incl.disabled = !cfg.enabled;
29
24
  }
30
25
 
31
- const autoToggle = $("backup-auto-toggle");
32
- if (autoToggle) autoToggle.checked = !!cfg.enabled;
26
+ const destPath = $("backup-dest-path");
27
+ if (destPath) destPath.textContent = status.dest_dir || "—";
33
28
 
34
29
  _renderLastRun(cfg);
35
30
  }
@@ -48,10 +43,10 @@ const BackupView = (() => {
48
43
  }
49
44
 
50
45
  async function _downloadNow() {
51
- const btn = $("btn-backup-now");
52
- const el = $("backup-status");
46
+ const btn = $("btn-backup-now");
47
+ const statusEl = $("backup-manual-status");
53
48
  if (btn) btn.disabled = true;
54
- if (el) { el.textContent = I18n.t("settings.backup.running"); el.className = "model-test-result"; }
49
+ if (statusEl) { statusEl.textContent = I18n.t("settings.backup.downloading"); statusEl.className = "model-test-result"; }
55
50
 
56
51
  const res = await Backup.fetchArchive();
57
52
  if (res.ok) {
@@ -63,14 +58,32 @@ const BackupView = (() => {
63
58
  a.click();
64
59
  a.remove();
65
60
  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";
61
+ if (statusEl) { statusEl.textContent = I18n.t("settings.backup.downloaded"); statusEl.className = "model-test-result success"; }
62
+ } else if (statusEl) {
63
+ statusEl.textContent = I18n.t("settings.backup.lastError", { msg: res.error });
64
+ statusEl.className = "model-test-result error";
70
65
  }
71
66
  if (btn) btn.disabled = false;
72
67
  }
73
68
 
69
+ async function _restoreBackup(e) {
70
+ const file = e.target.files[0];
71
+ if (!file) return;
72
+ e.target.value = "";
73
+
74
+ const statusEl = $("backup-manual-status");
75
+ if (statusEl) { statusEl.textContent = I18n.t("settings.backup.restoring"); statusEl.className = "model-test-result"; }
76
+
77
+ const buf = await file.arrayBuffer();
78
+ const res = await Backup.restore(buf);
79
+ if (res.ok) {
80
+ if (statusEl) { statusEl.textContent = I18n.t("settings.backup.restoreOk"); statusEl.className = "model-test-result success"; }
81
+ _restoreStatusEl = statusEl;
82
+ } else {
83
+ if (statusEl) { statusEl.textContent = res.error || "Restore failed"; statusEl.className = "model-test-result error"; }
84
+ }
85
+ }
86
+
74
87
  function _bind() {
75
88
  const btn = $("btn-backup-now");
76
89
  if (btn) btn.addEventListener("click", _downloadNow);
@@ -80,11 +93,25 @@ const BackupView = (() => {
80
93
 
81
94
  const incl = $("backup-include-sessions");
82
95
  if (incl) incl.addEventListener("change", () => Backup.saveConfig({ include_sessions: incl.checked }));
96
+
97
+ const openBtn = $("btn-backup-open-folder");
98
+ if (openBtn) openBtn.addEventListener("click", () => Backup.openFolder());
99
+
100
+ const restoreInput = $("input-backup-restore");
101
+ if (restoreInput) restoreInput.addEventListener("change", _restoreBackup);
83
102
  }
84
103
 
85
104
  function _subscribe() {
86
105
  Backup.on("backup:changed", _render);
87
106
  document.addEventListener("DOMContentLoaded", _bind);
107
+ WS.onEvent(ev => {
108
+ if (ev.type !== "_ws_connected" || !_restoreStatusEl) return;
109
+ const el = _restoreStatusEl;
110
+ _restoreStatusEl = null;
111
+ el.textContent = I18n.t("settings.backup.restartOk");
112
+ el.className = "model-test-result success";
113
+ if (typeof Settings !== "undefined") Settings.open();
114
+ });
88
115
  }
89
116
 
90
117
  return { init: _subscribe };
@@ -16,58 +16,107 @@ const TasksView = (() => {
16
16
  if (!cron) return cron;
17
17
  const parts = cron.trim().split(/\s+/);
18
18
  if (parts.length !== 5) return cron;
19
- const [min, hour, dom, month, dow] = parts;
19
+ let [min, hour, dom, month, dow] = parts;
20
+
21
+ // normalize */1 → *
22
+ if (min === "*/1") min = "*";
23
+ if (hour === "*/1") hour = "*";
24
+ if (dom === "*/1") dom = "*";
25
+ if (month === "*/1") month = "*";
26
+ if (dow === "*/1") dow = "*";
20
27
 
21
28
  const isAny = v => v === "*";
22
- const isNum = v => /^\d+$/.test(v);
29
+ const isInt = v => /^\d+$/.test(v);
23
30
  const pad = n => String(n).padStart(2, "0");
24
31
 
25
32
  const lang = (typeof I18n !== "undefined" && I18n.lang()) || "zh";
26
33
  const isZh = lang === "zh";
27
34
 
28
- const dowNames = isZh
29
- ? ["周日","周一","周二","周三","周四","周五","周六"]
30
- : ["Sun","Mon","Tue","Wed","Thu","Fri","Sat"];
35
+ const DOW_ZH = ["周日","周一","周二","周三","周四","周五","周六"];
36
+ const DOW_EN = ["Sun","Mon","Tue","Wed","Thu","Fri","Sat"];
37
+ const MON_ZH = ["1月","2月","3月","4月","5月","6月","7月","8月","9月","10月","11月","12月"];
38
+ const MON_EN = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
31
39
 
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"];
40
+ // dow label, e.g. "1-5"→"工作日", "1,3,5"→"周一、周三、周五", "1"→"每周一"
41
+ function dowLabel(d) {
42
+ if (isAny(d)) return null;
43
+ if (d === "1-5") return isZh ? "工作日" : "Weekdays";
44
+ if (d === "0,6" || d === "6,0") return isZh ? "周末" : "Weekends";
45
+ if (isInt(d)) {
46
+ const name = (isZh ? DOW_ZH : DOW_EN)[parseInt(d, 10)] || d;
47
+ return isZh ? `每${name}` : name;
48
+ }
49
+ if (/^[\d,]+$/.test(d)) {
50
+ const names = d.split(",").map(n => (isZh ? DOW_ZH : DOW_EN)[parseInt(n, 10)] || n);
51
+ return isZh ? names.join("、") : names.join("/");
52
+ }
53
+ return d;
54
+ }
35
55
 
36
- const timeStr = (isNum(hour) && isNum(min)) ? `${pad(hour)}:${pad(min)}` : null;
56
+ // build HH:MM string; supports single hour or comma-list like "10,14"
57
+ function timeStr() {
58
+ if (!isInt(min)) return null;
59
+ if (isInt(hour)) return `${pad(hour)}:${pad(min)}`;
60
+ if (/^[\d,]+$/.test(hour))
61
+ return hour.split(",").map(h => `${pad(h)}:${pad(min)}`).join(isZh ? "、" : "/");
62
+ return null;
63
+ }
37
64
 
65
+ // ── every-N-minutes ───────────────────────────────────────────────────
38
66
  if (min.startsWith("*/") && isAny(hour) && isAny(dom) && isAny(month) && isAny(dow)) {
39
67
  const n = min.slice(2);
40
68
  return isZh ? `每 ${n} 分钟` : `Every ${n} min`;
41
69
  }
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`;
70
+ // ── every-N-hours ─────────────────────────────────────────────────────
71
+ if (isAny(dom) && isAny(month) && isAny(dow)) {
72
+ if (isAny(min) && hour.startsWith("*/")) {
73
+ return isZh ? `每 ${hour.slice(2)} 小时` : `Every ${hour.slice(2)} hr`;
74
+ }
75
+ if (isInt(min) && hour.startsWith("*/")) {
76
+ return isZh ? `每 ${hour.slice(2)} 小时` : `Every ${hour.slice(2)} hr`;
77
+ }
45
78
  }
79
+ // ── every minute ──────────────────────────────────────────────────────
46
80
  if (isAny(min) && isAny(hour) && isAny(dom) && isAny(month) && isAny(dow)) {
47
81
  return isZh ? "每分钟" : "Every minute";
48
82
  }
49
- if (isNum(min) && isAny(hour) && isAny(dom) && isAny(month) && isAny(dow)) {
83
+ // ── hourly at :MM ─────────────────────────────────────────────────────
84
+ if (isInt(min) && isAny(hour) && isAny(dom) && isAny(month) && isAny(dow)) {
50
85
  return isZh ? `每小时 :${pad(min)}` : `Hourly at :${pad(min)}`;
51
86
  }
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}`;
87
+
88
+ // ── every-N-min within hour range on certain days e.g. */1 9-14 * * 1-5
89
+ if (min.startsWith("*/") && /^\d+-\d+$/.test(hour) && isAny(dom) && isAny(month)) {
90
+ const n = min.slice(2);
91
+ const dl = dowLabel(dow);
92
+ const hr = isZh ? `${hour}时` : `${hour}h`;
93
+ const ev = isZh ? `每 ${n} 分钟` : `Every ${n} min`;
94
+ return dl ? `${dl} ${hr} ${ev}` : `${hr} ${ev}`;
58
95
  }
59
- if (timeStr && isAny(dom) && isAny(month) && (dow === "0,6" || dow === "6,0")) {
60
- return isZh ? `周末 ${timeStr}` : `Weekends ${timeStr}`;
96
+ // ── every minute within hour range e.g. * 9-14 * * 1-5
97
+ if (isAny(min) && /^\d+-\d+$/.test(hour) && isAny(dom) && isAny(month)) {
98
+ const dl = dowLabel(dow);
99
+ const hr = isZh ? `${hour}时` : `${hour}h`;
100
+ const ev = isZh ? "每分钟" : "Every minute";
101
+ return dl ? `${dl} ${hr} ${ev}` : `${hr} ${ev}`;
61
102
  }
62
- if (timeStr && isAny(dom) && isAny(month) && isAny(dow)) {
63
- return isZh ? `每天 ${timeStr}` : `Daily ${timeStr}`;
103
+
104
+ const ts = timeStr();
105
+ const dl = dowLabel(dow);
106
+
107
+ // ── fixed time, variable days ─────────────────────────────────────────
108
+ if (ts && isAny(dom) && isAny(month)) {
109
+ if (dl) return `${dl} ${ts}`;
110
+ return isZh ? `每天 ${ts}` : `Daily ${ts}`;
64
111
  }
65
- if (timeStr && isNum(dom) && isAny(month) && isAny(dow)) {
66
- return isZh ? `每月 ${dom} ${timeStr}` : `Monthly day ${dom} ${timeStr}`;
112
+ // ── fixed time, fixed day-of-month ────────────────────────────────────
113
+ if (ts && isInt(dom) && isAny(month) && isAny(dow)) {
114
+ return isZh ? `每月 ${dom} 日 ${ts}` : `Monthly day ${dom} ${ts}`;
67
115
  }
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}`;
116
+ // ── fixed time, fixed date ────────────────────────────────────────────
117
+ if (ts && isInt(dom) && isInt(month) && isAny(dow)) {
118
+ const m = (isZh ? MON_ZH : MON_EN)[parseInt(month, 10) - 1] || month;
119
+ return isZh ? `${m}${dom}日 ${ts}` : `${m} ${dom} ${ts}`;
71
120
  }
72
121
 
73
122
  return cron;
@@ -552,6 +552,7 @@ const I18n = (() => {
552
552
  "settings.tabs.models": "Models",
553
553
  "settings.tabs.ui": "UI",
554
554
  "settings.tabs.general": "General",
555
+ "settings.tabs.data": "Data Management",
555
556
  "settings.tabs.about": "About",
556
557
  "settings.about.version": "Version",
557
558
  "settings.about.appName": "Application",
@@ -674,13 +675,22 @@ const I18n = (() => {
674
675
  "settings.network.cleared": "Cleared — direct connection",
675
676
  "settings.network.invalidUrl": "Invalid URL — use http://host:port or http://user:pass@host:port",
676
677
  "settings.backup.title": "Backup",
678
+ "settings.backup.autoTitle": "Auto Backup",
679
+ "settings.backup.manualTitle": "Manual Backup",
677
680
  "settings.backup.desc": "Back up your ~/.clacky directory (config, skills, memories, tasks, sessions). Regenerable caches and logs are excluded.",
678
681
  "settings.backup.includeSessions": "Include session history (larger archive)",
679
682
  "settings.backup.autoLabel": "Automatic backup",
680
683
  "settings.backup.autoHint": "Daily at 03:00, keeps the latest 7",
681
- "settings.backup.runNow": "Download backup (full snapshot)",
684
+ "settings.backup.destLabel": "Backup folder",
685
+ "settings.backup.openFolder": "Open",
686
+ "settings.backup.runNow": "Download backup",
687
+ "settings.backup.restoreBtn": "Restore backup",
682
688
  "settings.backup.running": "Preparing backup…",
689
+ "settings.backup.downloading": "Downloading…",
683
690
  "settings.backup.downloaded": "Backup downloaded.",
691
+ "settings.backup.restoring": "Restoring… server will restart automatically.",
692
+ "settings.backup.restoreOk": "Restored. Restarting…",
693
+ "settings.backup.restartOk": "Restarted successfully.",
684
694
  "settings.backup.lastOk": "Last backup: {{time}}",
685
695
  "settings.backup.lastError": "Backup failed: {{msg}}",
686
696
  "settings.brand.title": "Brand & License",
@@ -1477,6 +1487,7 @@ const I18n = (() => {
1477
1487
  "settings.tabs.models": "模型",
1478
1488
  "settings.tabs.ui": "界面",
1479
1489
  "settings.tabs.general": "通用",
1490
+ "settings.tabs.data": "数据管理",
1480
1491
  "settings.tabs.about": "关于",
1481
1492
  "settings.about.version": "版本",
1482
1493
  "settings.about.appName": "应用程序",
@@ -1606,13 +1617,22 @@ const I18n = (() => {
1606
1617
  "settings.network.cleared": "已清除 — 直连",
1607
1618
  "settings.network.invalidUrl": "地址格式不正确,请使用 http://host:port 或 http://user:pass@host:port",
1608
1619
  "settings.backup.title": "备份",
1620
+ "settings.backup.autoTitle": "自动备份",
1621
+ "settings.backup.manualTitle": "手动备份",
1609
1622
  "settings.backup.desc": "备份你的 ~/.clacky 目录(配置、技能、记忆、任务、会话)。可重新生成的缓存和日志会被排除。",
1610
1623
  "settings.backup.includeSessions": "包含会话历史(归档更大)",
1611
1624
  "settings.backup.autoLabel": "自动备份",
1612
1625
  "settings.backup.autoHint": "每天 03:00 自动备份,保留最近 7 份",
1613
- "settings.backup.runNow": "下载备份(完整快照)",
1626
+ "settings.backup.destLabel": "备份目录",
1627
+ "settings.backup.openFolder": "打开",
1628
+ "settings.backup.runNow": "下载备份",
1629
+ "settings.backup.restoreBtn": "恢复备份",
1614
1630
  "settings.backup.running": "正在准备备份…",
1631
+ "settings.backup.downloading": "正在下载…",
1615
1632
  "settings.backup.downloaded": "备份已下载。",
1633
+ "settings.backup.restoring": "正在恢复…服务将自动重启。",
1634
+ "settings.backup.restoreOk": "恢复成功,正在重启…",
1635
+ "settings.backup.restartOk": "重启成功。",
1616
1636
  "settings.backup.lastOk": "上次备份:{{time}}",
1617
1637
  "settings.backup.lastError": "备份失败:{{msg}}",
1618
1638
  "settings.brand.title": "品牌 & 授权",
@@ -805,6 +805,7 @@
805
805
  <button class="settings-tab active" data-tab="models" data-i18n="settings.tabs.models">Models</button>
806
806
  <button class="settings-tab" data-tab="ui" data-i18n="settings.tabs.ui">UI</button>
807
807
  <button class="settings-tab" data-tab="general" data-i18n="settings.tabs.general">General</button>
808
+ <button class="settings-tab" data-tab="data" data-i18n="settings.tabs.data">Data</button>
808
809
  <button class="settings-tab" data-tab="about" data-i18n="settings.tabs.about">About</button>
809
810
  <!-- Extension tab slot: extensions add custom settings tabs here (button needs data-tab="<id>"). -->
810
811
  <span id="ext-slot-settings-tabs" data-slot="settings.tabs"></span>
@@ -957,32 +958,7 @@
957
958
  </section>
958
959
 
959
960
  <!-- Backup section -->
960
- <section class="settings-section" id="backup-section">
961
- <div class="settings-section-title">
962
- <span data-i18n="settings.backup.title">Backup</span>
963
- </div>
964
- <p class="settings-section-desc" data-i18n="settings.backup.desc">Back up your ~/.clacky directory (config, skills, memories, tasks, sessions). Regenerable caches and logs are excluded.</p>
965
-
966
- <div class="backup-auto-card">
967
- <div class="backup-auto-row">
968
- <label class="toggle-switch">
969
- <input type="checkbox" id="backup-auto-toggle">
970
- <span class="toggle-slider"></span>
971
- </label>
972
- <span class="backup-auto-label" data-i18n="settings.backup.autoLabel">Automatic backup</span>
973
- <span class="backup-auto-hint" data-i18n="settings.backup.autoHint">Daily at 03:00, keeps the latest 7</span>
974
- </div>
975
-
976
- <label class="backup-option backup-option-nested">
977
- <input type="checkbox" id="backup-include-sessions">
978
- <span data-i18n="settings.backup.includeSessions">Include session history (larger archive)</span>
979
- </label>
980
- </div>
981
-
982
- <div class="backup-actions">
983
- <button id="btn-backup-now" class="btn-settings-action" data-i18n="settings.backup.runNow">Download backup (full snapshot)</button>
984
- <span id="backup-status" class="model-test-result"></span>
985
- </div>
961
+ <section class="settings-section" id="backup-section" style="display:none">
986
962
  </section>
987
963
 
988
964
  <!-- Brand & License section -->
@@ -1073,6 +1049,56 @@
1073
1049
  </section>
1074
1050
  </div><!-- end Tab: General -->
1075
1051
 
1052
+ <!-- ══ Tab: Data ══ -->
1053
+ <div class="settings-tab-content" data-tab-content="data" style="display:none">
1054
+
1055
+ <!-- 自动备份 -->
1056
+ <section class="settings-section">
1057
+ <div class="settings-section-title">
1058
+ <span data-i18n="settings.backup.autoTitle">Auto Backup</span>
1059
+ </div>
1060
+ <p class="settings-section-desc" data-i18n="settings.backup.desc">Back up your ~/.clacky directory (config, skills, memories, tasks, sessions). Regenerable caches and logs are excluded.</p>
1061
+
1062
+ <div class="backup-auto-card">
1063
+ <div class="backup-auto-row">
1064
+ <label class="toggle-switch">
1065
+ <input type="checkbox" id="backup-auto-toggle">
1066
+ <span class="toggle-slider"></span>
1067
+ </label>
1068
+ <span class="backup-auto-label" data-i18n="settings.backup.autoLabel">Automatic backup</span>
1069
+ <span class="backup-auto-hint" data-i18n="settings.backup.autoHint">Daily at 03:00, keeps the latest 7</span>
1070
+ </div>
1071
+ <label class="backup-option backup-option-nested">
1072
+ <input type="checkbox" id="backup-include-sessions">
1073
+ <span data-i18n="settings.backup.includeSessions">Include session history (larger archive)</span>
1074
+ </label>
1075
+ <div class="backup-dest-row">
1076
+ <span class="backup-dest-label" data-i18n="settings.backup.destLabel">Backup folder</span>
1077
+ <span class="backup-dest-path" id="backup-dest-path">—</span>
1078
+ <button id="btn-backup-open-folder" class="btn-settings-link" data-i18n="settings.backup.openFolder">Open</button>
1079
+ </div>
1080
+ <div id="backup-status" class="model-test-result"></div>
1081
+ </div>
1082
+ </section>
1083
+
1084
+ <!-- 手动备份 -->
1085
+ <section class="settings-section">
1086
+ <div class="settings-section-title">
1087
+ <span data-i18n="settings.backup.manualTitle">Manual Backup</span>
1088
+ </div>
1089
+
1090
+ <div class="backup-actions">
1091
+ <button id="btn-backup-now" class="btn-settings-action" data-i18n="settings.backup.runNow">Download backup</button>
1092
+ <label class="btn-settings-action btn-settings-action--file">
1093
+ <span data-i18n="settings.backup.restoreBtn">Restore backup</span>
1094
+ <input type="file" id="input-backup-restore" accept=".tar.gz,.gz" style="display:none">
1095
+ </label>
1096
+ <span id="backup-manual-status" class="model-test-result"></span>
1097
+ </div>
1098
+ </section>
1099
+
1100
+ </div><!-- end Tab: Data -->
1101
+
1076
1102
  <!-- ══ Tab: About ══ -->
1077
1103
  <div class="settings-tab-content" data-tab-content="about" style="display:none">
1078
1104
  <!-- Version section -->
@@ -41,6 +41,7 @@ const Sessions = (() => {
41
41
  let _searchToken = 0; // monotonic counter; in-flight requests check against this
42
42
  let _cronView = false; // are we in the cron sub-view?
43
43
  let _cronCount = 0; // total cron sessions from server
44
+ let _latestCronUpdatedAt = null; // updated_at of the newest cron session (for virtual entry sort position)
44
45
  // ── Cron sub-view independent pagination (commit 2) ──────────────────────
45
46
  // The folded cron sub-view paginates *independently* of the outer list so
46
47
  // that "Load more" inside it never advances the outer list's cursor, and so
@@ -2378,11 +2379,12 @@ const Sessions = (() => {
2378
2379
  // ── List management ───────────────────────────────────────────────────
2379
2380
 
2380
2381
  /** Populate list from initial session_list WS event (connect only). */
2381
- setAll(list, hasMore = false, cronCount = 0) {
2382
+ setAll(list, hasMore = false, cronCount = 0, latestCronUpdatedAt = null) {
2382
2383
  _sessions.length = 0;
2383
2384
  _sessions.push(...list);
2384
- _hasMore = !!hasMore;
2385
- _cronCount = cronCount;
2385
+ _hasMore = !!hasMore;
2386
+ _cronCount = cronCount;
2387
+ _latestCronUpdatedAt = latestCronUpdatedAt || null;
2386
2388
  },
2387
2389
 
2388
2390
  /** Insert a newly created session into the local list. */
@@ -2433,6 +2435,14 @@ const Sessions = (() => {
2433
2435
  } else {
2434
2436
  _sessions.unshift({ id, ...fields });
2435
2437
  }
2438
+ // Keep _latestCronUpdatedAt current when a cron session is patched/added.
2439
+ const updated = _sessions.find(s => s.id === id);
2440
+ if (updated?.source === "cron") {
2441
+ const t = updated.updated_at || updated.created_at;
2442
+ if (t && (!_latestCronUpdatedAt || t > _latestCronUpdatedAt)) {
2443
+ _latestCronUpdatedAt = t;
2444
+ }
2445
+ }
2436
2446
  },
2437
2447
 
2438
2448
  /** Remove a session from the list (from session_deleted event). */
@@ -2460,21 +2470,17 @@ const Sessions = (() => {
2460
2470
  Sessions.renderList();
2461
2471
 
2462
2472
  try {
2463
- // Cursor: oldest activity time (updated_at, falling back to
2464
- // created_at) in the current list, EXCLUDING pinned sessions. The
2465
- // backend always returns ALL pinned sessions on the first page (they
2466
- // bypass pagination), so their time is irrelevant for the cursor.
2467
- // Including them here would cause the cursor to jump too far back and
2468
- // skip sessions between the oldest pinned one and the real
2469
- // last-loaded non-pinned row.
2473
+ // Cursor: oldest activity time of non-pinned, non-cron sessions.
2474
+ // Cron sessions are excluded from the normal list pagination and have
2475
+ // their own independent cursor (_cronBefore) in the sub-view.
2470
2476
  const oldest = _sessions.reduce((min, s) => {
2471
- if (s.pinned) return min; // ignore pinned
2477
+ if (s.pinned || s.source === "cron") return min;
2472
2478
  const t = s.updated_at || s.created_at;
2473
2479
  if (!t) return min;
2474
2480
  return (!min || t < min) ? t : min;
2475
2481
  }, null);
2476
2482
 
2477
- const params = new URLSearchParams({ limit: "20" });
2483
+ const params = new URLSearchParams({ limit: "20", exclude_type: "cron" });
2478
2484
  if (oldest) params.set("before", oldest);
2479
2485
  if (_filter.q) params.set("q", _filter.q);
2480
2486
  if (_filter.date) params.set("date", _filter.date);
@@ -2487,8 +2493,9 @@ const Sessions = (() => {
2487
2493
  (data.sessions || []).forEach(s => {
2488
2494
  if (!_sessions.find(x => x.id === s.id)) _sessions.push(s);
2489
2495
  });
2490
- _hasMore = !!data.has_more;
2491
- _cronCount = data.cron_count || 0;
2496
+ _hasMore = !!data.has_more;
2497
+ _cronCount = data.cron_count ?? _cronCount;
2498
+ if (data.latest_cron_updated_at) _latestCronUpdatedAt = data.latest_cron_updated_at;
2492
2499
  } catch (e) {
2493
2500
  console.error("loadMore error:", e);
2494
2501
  } finally {
@@ -2884,35 +2891,28 @@ const Sessions = (() => {
2884
2891
  }
2885
2892
  cronSessions.forEach(s => _renderSessionItem(list, s));
2886
2893
  } else if (_cronCount > 0) {
2887
- // Normal list view: the cron group entry is a *virtual* row that
2888
- // participates in the time-ordering instead of being pinned to the
2889
- // top. We walk the already-sorted `visible` list and drop the group
2890
- // entry at the position of the newest (first-encountered) cron
2891
- // session so it sits exactly where the latest folded cron task
2892
- // would sort by created_at. Pinning is intentionally ignored for the
2893
- // entry itself: a pinned cron session only takes effect *inside* the
2894
- // folded sub-view, not on the outer entry.
2895
- const cronHasRunning = cronSessions.some(s => s.status === "running");
2894
+ // Normal list view: virtual cron group entry positioned by _latestCronUpdatedAt.
2895
+ // Walk the sorted non-cron list and insert the entry at the first row
2896
+ // whose time is older than the newest cron session's updated_at.
2897
+ const cronHasRunning = _sessions.some(s => s.source === "cron" && s.status === "running");
2898
+ const nonCron = visible.filter(s => s.source !== "cron");
2896
2899
  let cronEntryRendered = false;
2897
- visible.forEach(s => {
2898
- if (s.source === "cron") {
2899
- // Render the group entry once, at the first (newest) cron slot;
2900
- // skip every individual cron session in the flat list.
2901
- if (!cronEntryRendered) {
2900
+
2901
+ nonCron.forEach(s => {
2902
+ if (!cronEntryRendered && _latestCronUpdatedAt) {
2903
+ const t = s.updated_at || s.created_at;
2904
+ if (!t || t < _latestCronUpdatedAt) {
2902
2905
  _renderCronGroupItem(list, _cronCount, cronHasRunning);
2903
2906
  cronEntryRendered = true;
2904
2907
  }
2905
- return;
2906
2908
  }
2907
2909
  _renderSessionItem(list, s);
2908
2910
  });
2909
- // NOTE: we intentionally do NOT append a fallback entry when no cron
2910
- // session is loaded yet. The group entry is a virtual row that must
2911
- // ride along with pagination: it only appears at the sort slot of the
2912
- // first loaded cron session. If the newest cron lives on a later page,
2913
- // the entry simply shows up once that page is loaded — rather than
2914
- // being forced onto the bottom of page 1 (which would mislead the
2915
- // sort position and miss the running state of an unpaged cron).
2911
+ // If no insertion point found (all normal sessions are newer, or list is empty),
2912
+ // append the entry at the bottom of the loaded rows.
2913
+ if (!cronEntryRendered) {
2914
+ _renderCronGroupItem(list, _cronCount, cronHasRunning);
2915
+ }
2916
2916
  } else {
2917
2917
  // Normal list view, no cron sessions
2918
2918
  visible.forEach(s => _renderSessionItem(list, s));
@@ -180,7 +180,7 @@ WS.onEvent(ev => {
180
180
 
181
181
  // ── Session list ───────────────────────────────────────────────────
182
182
  case "session_list": {
183
- Sessions.setAll(ev.sessions || [], !!ev.has_more, ev.cron_count || 0);
183
+ Sessions.setAll(ev.sessions || [], !!ev.has_more, ev.cron_count || 0, ev.latest_cron_updated_at || null);
184
184
  Sessions.renderList();
185
185
 
186
186
  // Restore URL hash once on initial connect; ignore subsequent session_list events.
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: openclacky
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.5
4
+ version: 1.3.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - windy