@nightkatana/kronosys-app 1.0.0-beta.14 → 1.0.0-beta.16

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.
@@ -0,0 +1,151 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { NextResponse } from "next/server";
4
+
5
+ import type { KronosysUpdatePayload } from "@/lib/kronosysApi";
6
+ import { ensureDataDirectory } from "@/lib/dataDir";
7
+ import { getSqlite, resetSqliteConnection } from "@/server/db";
8
+ import { writePayload } from "@/server/payloadStore";
9
+
10
+ export const runtime = "nodejs";
11
+
12
+ const DB_NAME = "kronosys.sqlite";
13
+ const MAX_UPLOAD_BYTES = 64 * 1024 * 1024;
14
+
15
+ function nowStamp(): string {
16
+ return new Date().toISOString().replace(/\.\d{3}Z$/, "Z").replaceAll(":", "-");
17
+ }
18
+
19
+ function isRecord(v: unknown): v is Record<string, unknown> {
20
+ return typeof v === "object" && v !== null && !Array.isArray(v);
21
+ }
22
+
23
+ function validatePayloadShape(v: unknown): v is KronosysUpdatePayload {
24
+ if (!isRecord(v)) {
25
+ return false;
26
+ }
27
+ return typeof v.viewType === "string" && v.viewType.trim().length > 0;
28
+ }
29
+
30
+ function cleanSqliteSidecars(dbPath: string): void {
31
+ for (const ext of [".wal", ".shm"]) {
32
+ const p = `${dbPath}${ext}`;
33
+ try {
34
+ if (fs.existsSync(p)) {
35
+ fs.unlinkSync(p);
36
+ }
37
+ } catch {
38
+ /* ignore */
39
+ }
40
+ }
41
+ }
42
+
43
+ function restoreFromSqliteBuffer(buf: Buffer): { backupPath: string | null } {
44
+ const dir = ensureDataDirectory();
45
+ const dbPath = path.join(dir, DB_NAME);
46
+ const tmpPath = `${dbPath}.restore-tmp`;
47
+ const backupPath = `${dbPath}.pre-restore-${nowStamp()}.bak`;
48
+ let createdBackup: string | null = null;
49
+
50
+ resetSqliteConnection();
51
+ if (fs.existsSync(dbPath)) {
52
+ fs.copyFileSync(dbPath, backupPath);
53
+ createdBackup = backupPath;
54
+ }
55
+ cleanSqliteSidecars(dbPath);
56
+ fs.writeFileSync(tmpPath, buf);
57
+ fs.renameSync(tmpPath, dbPath);
58
+
59
+ try {
60
+ const db = getSqlite();
61
+ db.pragma("integrity_check");
62
+ db.exec("SELECT 1;");
63
+ } catch (error) {
64
+ resetSqliteConnection();
65
+ if (createdBackup && fs.existsSync(createdBackup)) {
66
+ fs.copyFileSync(createdBackup, dbPath);
67
+ }
68
+ throw error;
69
+ }
70
+
71
+ return { backupPath: createdBackup };
72
+ }
73
+
74
+ export async function POST(req: Request) {
75
+ const ctype = req.headers.get("content-type") ?? "";
76
+ const isMultipart = ctype.toLowerCase().includes("multipart/form-data");
77
+ if (!isMultipart) {
78
+ return NextResponse.json(
79
+ { ok: false, error: "unsupported_content_type" },
80
+ { status: 415 },
81
+ );
82
+ }
83
+
84
+ const form = await req.formData();
85
+ const file = form.get("file");
86
+ const formatField = form.get("format");
87
+ const formatRaw =
88
+ typeof formatField === "string" ? formatField.trim().toLowerCase() : "";
89
+ if (!(file instanceof File)) {
90
+ return NextResponse.json({ ok: false, error: "file_required" }, { status: 400 });
91
+ }
92
+ if (file.size <= 0 || file.size > MAX_UPLOAD_BYTES) {
93
+ return NextResponse.json(
94
+ { ok: false, error: "invalid_file_size", maxBytes: MAX_UPLOAD_BYTES },
95
+ { status: 400 },
96
+ );
97
+ }
98
+
99
+ const lowerName = file.name.toLowerCase();
100
+ let format: "json" | "sqlite";
101
+ if (formatRaw === "json" || formatRaw === "sqlite") {
102
+ format = formatRaw;
103
+ } else {
104
+ format = lowerName.endsWith(".json") ? "json" : "sqlite";
105
+ }
106
+
107
+ if (format === "json") {
108
+ const text = await file.text();
109
+ let parsed: unknown;
110
+ try {
111
+ parsed = JSON.parse(text);
112
+ } catch {
113
+ return NextResponse.json({ ok: false, error: "invalid_json" }, { status: 400 });
114
+ }
115
+ if (!validatePayloadShape(parsed)) {
116
+ return NextResponse.json(
117
+ { ok: false, error: "invalid_kronosys_payload" },
118
+ { status: 400 },
119
+ );
120
+ }
121
+ writePayload(parsed);
122
+ return NextResponse.json({ ok: true, restored: "json" });
123
+ }
124
+
125
+ const bytes = await file.arrayBuffer();
126
+ const buf = Buffer.from(bytes);
127
+ if (buf.length < 1024) {
128
+ return NextResponse.json(
129
+ { ok: false, error: "sqlite_too_small" },
130
+ { status: 400 },
131
+ );
132
+ }
133
+ const header = buf.subarray(0, 16).toString("utf8");
134
+ if (!header.startsWith("SQLite format 3")) {
135
+ return NextResponse.json(
136
+ { ok: false, error: "invalid_sqlite_file" },
137
+ { status: 400 },
138
+ );
139
+ }
140
+
141
+ try {
142
+ const { backupPath } = restoreFromSqliteBuffer(buf);
143
+ return NextResponse.json({ ok: true, restored: "sqlite", backupPath });
144
+ } catch (error) {
145
+ const detail = error instanceof Error ? error.message : String(error);
146
+ return NextResponse.json(
147
+ { ok: false, error: "restore_failed", detail },
148
+ { status: 500 },
149
+ );
150
+ }
151
+ }
@@ -3,6 +3,7 @@
3
3
  import { Suspense, useMemo } from "react";
4
4
  import Link from "next/link";
5
5
  import { useSearchParams } from "next/navigation";
6
+ import ReactMarkdown, { type Components } from "react-markdown";
6
7
  import { AppVersionStamp } from "@/components/dashboard/AppVersionStamp";
7
8
  import { ThemeToggle } from "@/components/dashboard/ThemeToggle";
8
9
  import { PageRefreshButton } from "@/components/dashboard/PageRefreshButton";
@@ -18,6 +19,55 @@ import { withDashboardSessionParam } from "@/lib/dashboardSessionNav";
18
19
 
19
20
  type LiveShape = { language?: string };
20
21
 
22
+ type ChangelogItemGroup = {
23
+ heading: string;
24
+ items: string[];
25
+ };
26
+
27
+ const CHANGELOG_MARKDOWN_COMPONENTS: Components = {
28
+ p: ({ children }) => <span>{children}</span>,
29
+ a: ({ children, href }) => (
30
+ <a
31
+ href={href}
32
+ className="text-violet-700 underline decoration-violet-400/80 underline-offset-2 hover:text-violet-900 dark:text-violet-300 dark:decoration-violet-500/60 dark:hover:text-violet-200"
33
+ target="_blank"
34
+ rel="noopener noreferrer"
35
+ >
36
+ {children}
37
+ </a>
38
+ ),
39
+ code: ({ children }) => (
40
+ <code className="rounded bg-zinc-200/70 px-1 py-0.5 font-mono text-[0.82em] dark:bg-zinc-700/80">
41
+ {children}
42
+ </code>
43
+ ),
44
+ };
45
+
46
+ function groupChangelogItems(items: string[]): ChangelogItemGroup[] {
47
+ const groups: ChangelogItemGroup[] = [];
48
+ const indexByHeading = new Map<string, number>();
49
+ const fallbackHeading = "Notes";
50
+
51
+ for (const raw of items) {
52
+ const item = raw.trim();
53
+ const m = /^([^—:]+)\s*[—:]\s*(.+)$/u.exec(item);
54
+ const heading = m?.[1]?.trim() || fallbackHeading;
55
+ const content = m?.[2]?.trim() || item;
56
+ if (!content) {
57
+ continue;
58
+ }
59
+ const existingIndex = indexByHeading.get(heading);
60
+ if (existingIndex !== undefined) {
61
+ groups[existingIndex].items.push(content);
62
+ continue;
63
+ }
64
+ indexByHeading.set(heading, groups.length);
65
+ groups.push({ heading, items: [content] });
66
+ }
67
+
68
+ return groups;
69
+ }
70
+
21
71
  function ChangelogBody() {
22
72
  const searchParams = useSearchParams();
23
73
  const dashboardSessionNavId = searchParams.get("session");
@@ -90,11 +140,26 @@ function ChangelogBody() {
90
140
  <h2 className="text-base font-semibold text-zinc-900 dark:text-zinc-100">
91
141
  v{entry.version}
92
142
  </h2>
93
- <ul className="mt-3 list-inside list-disc space-y-1.5 text-sm leading-relaxed text-zinc-700 dark:text-zinc-300">
94
- {entry.items.map((item) => (
95
- <li key={item}>{item}</li>
143
+ <div className="mt-3 space-y-3">
144
+ {groupChangelogItems(entry.items).map((group) => (
145
+ <div key={group.heading} className="space-y-1.5">
146
+ <h3 className="text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
147
+ {group.heading}
148
+ </h3>
149
+ <ul className="list-inside list-disc space-y-1.5 text-sm leading-relaxed text-zinc-700 dark:text-zinc-300">
150
+ {group.items.map((item) => (
151
+ <li key={`${group.heading}:${item}`}>
152
+ <ReactMarkdown
153
+ components={CHANGELOG_MARKDOWN_COMPONENTS}
154
+ >
155
+ {item}
156
+ </ReactMarkdown>
157
+ </li>
158
+ ))}
159
+ </ul>
160
+ </div>
96
161
  ))}
97
- </ul>
162
+ </div>
98
163
  </section>
99
164
  ))
100
165
  )}
@@ -3,7 +3,7 @@
3
3
  import { Suspense, useCallback, useEffect, useMemo, useState } from "react";
4
4
  import Link from "next/link";
5
5
  import { useSearchParams } from "next/navigation";
6
- import { Check, FileCode2, X } from "lucide-react";
6
+ import { Check, FileCode2, ScrollText, X } from "lucide-react";
7
7
  import { AppVersionStamp } from "@/components/dashboard/AppVersionStamp";
8
8
  import { ThemeToggle } from "@/components/dashboard/ThemeToggle";
9
9
  import { PageRefreshButton } from "@/components/dashboard/PageRefreshButton";
@@ -153,6 +153,17 @@ function ImplementationBody() {
153
153
  <div className="flex w-full justify-end">
154
154
  <div className={appShellHeaderToolbarClassName}>
155
155
  <AppShellCommandCenterPlaceholder />
156
+ <Link
157
+ href={withDashboardSessionParam(
158
+ "/changelog",
159
+ dashboardSessionNavId,
160
+ )}
161
+ className="inline-flex size-10 shrink-0 items-center justify-center rounded-lg border border-zinc-300 bg-white text-zinc-700 shadow-sm transition hover:border-zinc-400 hover:bg-zinc-50 hover:text-violet-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-violet-500 dark:border-zinc-600 dark:bg-zinc-800/90 dark:text-zinc-200 dark:hover:border-zinc-500 dark:hover:bg-zinc-800 dark:hover:text-violet-300"
162
+ aria-label={notes.openChangelogAria}
163
+ title={notes.openChangelogTooltip}
164
+ >
165
+ <ScrollText size={18} />
166
+ </Link>
156
167
  <AppShellRouteNav
157
168
  current="implementation"
158
169
  labels={nav}
package/app/page.tsx CHANGED
@@ -232,6 +232,7 @@ function DashboardHome() {
232
232
  >("keep");
233
233
  const [tourOpen, setTourOpen] = useState(false);
234
234
  const [newSessionModalOpen, setNewSessionModalOpen] = useState(false);
235
+ const pendingNewSessionFocusRef = useRef(false);
235
236
  const [gitBannerDismissed, setGitBannerDismissed] = useState(false);
236
237
  const [gitIdentitySetupModalOpen, setGitIdentitySetupModalOpen] =
237
238
  useState(false);
@@ -894,6 +895,32 @@ function DashboardHome() {
894
895
  });
895
896
  }, []);
896
897
 
898
+ const focusTaskLauncherInput = useCallback(() => {
899
+ document.getElementById("dashboard-col-tasks")?.scrollIntoView({
900
+ behavior: "smooth",
901
+ block: "start",
902
+ });
903
+ const tryFocus = () => {
904
+ const input = document.getElementById(
905
+ "kronosys-task-launcher-input",
906
+ ) as HTMLInputElement | null;
907
+ if (!input) {
908
+ return false;
909
+ }
910
+ input.focus();
911
+ input.select();
912
+ return true;
913
+ };
914
+ globalThis.requestAnimationFrame(() => {
915
+ if (tryFocus()) {
916
+ return;
917
+ }
918
+ globalThis.setTimeout(() => {
919
+ void tryFocus();
920
+ }, 140);
921
+ });
922
+ }, []);
923
+
897
924
  const scrollToTaskInPanel = useCallback((taskId: string) => {
898
925
  const el =
899
926
  document.getElementById(`kronosys-active-task-${taskId}`) ??
@@ -1209,6 +1236,45 @@ function DashboardHome() {
1209
1236
  await refresh();
1210
1237
  }, [pathname, refresh, router, searchParams, sessionQueryMode]);
1211
1238
 
1239
+ useEffect(() => {
1240
+ if (!pendingNewSessionFocusRef.current) {
1241
+ return;
1242
+ }
1243
+ const liveSid =
1244
+ typeof live?.sessionId === "string" ? live.sessionId.trim() : "";
1245
+ if (!liveSid) {
1246
+ return;
1247
+ }
1248
+ pendingNewSessionFocusRef.current = false;
1249
+ focusTaskLauncherInput();
1250
+ }, [focusTaskLauncherInput, live?.sessionId]);
1251
+
1252
+ const createNewSessionAndFocus = useCallback(
1253
+ async (sessionScope: unknown) => {
1254
+ setNewSessionModalOpen(false);
1255
+ pendingNewSessionFocusRef.current = true;
1256
+ await post({ type: "newSession", sessionScope });
1257
+ if (sessionQueryMode) {
1258
+ router.replace(
1259
+ pathnameWithUpdatedSessionQuery(
1260
+ pathname,
1261
+ searchParams.toString(),
1262
+ null,
1263
+ ),
1264
+ );
1265
+ }
1266
+ focusTaskLauncherInput();
1267
+ },
1268
+ [
1269
+ focusTaskLauncherInput,
1270
+ pathname,
1271
+ post,
1272
+ router,
1273
+ searchParams,
1274
+ sessionQueryMode,
1275
+ ],
1276
+ );
1277
+
1212
1278
  const openSessionInNewTab = useCallback(
1213
1279
  (sessionId: string) => {
1214
1280
  const url = `${
@@ -1576,7 +1642,7 @@ function DashboardHome() {
1576
1642
  <div className="grid w-full grid-cols-1 gap-8 xl:grid-cols-[minmax(0,1fr)_minmax(0,2.25fr)_minmax(0,1fr)] xl:items-start xl:gap-x-6 2xl:gap-x-10">
1577
1643
  <div
1578
1644
  id="dashboard-col-sessions"
1579
- className="flex min-w-0 flex-col xl:sticky xl:top-44 xl:max-h-[calc(100vh-12rem)] xl:min-h-0 xl:overflow-hidden xl:pb-6 xl:pr-3 2xl:pr-4"
1645
+ className="flex min-w-0 flex-col xl:min-h-0 xl:overflow-visible xl:pb-6 xl:pr-3 2xl:pr-4"
1580
1646
  >
1581
1647
  <div className="w-full min-w-0 xl:flex xl:min-h-0 xl:flex-1 xl:flex-col">
1582
1648
  <div className="mx-auto flex w-full max-w-md flex-col gap-8 sm:max-w-lg xl:mx-0 xl:max-w-none xl:min-h-0 xl:flex-1 xl:flex-col">
@@ -1611,53 +1677,59 @@ function DashboardHome() {
1611
1677
  ? renderSelectedSessionSidebarCard()
1612
1678
  : null}
1613
1679
 
1614
- <SessionListPanel
1615
- sessions={sessionListPanelSessions}
1616
- lang={lang}
1617
- displayTimeZone={dashboardDisplayTimeZone}
1618
- use24HourClock={dashboardUse24HourClock}
1619
- liveSessionId={live?.sessionId}
1620
- selectedSessionId={selectedSessionId}
1621
- t={dt}
1622
- onSelectSession={(id) => void handleSelectSession(id)}
1623
- onOpenSessionInNewTab={openSessionInNewTab}
1624
- onEndLiveSession={() =>
1625
- void handleRequestEndLiveSession()
1626
- }
1627
- onArchiveSession={confirmArchiveSession}
1628
- onDeleteSession={(id) => setDeleteSessionId(id)}
1629
- onOpenArchives={() =>
1630
- void router.push(
1631
- withDashboardSessionParam(
1632
- "/settings#settings-archived-sessions",
1633
- selectedSessionId,
1634
- ),
1635
- )
1636
- }
1637
- archivedCount={historyArchived.length}
1638
- mongoPushEnabled={mongoPushEnabled}
1639
- onPushSessionToMongo={(id) =>
1640
- void handlePushSessionToMongo(id)
1641
- }
1642
- pushingSessionId={mongoPushBusyId}
1643
- sessionDurationAlertThresholdMinutes={
1644
- sessionDurationAlertThresholdMinutes
1645
- }
1646
- onOpenSessionGantt={openGanttForSessionId}
1647
- sessionRowExitAnimateId={sessionRowExitAnimateId}
1648
- onSessionRowExitAnimationDone={clearSessionRowExitStates}
1649
- liveChromeExitSessionId={endLiveListExitAnimateId}
1650
- sortPinSessionId={endLiveListExitAnimateId}
1651
- sessionDetailInline={
1652
- archiveSessionInMainHistoryList &&
1653
- typeof columnArchiveId === "string"
1654
- ? {
1655
- sessionId: columnArchiveId,
1656
- content: renderSelectedSessionSidebarCard(),
1657
- }
1658
- : null
1659
- }
1660
- />
1680
+ <div className="min-h-[14rem] xl:h-full xl:min-h-0 xl:flex-1">
1681
+ <SessionListPanel
1682
+ sessions={sessionListPanelSessions}
1683
+ lang={lang}
1684
+ displayTimeZone={dashboardDisplayTimeZone}
1685
+ use24HourClock={dashboardUse24HourClock}
1686
+ liveSessionId={live?.sessionId}
1687
+ selectedSessionId={selectedSessionId}
1688
+ t={dt}
1689
+ onSelectSession={(id) => void handleSelectSession(id)}
1690
+ onOpenSessionInNewTab={openSessionInNewTab}
1691
+ onEndLiveSession={() =>
1692
+ void handleRequestEndLiveSession()
1693
+ }
1694
+ onArchiveSession={confirmArchiveSession}
1695
+ onDeleteSession={(id) => setDeleteSessionId(id)}
1696
+ onOpenArchives={() =>
1697
+ void router.push(
1698
+ withDashboardSessionParam(
1699
+ "/settings#settings-archived-sessions",
1700
+ selectedSessionId,
1701
+ ),
1702
+ )
1703
+ }
1704
+ archivedCount={historyArchived.length}
1705
+ mongoPushEnabled={mongoPushEnabled}
1706
+ onPushSessionToMongo={(id) =>
1707
+ void handlePushSessionToMongo(id)
1708
+ }
1709
+ pushingSessionId={mongoPushBusyId}
1710
+ sessionDurationAlertThresholdMinutes={
1711
+ sessionDurationAlertThresholdMinutes
1712
+ }
1713
+ onOpenSessionGantt={openGanttForSessionId}
1714
+ sessionRowExitAnimateId={sessionRowExitAnimateId}
1715
+ onSessionRowExitAnimationDone={
1716
+ clearSessionRowExitStates
1717
+ }
1718
+ liveChromeExitSessionId={endLiveListExitAnimateId}
1719
+ sortPinSessionId={endLiveListExitAnimateId}
1720
+ sessionDetailInline={
1721
+ archiveSessionInMainHistoryList &&
1722
+ typeof columnArchiveId === "string" &&
1723
+ columnArchiveId.length > 0
1724
+ ? {
1725
+ sessionId: columnArchiveId,
1726
+ content: renderSelectedSessionSidebarCard(),
1727
+ }
1728
+ : null
1729
+ }
1730
+ forcePageScroll
1731
+ />
1732
+ </div>
1661
1733
  </div>
1662
1734
  </div>
1663
1735
  </div>
@@ -1742,8 +1814,7 @@ function DashboardHome() {
1742
1814
  t={dt}
1743
1815
  onCancel={() => setNewSessionModalOpen(false)}
1744
1816
  onConfirm={(sessionScope) => {
1745
- setNewSessionModalOpen(false);
1746
- void post({ type: "newSession", sessionScope });
1817
+ void createNewSessionAndFocus(sessionScope);
1747
1818
  }}
1748
1819
  />
1749
1820
  <GlobalPauseConfirmModal
@@ -45,7 +45,6 @@ import { showWorkspaceFoldersEmptyMessage } from "@/lib/usageProfile";
45
45
  import { readDashboardUse24HourClockFromCfg } from "@/lib/dashboardClockFormat";
46
46
  import {
47
47
  DASHBOARD_TIME_ZONE_SELECT_OPTIONS,
48
- isValidIanaTimeZone,
49
48
  readDashboardTimeZoneFromCfg,
50
49
  } from "@/lib/dashboardTimeZone";
51
50
  import { SESSION_NAME_TEMPLATE_CFG_MAX_LEN } from "@/lib/formatSessionNameTemplate";
@@ -648,7 +647,7 @@ function SettingsToc({
648
647
  return (
649
648
  <nav
650
649
  aria-label={s.tocNavAriaLabel}
651
- className="rounded-xl border border-zinc-200 bg-zinc-50/90 p-4 lg:max-h-[calc(100vh-12rem)] lg:overflow-y-auto lg:overscroll-contain dark:border-zinc-800 dark:bg-zinc-900/40"
650
+ className="rounded-xl border border-zinc-200 bg-zinc-50/90 p-4 lg:max-h-[calc(100dvh-14rem)] lg:overflow-y-scroll lg:overscroll-contain dark:border-zinc-800 dark:bg-zinc-900/40"
652
651
  >
653
652
  <p className="mb-3 text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-500">
654
653
  {s.tocHeading}
@@ -952,6 +951,10 @@ function SettingsPageContent() {
952
951
  >(null);
953
952
  const [clearHistoryConfirmOpen, setClearHistoryConfirmOpen] = useState(false);
954
953
  const [clearHistoryBusy, setClearHistoryBusy] = useState(false);
954
+ const [restoreConfirmOpen, setRestoreConfirmOpen] = useState(false);
955
+ const [restoreBusy, setRestoreBusy] = useState(false);
956
+ const [restoreFile, setRestoreFile] = useState<File | null>(null);
957
+ const restoreFileInputRef = useRef<HTMLInputElement | null>(null);
955
958
  const [settingsTocFilter, setSettingsTocFilter] = useState("");
956
959
  const [settingsTourOpen, setSettingsTourOpen] = useState(false);
957
960
 
@@ -1557,6 +1560,56 @@ function SettingsPageContent() {
1557
1560
  }
1558
1561
  }, [refresh, router]);
1559
1562
 
1563
+ const restoreBackup = useCallback(async () => {
1564
+ if (!restoreFile) {
1565
+ setSettingsDialogAlert(s.dangerRestoreNoFile);
1566
+ return;
1567
+ }
1568
+ setRestoreBusy(true);
1569
+ try {
1570
+ const fd = new FormData();
1571
+ fd.set("file", restoreFile);
1572
+ const format = restoreFile.name.toLowerCase().endsWith(".json")
1573
+ ? "json"
1574
+ : "sqlite";
1575
+ fd.set("format", format);
1576
+ const res = await fetch("/api/restore", {
1577
+ method: "POST",
1578
+ body: fd,
1579
+ });
1580
+ const text = await res.text();
1581
+ let data: Record<string, unknown> = {};
1582
+ try {
1583
+ data = text ? (JSON.parse(text) as Record<string, unknown>) : {};
1584
+ } catch {
1585
+ data = {};
1586
+ }
1587
+ if (!res.ok || data.ok !== true) {
1588
+ const detail =
1589
+ typeof data.detail === "string"
1590
+ ? data.detail
1591
+ : typeof data.error === "string"
1592
+ ? data.error
1593
+ : text || `HTTP ${res.status}`;
1594
+ setSettingsDialogAlert(
1595
+ `${s.dangerRestoreFailed.replace("{detail}", detail)}`,
1596
+ );
1597
+ return;
1598
+ }
1599
+ setRestoreConfirmOpen(false);
1600
+ setRestoreFile(null);
1601
+ if (restoreFileInputRef.current) {
1602
+ restoreFileInputRef.current.value = "";
1603
+ }
1604
+ await refresh({ preserveForm: false, routerInvalidate: true });
1605
+ setSettingsDialogAlert(
1606
+ format === "json" ? s.dangerRestoreDoneJson : s.dangerRestoreDoneSqlite,
1607
+ );
1608
+ } finally {
1609
+ setRestoreBusy(false);
1610
+ }
1611
+ }, [refresh, restoreFile, s]);
1612
+
1560
1613
  const applyResetToDefaults = useCallback(async () => {
1561
1614
  setResetDefaultsBusy(true);
1562
1615
  setSettingsAck(null);
@@ -3491,6 +3544,64 @@ function SettingsPageContent() {
3491
3544
  {s.sectionDangerZone}
3492
3545
  </h2>
3493
3546
  <div className="max-w-xl rounded-xl border border-red-300/90 bg-red-50/80 p-4 dark:border-red-900/55 dark:bg-red-950/30">
3547
+ <h3 className="text-sm font-semibold text-red-900 dark:text-red-200">
3548
+ {s.dangerBackupRestoreTitle}
3549
+ </h3>
3550
+ <p className="mt-2 text-xs leading-relaxed text-red-900/85 dark:text-red-200/85">
3551
+ {s.dangerBackupRestoreIntro}
3552
+ </p>
3553
+ <div className="mt-3 flex flex-wrap gap-2">
3554
+ <a
3555
+ href="/api/backup?format=json"
3556
+ download
3557
+ className="rounded-lg border border-red-500/70 px-3 py-1.5 text-xs font-medium text-red-900 hover:bg-red-100 dark:text-red-200 dark:hover:bg-red-900/40"
3558
+ >
3559
+ {s.dangerClearHistoryBackupJson}
3560
+ </a>
3561
+ <a
3562
+ href="/api/backup?format=csv-zip"
3563
+ download
3564
+ className="rounded-lg border border-red-500/70 px-3 py-1.5 text-xs font-medium text-red-900 hover:bg-red-100 dark:text-red-200 dark:hover:bg-red-900/40"
3565
+ >
3566
+ {s.dangerClearHistoryBackupCsvZip}
3567
+ </a>
3568
+ <a
3569
+ href="/api/backup?format=sqlite"
3570
+ download
3571
+ className="rounded-lg border border-red-500/70 px-3 py-1.5 text-xs font-medium text-red-900 hover:bg-red-100 dark:text-red-200 dark:hover:bg-red-900/40"
3572
+ >
3573
+ {s.dangerClearHistoryBackupSqlite}
3574
+ </a>
3575
+ </div>
3576
+ <div className="mt-4 space-y-2">
3577
+ <label className="block text-xs font-medium text-red-900/90 dark:text-red-200/90">
3578
+ {s.dangerRestorePickFile}
3579
+ </label>
3580
+ <input
3581
+ ref={restoreFileInputRef}
3582
+ type="file"
3583
+ accept=".json,.sqlite,.db,application/json,application/vnd.sqlite3"
3584
+ disabled={restoreBusy || clearHistoryBusy}
3585
+ className="block w-full rounded-lg border border-red-300 bg-white px-3 py-2 text-xs text-zinc-800 file:mr-3 file:rounded file:border-0 file:bg-red-100 file:px-2.5 file:py-1 file:text-xs file:font-medium file:text-red-900 dark:border-red-900/60 dark:bg-zinc-950 dark:text-zinc-200 dark:file:bg-red-900/35 dark:file:text-red-200"
3586
+ onChange={(e) => {
3587
+ const f = e.target.files?.[0] ?? null;
3588
+ setRestoreFile(f);
3589
+ }}
3590
+ />
3591
+ <p className="text-[11px] leading-relaxed text-red-900/80 dark:text-red-300/80">
3592
+ {s.dangerRestoreHint}
3593
+ </p>
3594
+ <button
3595
+ type="button"
3596
+ disabled={!restoreFile || restoreBusy || clearHistoryBusy}
3597
+ className="rounded-lg border border-red-600/80 bg-red-700 px-3 py-1.5 text-xs font-medium text-white hover:bg-red-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-red-500 dark:bg-red-800/90 dark:hover:bg-red-700"
3598
+ onClick={() => setRestoreConfirmOpen(true)}
3599
+ >
3600
+ {restoreBusy
3601
+ ? s.dangerRestoreBusy
3602
+ : s.dangerRestoreButton}
3603
+ </button>
3604
+ </div>
3494
3605
  <h3 className="text-sm font-semibold text-red-900 dark:text-red-200">
3495
3606
  {s.dangerClearHistoryTitle}
3496
3607
  </h3>
@@ -3535,6 +3646,23 @@ function SettingsPageContent() {
3535
3646
  onCancel={() => setMongoResyncConfirmOpen(false)}
3536
3647
  onConfirm={() => void executeMongoResync()}
3537
3648
  />
3649
+ <DashboardConfirmModal
3650
+ open={restoreConfirmOpen}
3651
+ title={s.dangerRestoreButton}
3652
+ message={s.dangerRestoreConfirm}
3653
+ cancelLabel={s.dialogCancelBtn}
3654
+ confirmLabel={s.dialogConfirmBtn}
3655
+ confirmVariant="danger"
3656
+ pending={restoreBusy}
3657
+ onCancel={() => {
3658
+ if (!restoreBusy) {
3659
+ setRestoreConfirmOpen(false);
3660
+ }
3661
+ }}
3662
+ onConfirm={() => {
3663
+ void restoreBackup();
3664
+ }}
3665
+ />
3538
3666
  <DashboardConfirmModal
3539
3667
  open={clearHistoryConfirmOpen}
3540
3668
  title={s.dangerClearHistoryTitle}
package/bin/kronosys.mjs CHANGED
@@ -5,6 +5,7 @@
5
5
  * Supported commands:
6
6
  * start -> next start -p 5555 (production, default; auto-build if needed)
7
7
  * dev -> next dev --webpack -H kronosys -p 5555
8
+ * --version, -v, version -> print installed Kronosys version
8
9
  */
9
10
 
10
11
  import { spawn } from "node:child_process";
@@ -29,6 +30,11 @@ const psBin = process.platform === "win32" ? "powershell.exe" : null;
29
30
 
30
31
  const [, , command = "start", ...rest] = process.argv;
31
32
 
33
+ if (command === "--version" || command === "-v" || command === "version") {
34
+ console.log(packageVersion);
35
+ process.exit(0);
36
+ }
37
+
32
38
  const commands = {
33
39
  start: [npxBin, "next", "start", "-p", "5555", ...rest],
34
40
  dev: [