dsh-todo-time-log 0.6.1 → 0.6.2

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.
package/lib/client.js CHANGED
@@ -260,6 +260,42 @@
260
260
  "notice.serverError": "服务器返回了错误。",
261
261
  "toast.undo": "撤销",
262
262
  "toast.dismiss": "关闭提示",
263
+ "transfer.open": "数据",
264
+ "transfer.title": "导入 / 导出",
265
+ "transfer.close": "关闭",
266
+ "transfer.exportTitle": "导出",
267
+ "transfer.exportHint": "导出全部待办。JSON 无损、可回导;CSV 适合用 Excel 统计耗时。",
268
+ "transfer.exportJson": "导出 JSON",
269
+ "transfer.exportCsv": "导出 CSV",
270
+ "transfer.exported": "已导出 {count} 条待办({format})",
271
+ "transfer.importTitle": "导入",
272
+ "transfer.importHint": "支持本插件 JSON(v1–v5)、待办数组或 CSV;导入前会自动备份当前数据。",
273
+ "transfer.chooseFile": "选择文件",
274
+ "transfer.noFile": "未选择文件",
275
+ "transfer.reading": "读取中…",
276
+ "transfer.readError": "读取文件失败:{message}",
277
+ "transfer.emptyFile": "文件内容为空。",
278
+ "transfer.noPreview": "选择文件后先显示预览,确认后才写入。",
279
+ "transfer.previewTitle": "导入预览",
280
+ "transfer.previewAdded": "新增 {count}",
281
+ "transfer.previewUpdated": "更新 {count}",
282
+ "transfer.previewSkipped": "跳过 {count}",
283
+ "transfer.previewRemoved": "移除 {count}",
284
+ "transfer.previewTotal": "合计 {count}",
285
+ "transfer.previewRows": "共读取 {count} 行",
286
+ "transfer.previewInvalid": "非法 {count} 行",
287
+ "transfer.rowError": "第 {row} 行:{message}",
288
+ "transfer.modeLabel": "冲突处理",
289
+ "transfer.modeMerge": "合并(推荐)",
290
+ "transfer.modeMergeHint": "同 ID 以更新时间较新的为准;新 ID 追加为待办。",
291
+ "transfer.modeAdd": "仅新增",
292
+ "transfer.modeAddHint": "已存在的 ID 直接跳过,不修改现有待办。",
293
+ "transfer.modeReplace": "覆盖全部",
294
+ "transfer.modeReplaceHint": "先自动备份,再用文件内容替换全部待办。",
295
+ "transfer.confirm": "开始导入",
296
+ "transfer.importing": "导入中…",
297
+ "transfer.imported": "导入完成",
298
+ "transfer.undone": "已撤销导入",
263
299
  "error.panel": "待办时间日志面板出现异常",
264
300
  "error.panelHint": "请关闭后重新打开面板;如果问题持续存在,请检查插件日志。",
265
301
  "error.detail": "详情:{message}"
@@ -515,6 +551,42 @@
515
551
  "notice.serverError": "The server returned an error.",
516
552
  "toast.undo": "Undo",
517
553
  "toast.dismiss": "Dismiss message",
554
+ "transfer.open": "Data",
555
+ "transfer.title": "Import / Export",
556
+ "transfer.close": "Close",
557
+ "transfer.exportTitle": "Export",
558
+ "transfer.exportHint": "Exports every todo. JSON is lossless and re-importable; CSV suits Excel time analysis.",
559
+ "transfer.exportJson": "Export JSON",
560
+ "transfer.exportCsv": "Export CSV",
561
+ "transfer.exported": "Exported {count} todo(s) ({format})",
562
+ "transfer.importTitle": "Import",
563
+ "transfer.importHint": "Accepts this plugin JSON (v1–v5), a todo array or CSV; the current data is backed up first.",
564
+ "transfer.chooseFile": "Choose file",
565
+ "transfer.noFile": "No file selected",
566
+ "transfer.reading": "Reading…",
567
+ "transfer.readError": "Could not read the file: {message}",
568
+ "transfer.emptyFile": "The file is empty.",
569
+ "transfer.noPreview": "Pick a file to preview; nothing is written until you confirm.",
570
+ "transfer.previewTitle": "Import preview",
571
+ "transfer.previewAdded": "Add {count}",
572
+ "transfer.previewUpdated": "Update {count}",
573
+ "transfer.previewSkipped": "Skip {count}",
574
+ "transfer.previewRemoved": "Remove {count}",
575
+ "transfer.previewTotal": "Total {count}",
576
+ "transfer.previewRows": "{count} row(s) read",
577
+ "transfer.previewInvalid": "{count} invalid row(s)",
578
+ "transfer.rowError": "Row {row}: {message}",
579
+ "transfer.modeLabel": "Conflict handling",
580
+ "transfer.modeMerge": "Merge (recommended)",
581
+ "transfer.modeMergeHint": "Same id keeps the newer update; new ids are appended.",
582
+ "transfer.modeAdd": "Add only",
583
+ "transfer.modeAddHint": "Existing ids are skipped; current todos stay untouched.",
584
+ "transfer.modeReplace": "Replace all",
585
+ "transfer.modeReplaceHint": "Backs up first, then replaces every todo with the file contents.",
586
+ "transfer.confirm": "Start import",
587
+ "transfer.importing": "Importing…",
588
+ "transfer.imported": "Import finished",
589
+ "transfer.undone": "Import undone",
518
590
  "error.panel": "The todo time log panel crashed",
519
591
  "error.panelHint": "Close and reopen the panel; check the plugin logs if it keeps happening.",
520
592
  "error.detail": "Details: {message}"
@@ -577,6 +649,11 @@
577
649
  { value: "comfortable", labelKey: "board.densityComfortable" },
578
650
  { value: "compact", labelKey: "board.densityCompact" }
579
651
  ];
652
+ var TRANSFER_MODES = [
653
+ { value: "merge", labelKey: "transfer.modeMerge", hintKey: "transfer.modeMergeHint" },
654
+ { value: "add", labelKey: "transfer.modeAdd", hintKey: "transfer.modeAddHint" },
655
+ { value: "replace", labelKey: "transfer.modeReplace", hintKey: "transfer.modeReplaceHint" }
656
+ ];
580
657
  var BOARD_COMPLETED_LIMIT = 10;
581
658
  var CALENDAR_BASES = [
582
659
  { value: "createdAt", labelKey: "calendar.basisCreated" },
@@ -2204,6 +2281,163 @@
2204
2281
  .ttl-board-card-files { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 2px; }
2205
2282
  .ttl-calendar-entry-files { display: flex; flex: 1 1 100%; flex-wrap: wrap; gap: 4px; }
2206
2283
  .ttl-detail-files { display: flex; flex-wrap: wrap; gap: 4px; }
2284
+
2285
+ /* 导入 / 导出:轻量浮层对话框,内容超高时只滚动对话框主体。 */
2286
+ .ttl-download-anchor {
2287
+ position: absolute;
2288
+ width: 1px;
2289
+ height: 1px;
2290
+ overflow: hidden;
2291
+ clip: rect(0 0 0 0);
2292
+ white-space: nowrap;
2293
+ }
2294
+ .ttl-overlay {
2295
+ position: absolute;
2296
+ inset: 0;
2297
+ z-index: 30;
2298
+ display: flex;
2299
+ align-items: center;
2300
+ justify-content: center;
2301
+ padding: 24px;
2302
+ background: var(--dsw-alias-bg-mask-1);
2303
+ }
2304
+ .ttl-dialog {
2305
+ display: flex;
2306
+ flex-direction: column;
2307
+ width: min(560px, 100%);
2308
+ max-height: 100%;
2309
+ border: 0.5px solid var(--dsw-alias-border-l2);
2310
+ border-radius: var(--dsw-radius-lg);
2311
+ background: var(--dsw-alias-bg-base);
2312
+ box-shadow: var(--dsw-shadow-lv3);
2313
+ overflow: hidden;
2314
+ }
2315
+ .ttl-dialog-head {
2316
+ display: flex;
2317
+ flex: none;
2318
+ align-items: center;
2319
+ justify-content: space-between;
2320
+ gap: 12px;
2321
+ padding: 14px 16px;
2322
+ border-bottom: 0.5px solid var(--dsw-alias-border-l1);
2323
+ }
2324
+ .ttl-dialog-title {
2325
+ margin: 0;
2326
+ font-size: 15px;
2327
+ font-weight: 600;
2328
+ line-height: 22px;
2329
+ color: var(--dsw-alias-label-primary);
2330
+ }
2331
+ .ttl-dialog-close {
2332
+ display: inline-flex;
2333
+ align-items: center;
2334
+ justify-content: center;
2335
+ width: 26px;
2336
+ height: 26px;
2337
+ padding: 0;
2338
+ border: 0;
2339
+ border-radius: var(--dsw-radius-sm);
2340
+ background: none;
2341
+ color: var(--dsw-alias-label-tertiary);
2342
+ font: inherit;
2343
+ font-size: 18px;
2344
+ line-height: 1;
2345
+ cursor: pointer;
2346
+ }
2347
+ .ttl-dialog-close:hover { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-primary); }
2348
+ .ttl-dialog-body {
2349
+ display: flex;
2350
+ flex-direction: column;
2351
+ gap: 18px;
2352
+ padding: 16px;
2353
+ overflow-y: auto;
2354
+ }
2355
+ .ttl-dialog-footer {
2356
+ display: flex;
2357
+ flex: none;
2358
+ align-items: center;
2359
+ justify-content: flex-end;
2360
+ gap: 8px;
2361
+ padding: 12px 16px;
2362
+ border-top: 0.5px solid var(--dsw-alias-border-l1);
2363
+ background: var(--dsw-alias-bg-base);
2364
+ }
2365
+ .ttl-transfer-section { display: flex; flex-direction: column; gap: 8px; }
2366
+ .ttl-transfer-heading {
2367
+ margin: 0;
2368
+ font-size: 13px;
2369
+ font-weight: 600;
2370
+ line-height: 20px;
2371
+ color: var(--dsw-alias-label-primary);
2372
+ }
2373
+ .ttl-transfer-hint { margin: 0; font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-tertiary); }
2374
+ .ttl-dialog-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
2375
+ .ttl-dialog-actions-end { justify-content: flex-end; padding-top: 4px; }
2376
+ .ttl-transfer-file {
2377
+ max-width: 100%;
2378
+ overflow: hidden;
2379
+ text-overflow: ellipsis;
2380
+ white-space: nowrap;
2381
+ font-size: 12px;
2382
+ line-height: 18px;
2383
+ color: var(--dsw-alias-label-secondary);
2384
+ }
2385
+ .ttl-file-input { display: none; }
2386
+ .ttl-transfer-error {
2387
+ margin: 0;
2388
+ padding: 8px 10px;
2389
+ border-radius: var(--dsw-radius-sm);
2390
+ background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent);
2391
+ color: var(--dsw-alias-state-error-primary);
2392
+ font-size: 12px;
2393
+ line-height: 18px;
2394
+ }
2395
+ .ttl-transfer-preview {
2396
+ display: flex;
2397
+ flex-direction: column;
2398
+ gap: 10px;
2399
+ padding: 12px;
2400
+ border: 0.5px solid var(--dsw-alias-border-l2);
2401
+ border-radius: var(--dsw-radius-md);
2402
+ background: var(--dsw-alias-bg-layer-1);
2403
+ }
2404
+ .ttl-transfer-preview-title { font-size: 12px; font-weight: 600; line-height: 18px; color: var(--dsw-alias-label-primary); }
2405
+ .ttl-transfer-counts { display: flex; flex-wrap: wrap; gap: 6px; }
2406
+ .ttl-transfer-count {
2407
+ padding: 2px 8px;
2408
+ border-radius: 999px;
2409
+ background: var(--dsw-alias-bg-module-platform);
2410
+ color: var(--dsw-alias-label-secondary);
2411
+ font-size: 12px;
2412
+ line-height: 18px;
2413
+ font-variant-numeric: tabular-nums;
2414
+ }
2415
+ .ttl-transfer-rows { margin: 0; font-size: 11px; line-height: 16px; color: var(--dsw-alias-label-tertiary); }
2416
+ .ttl-transfer-invalid {
2417
+ margin: 0;
2418
+ padding-left: 16px;
2419
+ color: var(--dsw-alias-state-error-primary);
2420
+ font-size: 11px;
2421
+ line-height: 17px;
2422
+ }
2423
+ .ttl-transfer-mode { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; font-size: 12px; color: var(--dsw-alias-label-tertiary); }
2424
+ .ttl-transfer-mode select {
2425
+ height: 28px;
2426
+ padding: 0 6px;
2427
+ border: 0.5px solid var(--dsw-alias-border-l3);
2428
+ border-radius: var(--dsw-radius-sm);
2429
+ background: var(--dsw-alias-bg-layer-1);
2430
+ color: var(--dsw-alias-label-secondary);
2431
+ font: inherit;
2432
+ font-size: 12px;
2433
+ cursor: pointer;
2434
+ }
2435
+ .ttl-transfer-mode-hint { margin: 0; font-size: 11px; line-height: 16px; color: var(--dsw-alias-label-tertiary); }
2436
+ .ttl-transfer-empty { margin: 0; font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-tertiary); }
2437
+ .ttl-overlay .ttl-button { height: 28px; font-size: 12px; }
2438
+ @media (max-width: 560px) {
2439
+ .ttl-overlay { align-items: flex-start; padding: 12px; }
2440
+ }
2207
2441
  @media (prefers-reduced-motion: reduce) {
2208
2442
  .ttl-input,
2209
2443
  .ttl-button,
@@ -4369,6 +4603,245 @@
4369
4603
  return renderTableView;
4370
4604
  }
4371
4605
 
4606
+ // src/client/transfer.js
4607
+ function makeDownload(content, filename, mime) {
4608
+ const text = typeof content === "string" ? content : "";
4609
+ const name = typeof filename === "string" && filename !== "" ? filename : "todos.json";
4610
+ const type = typeof mime === "string" && mime !== "" ? mime : "application/octet-stream";
4611
+ try {
4612
+ if (typeof Blob !== "undefined" && typeof URL !== "undefined" && typeof URL.createObjectURL === "function") {
4613
+ return { url: URL.createObjectURL(new Blob([text], { type })), name };
4614
+ }
4615
+ } catch {
4616
+ }
4617
+ return { url: `data:${type},${encodeURIComponent(text)}`, name };
4618
+ }
4619
+ function readFileText(file) {
4620
+ if (file && typeof file.text === "function") {
4621
+ return file.text().then((text) => String(text ?? ""));
4622
+ }
4623
+ return new Promise((resolve, reject) => {
4624
+ const reader = new FileReader();
4625
+ reader.onload = () => resolve(String(reader.result ?? ""));
4626
+ reader.onerror = () => reject(new Error("读取文件失败"));
4627
+ reader.readAsText(file, "utf-8");
4628
+ });
4629
+ }
4630
+ function countChip(labelKey, count) {
4631
+ const value = Number(count);
4632
+ return h(
4633
+ "span",
4634
+ { className: "ttl-transfer-count", key: labelKey },
4635
+ t(labelKey, { count: Number.isFinite(value) && value > 0 ? Math.floor(value) : 0 })
4636
+ );
4637
+ }
4638
+ function TransferDialog(props) {
4639
+ const {
4640
+ open,
4641
+ busy,
4642
+ applying,
4643
+ fileName,
4644
+ mode,
4645
+ preview,
4646
+ error,
4647
+ fileInputRef,
4648
+ onClose,
4649
+ onExport,
4650
+ onPickFile,
4651
+ onFileChange,
4652
+ onModeChange,
4653
+ onConfirm
4654
+ } = props;
4655
+ if (open !== true) return null;
4656
+ const record = isRecord(preview) ? preview : null;
4657
+ const summary = record !== null && isRecord(record.summary) ? record.summary : null;
4658
+ const invalid = record !== null && Array.isArray(record.invalid) ? record.invalid.slice(0, 5) : [];
4659
+ const invalidCount = record !== null && typeof record.invalidCount === "number" ? record.invalidCount : 0;
4660
+ const rowCount = record !== null && typeof record.rowCount === "number" ? record.rowCount : 0;
4661
+ const activeMode = TRANSFER_MODES.find((option) => option.value === mode) ?? TRANSFER_MODES[0];
4662
+ const canConfirm = summary !== null && busy !== true && applying !== true;
4663
+ const backdrop = (event) => {
4664
+ if (event && event.target === event.currentTarget) onClose();
4665
+ };
4666
+ return h(
4667
+ "div",
4668
+ { className: "ttl-overlay", role: "presentation", onMouseDown: backdrop },
4669
+ h(
4670
+ "div",
4671
+ {
4672
+ className: "ttl-dialog",
4673
+ role: "dialog",
4674
+ "aria-modal": "true",
4675
+ "aria-labelledby": "ttl-transfer-title",
4676
+ onKeyDown: (event) => {
4677
+ if (event && event.key === "Escape") {
4678
+ event.stopPropagation();
4679
+ onClose();
4680
+ }
4681
+ }
4682
+ },
4683
+ h(
4684
+ "div",
4685
+ { className: "ttl-dialog-head" },
4686
+ h("h2", { id: "ttl-transfer-title", className: "ttl-dialog-title" }, t("transfer.title")),
4687
+ h(
4688
+ "button",
4689
+ {
4690
+ type: "button",
4691
+ className: "ttl-dialog-close",
4692
+ onClick: onClose,
4693
+ disabled: busy === true,
4694
+ "aria-label": t("transfer.close"),
4695
+ autoFocus: true
4696
+ },
4697
+ "×"
4698
+ )
4699
+ ),
4700
+ h(
4701
+ "div",
4702
+ { className: "ttl-dialog-body" },
4703
+ // 导出
4704
+ h(
4705
+ "section",
4706
+ { className: "ttl-transfer-section" },
4707
+ h("h3", { className: "ttl-transfer-heading" }, t("transfer.exportTitle")),
4708
+ h("p", { className: "ttl-transfer-hint" }, t("transfer.exportHint")),
4709
+ h(
4710
+ "div",
4711
+ { className: "ttl-dialog-actions" },
4712
+ h(
4713
+ "button",
4714
+ {
4715
+ type: "button",
4716
+ className: "ttl-button ttl-button-outline",
4717
+ disabled: busy === true,
4718
+ onClick: () => onExport("json")
4719
+ },
4720
+ t("transfer.exportJson")
4721
+ ),
4722
+ h(
4723
+ "button",
4724
+ {
4725
+ type: "button",
4726
+ className: "ttl-button ttl-button-outline",
4727
+ disabled: busy === true,
4728
+ onClick: () => onExport("csv")
4729
+ },
4730
+ t("transfer.exportCsv")
4731
+ )
4732
+ )
4733
+ ),
4734
+ // 导入
4735
+ h(
4736
+ "section",
4737
+ { className: "ttl-transfer-section" },
4738
+ h("h3", { className: "ttl-transfer-heading" }, t("transfer.importTitle")),
4739
+ h("p", { className: "ttl-transfer-hint" }, t("transfer.importHint")),
4740
+ h(
4741
+ "div",
4742
+ { className: "ttl-dialog-actions" },
4743
+ h(
4744
+ "button",
4745
+ {
4746
+ type: "button",
4747
+ className: "ttl-button ttl-button-outline",
4748
+ disabled: busy === true,
4749
+ onClick: onPickFile
4750
+ },
4751
+ t("transfer.chooseFile")
4752
+ ),
4753
+ h(
4754
+ "span",
4755
+ { className: "ttl-transfer-file", title: typeof fileName === "string" ? fileName : "" },
4756
+ typeof fileName === "string" && fileName !== "" ? fileName : t("transfer.noFile")
4757
+ )
4758
+ ),
4759
+ h("input", {
4760
+ ref: fileInputRef,
4761
+ className: "ttl-file-input",
4762
+ type: "file",
4763
+ accept: ".json,.csv,application/json,text/csv",
4764
+ tabIndex: -1,
4765
+ "aria-label": t("transfer.chooseFile"),
4766
+ onChange: onFileChange
4767
+ }),
4768
+ typeof error === "string" && error !== "" ? h("p", { className: "ttl-transfer-error", role: "alert" }, error) : null,
4769
+ summary !== null ? h(
4770
+ "div",
4771
+ { className: "ttl-transfer-preview" },
4772
+ h("div", { className: "ttl-transfer-preview-title" }, t("transfer.previewTitle")),
4773
+ h(
4774
+ "div",
4775
+ { className: "ttl-transfer-counts" },
4776
+ countChip("transfer.previewAdded", summary.added),
4777
+ countChip("transfer.previewUpdated", summary.updated),
4778
+ countChip("transfer.previewSkipped", summary.skipped),
4779
+ Number(summary.removed) > 0 ? countChip("transfer.previewRemoved", summary.removed) : null,
4780
+ countChip("transfer.previewTotal", summary.total)
4781
+ ),
4782
+ rowCount > 0 || invalidCount > 0 ? h(
4783
+ "p",
4784
+ { className: "ttl-transfer-rows" },
4785
+ t("transfer.previewRows", { count: rowCount }),
4786
+ invalidCount > 0 ? ` · ${t("transfer.previewInvalid", { count: invalidCount })}` : ""
4787
+ ) : null,
4788
+ invalid.length > 0 ? h(
4789
+ "ul",
4790
+ { className: "ttl-transfer-invalid" },
4791
+ invalid.map((item, index) => h(
4792
+ "li",
4793
+ { key: `invalid-${index}` },
4794
+ t("transfer.rowError", {
4795
+ row: isRecord(item) ? Number(item.row) || 0 : 0,
4796
+ message: isRecord(item) && typeof item.message === "string" ? item.message : ""
4797
+ })
4798
+ ))
4799
+ ) : null,
4800
+ h(
4801
+ "div",
4802
+ { className: "ttl-transfer-mode" },
4803
+ h("span", { className: "ttl-transfer-mode-label" }, t("transfer.modeLabel")),
4804
+ h(
4805
+ "select",
4806
+ {
4807
+ value: typeof mode === "string" ? mode : "merge",
4808
+ disabled: busy === true,
4809
+ "aria-label": t("transfer.modeLabel"),
4810
+ onChange: (event) => onModeChange(event.target.value)
4811
+ },
4812
+ TRANSFER_MODES.map((option) => h(
4813
+ "option",
4814
+ { key: option.value, value: option.value },
4815
+ t(option.labelKey)
4816
+ ))
4817
+ )
4818
+ ),
4819
+ h("p", { className: "ttl-transfer-mode-hint" }, t(activeMode.hintKey))
4820
+ ) : h(
4821
+ "p",
4822
+ { className: "ttl-transfer-empty" },
4823
+ busy === true ? t("transfer.reading") : t("transfer.noPreview")
4824
+ )
4825
+ )
4826
+ ),
4827
+ h(
4828
+ "div",
4829
+ { className: "ttl-dialog-footer" },
4830
+ h(
4831
+ "button",
4832
+ {
4833
+ type: "button",
4834
+ className: "ttl-button ttl-button-primary",
4835
+ disabled: canConfirm !== true,
4836
+ onClick: onConfirm
4837
+ },
4838
+ applying === true ? t("transfer.importing") : t("transfer.confirm")
4839
+ )
4840
+ )
4841
+ )
4842
+ );
4843
+ }
4844
+
4372
4845
  // src/client/panel.js
4373
4846
  function TodoPanel() {
4374
4847
  const [todos, setTodos] = React.useState([]);
@@ -4387,6 +4860,15 @@
4387
4860
  const [editTitle, setEditTitle] = React.useState("");
4388
4861
  const [confirmId, setConfirmId] = React.useState(null);
4389
4862
  const [notice, setNotice] = React.useState(null);
4863
+ const [transferOpen, setTransferOpen] = React.useState(false);
4864
+ const [transferBusy, setTransferBusy] = React.useState(false);
4865
+ const [transferApplying, setTransferApplying] = React.useState(false);
4866
+ const [transferError, setTransferError] = React.useState("");
4867
+ const [transferFileName, setTransferFileName] = React.useState("");
4868
+ const [transferContent, setTransferContent] = React.useState("");
4869
+ const [transferMode, setTransferMode] = React.useState("merge");
4870
+ const [transferPreview, setTransferPreview] = React.useState(null);
4871
+ const [download, setDownload] = React.useState({ url: "", name: "" });
4390
4872
  const [expandedIds, setExpandedIds] = React.useState({});
4391
4873
  const [view, setView] = React.useState("table");
4392
4874
  const [range, setRange] = React.useState("all");
@@ -4447,6 +4929,8 @@
4447
4929
  const projectSeqRef = React.useRef(0);
4448
4930
  const suggestKindRef = React.useRef("file");
4449
4931
  const createBlurTimerRef = React.useRef(null);
4932
+ const transferInputRef = React.useRef(null);
4933
+ const downloadRef = React.useRef(null);
4450
4934
  React.useEffect(() => {
4451
4935
  mountedRef.current = true;
4452
4936
  return () => {
@@ -4472,6 +4956,21 @@
4472
4956
  }, delay);
4473
4957
  return () => clearTimeout(timer);
4474
4958
  }, [notice]);
4959
+ React.useEffect(() => {
4960
+ if (download.url === "" || downloadRef.current === null) return void 0;
4961
+ downloadRef.current.click();
4962
+ const url = download.url;
4963
+ const timer = setTimeout(() => {
4964
+ try {
4965
+ if (typeof URL !== "undefined" && typeof URL.revokeObjectURL === "function") {
4966
+ URL.revokeObjectURL(url);
4967
+ }
4968
+ } catch {
4969
+ }
4970
+ if (mountedRef.current) setDownload({ url: "", name: "" });
4971
+ }, 2e3);
4972
+ return () => clearTimeout(timer);
4973
+ }, [download]);
4475
4974
  const loadList = React.useCallback(
4476
4975
  async (nextFilter, nextSortBy, nextDirection, nextProject, options) => {
4477
4976
  const quiet = Boolean(options && options.quiet);
@@ -5549,6 +6048,128 @@
5549
6048
  if (mountedRef.current) setBusyId(null);
5550
6049
  }
5551
6050
  };
6051
+ const openTransfer = () => {
6052
+ if (transferBusy) return;
6053
+ setTransferError("");
6054
+ setTransferPreview(null);
6055
+ setTransferFileName("");
6056
+ setTransferContent("");
6057
+ setTransferMode("merge");
6058
+ setTransferOpen(true);
6059
+ };
6060
+ const closeTransfer = () => {
6061
+ if (transferBusy) return;
6062
+ setTransferOpen(false);
6063
+ };
6064
+ const runImportPreview = async (content, mode) => {
6065
+ setTransferBusy(true);
6066
+ setTransferError("");
6067
+ try {
6068
+ const value = await request("/import", { content, mode, dryRun: true });
6069
+ if (!mountedRef.current) return;
6070
+ setTransferPreview(value);
6071
+ } catch (error) {
6072
+ if (!mountedRef.current) return;
6073
+ setTransferPreview(null);
6074
+ setTransferError(messageOf(error));
6075
+ } finally {
6076
+ if (mountedRef.current) setTransferBusy(false);
6077
+ }
6078
+ };
6079
+ const handleTransferModeChange = (nextMode) => {
6080
+ if (transferBusy || transferContent === "") return;
6081
+ setTransferMode(nextMode);
6082
+ void runImportPreview(transferContent, nextMode);
6083
+ };
6084
+ const handleExport = async (format) => {
6085
+ if (transferBusy) return;
6086
+ setTransferBusy(true);
6087
+ setTransferError("");
6088
+ try {
6089
+ const value = await request("/export", { format });
6090
+ if (!mountedRef.current) return;
6091
+ setDownload(makeDownload(value.content, value.filename, value.mime));
6092
+ showNotice("success", t("transfer.exported", {
6093
+ count: Number(value.count) || 0,
6094
+ format: String(value.format || format).toUpperCase()
6095
+ }));
6096
+ setTransferOpen(false);
6097
+ } catch (error) {
6098
+ if (!mountedRef.current) return;
6099
+ setTransferError(messageOf(error));
6100
+ } finally {
6101
+ if (mountedRef.current) setTransferBusy(false);
6102
+ }
6103
+ };
6104
+ const handlePickFile = () => {
6105
+ const input = transferInputRef.current;
6106
+ if (input && typeof input.click === "function") input.click();
6107
+ };
6108
+ const handleImportFileChange = async (event) => {
6109
+ const target = event && event.target ? event.target : null;
6110
+ const files = target && target.files ? target.files : null;
6111
+ const file = files && files.length > 0 ? files[0] : null;
6112
+ if (target) target.value = "";
6113
+ if (file === null) return;
6114
+ let content = "";
6115
+ try {
6116
+ content = await readFileText(file);
6117
+ } catch (error) {
6118
+ if (mountedRef.current) setTransferError(t("transfer.readError", { message: messageOf(error) }));
6119
+ return;
6120
+ }
6121
+ if (!mountedRef.current) return;
6122
+ if (content.trim() === "") {
6123
+ setTransferPreview(null);
6124
+ setTransferError(t("transfer.emptyFile"));
6125
+ return;
6126
+ }
6127
+ setTransferFileName(typeof file.name === "string" ? file.name : "");
6128
+ setTransferContent(content);
6129
+ void runImportPreview(content, transferMode);
6130
+ };
6131
+ const handleRestoreBackup = async (backupFile) => {
6132
+ if (typeof backupFile !== "string" || backupFile === "") return;
6133
+ try {
6134
+ const value = await request("/restore", { file: backupFile });
6135
+ if (!mountedRef.current) return;
6136
+ showNotice("success", notifyText(value, "transfer.undone"));
6137
+ void fetchProjectSuggestions(workspaceRef.current, true);
6138
+ await reload(true);
6139
+ } catch (error) {
6140
+ showNotice("error", messageOf(error));
6141
+ }
6142
+ };
6143
+ const handleImportConfirm = async () => {
6144
+ if (transferBusy || transferContent === "") return;
6145
+ setTransferBusy(true);
6146
+ setTransferApplying(true);
6147
+ setTransferError("");
6148
+ try {
6149
+ const value = await request("/import", { content: transferContent, mode: transferMode });
6150
+ if (!mountedRef.current) return;
6151
+ setTransferOpen(false);
6152
+ setTransferPreview(null);
6153
+ setTransferContent("");
6154
+ setTransferFileName("");
6155
+ const backup = value && typeof value.backup === "string" ? value.backup : "";
6156
+ showNotice("success", notifyText(value, "transfer.imported"), backup === "" ? void 0 : {
6157
+ onClick: () => {
6158
+ void handleRestoreBackup(backup);
6159
+ }
6160
+ });
6161
+ void fetchProjectSuggestions(workspaceRef.current, true);
6162
+ await reload(true);
6163
+ } catch (error) {
6164
+ if (!mountedRef.current) return;
6165
+ setTransferError(messageOf(error));
6166
+ } finally {
6167
+ if (mountedRef.current) {
6168
+ setTransferBusy(false);
6169
+ setTransferApplying(false);
6170
+ }
6171
+ }
6172
+ };
5552
6173
  const shiftCalendarMonth = (delta) => {
5553
6174
  const total = calendarYear * 12 + calendarMonth + delta;
5554
6175
  setCalendarYear(Math.floor(total / 12));
@@ -5898,6 +6519,15 @@
5898
6519
  "div",
5899
6520
  { className: "ttl-root", "data-dsh-todo-time-log": "panel", onKeyDown: handlePanelKeyDown },
5900
6521
  h(StyleSheet),
6522
+ // 下载锚点:makeDownload() 生成 blob URL 后由 effect 通过 ref 点击。
6523
+ h("a", {
6524
+ ref: downloadRef,
6525
+ className: "ttl-download-anchor",
6526
+ href: download.url === "" ? void 0 : download.url,
6527
+ download: download.name === "" ? void 0 : download.name,
6528
+ "aria-hidden": "true",
6529
+ tabIndex: -1
6530
+ }),
5901
6531
  h(
5902
6532
  "div",
5903
6533
  { className: "ttl-scroll" },
@@ -5917,6 +6547,35 @@
5917
6547
  h(
5918
6548
  "div",
5919
6549
  { className: "ttl-heading-actions" },
6550
+ h(
6551
+ "button",
6552
+ {
6553
+ type: "button",
6554
+ className: "ttl-ghost-button",
6555
+ "aria-haspopup": "dialog",
6556
+ "aria-expanded": transferOpen ? "true" : "false",
6557
+ title: t("transfer.open"),
6558
+ onClick: openTransfer
6559
+ },
6560
+ h(
6561
+ "svg",
6562
+ {
6563
+ className: "ttl-ghost-button-icon",
6564
+ width: 12,
6565
+ height: 12,
6566
+ viewBox: "0 0 16 16",
6567
+ fill: "none",
6568
+ stroke: "currentColor",
6569
+ strokeWidth: 1.4,
6570
+ strokeLinecap: "round",
6571
+ strokeLinejoin: "round",
6572
+ "aria-hidden": "true",
6573
+ focusable: "false"
6574
+ },
6575
+ h("path", { d: "M8 2v7m0 0l3-3m-3 3L5 6M3 12v1.5a.5.5 0 0 0 .5.5h9a.5.5 0 0 0 .5-.5V12" })
6576
+ ),
6577
+ t("transfer.open")
6578
+ ),
5920
6579
  h(
5921
6580
  "button",
5922
6581
  {
@@ -6099,7 +6758,23 @@
6099
6758
  "×"
6100
6759
  )
6101
6760
  )
6102
- )
6761
+ ),
6762
+ h(TransferDialog, {
6763
+ open: transferOpen,
6764
+ busy: transferBusy,
6765
+ applying: transferApplying,
6766
+ fileName: transferFileName,
6767
+ mode: transferMode,
6768
+ preview: transferPreview,
6769
+ error: transferError,
6770
+ fileInputRef: transferInputRef,
6771
+ onClose: closeTransfer,
6772
+ onExport: handleExport,
6773
+ onPickFile: handlePickFile,
6774
+ onFileChange: handleImportFileChange,
6775
+ onModeChange: handleTransferModeChange,
6776
+ onConfirm: handleImportConfirm
6777
+ })
6103
6778
  );
6104
6779
  }
6105
6780
  function createPanel() {