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/README.md +18 -0
- package/lib/client.js +676 -1
- package/lib/index.js +133 -9
- package/lib/store.js +447 -0
- package/lib/transfer.js +573 -0
- package/package.json +5 -4
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() {
|