dsh-calendar 0.10.0 → 0.10.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/CHANGELOG.md +9 -0
- package/README.en.md +3 -1
- package/README.md +3 -1
- package/docs/CHANGELOG.en.md +4 -0
- package/docs/USAGE.en.md +4 -0
- package/docs/USAGE.md +4 -0
- package/docs/VALIDATION.md +2 -0
- package/docs/validation-language-2026-10-03.md +16 -0
- package/lib/client.js +96 -17
- package/lib/ical.js +2 -0
- package/lib/time.d.ts +3 -0
- package/lib/time.js +26 -0
- package/lib/tools.d.ts +1 -1
- package/lib/tools.js +3 -18
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,15 @@
|
|
|
4
4
|
|
|
5
5
|
[历史英文记录](docs/CHANGELOG.en.md)
|
|
6
6
|
|
|
7
|
+
## 0.10.2 (2026-10-05)
|
|
8
|
+
|
|
9
|
+
- 严格验证真实 ISO 日期,拒绝不存在的日期、模糊地域格式与缺少时区的日期时间,防止日程悄悄改期。
|
|
10
|
+
|
|
11
|
+
## 0.10.1 (2026-10-03)
|
|
12
|
+
|
|
13
|
+
- 修复语言切换时菜单和悬浮按钮不更新;日期、星期、重复规则、ICS 告警与连接引导支持英文。
|
|
14
|
+
- 验收范围见 [中英文界面检查](docs/validation-language-2026-10-03.md)。
|
|
15
|
+
|
|
7
16
|
## 0.10.0 (2026-10-03)
|
|
8
17
|
|
|
9
18
|
- 新增 ICS 导入:选择文件或粘贴内容,预览后自行勾选、分批确认;保留重复规则、排除日期、改期例外和时区定义。
|
package/README.en.md
CHANGED
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
[中文](README.md)
|
|
4
4
|
|
|
5
|
+

|
|
6
|
+
|
|
5
7
|
View, create and reschedule CalDAV events from DSH.
|
|
6
8
|
|
|
7
|
-
[](https://www.npmjs.com/package/dsh-calendar) [](https://www.npmjs.com/package/dsh-calendar) [](https://www.npmjs.com/package/dsh-calendar)
|
|
8
10
|
|
|
9
11
|
## What it does
|
|
10
12
|
|
package/README.md
CHANGED
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
[English](README.en.md)
|
|
4
4
|
|
|
5
|
+

|
|
6
|
+
|
|
5
7
|
连接 CalDAV 日历,在 DSH 中查看、创建和调整日程。
|
|
6
8
|
|
|
7
|
-
[](https://www.npmjs.com/package/dsh-calendar) [](https://www.npmjs.com/package/dsh-calendar) [](https://www.npmjs.com/package/dsh-calendar)
|
|
8
10
|
|
|
9
11
|
## 功能
|
|
10
12
|
|
package/docs/CHANGELOG.en.md
CHANGED
|
@@ -4,6 +4,10 @@
|
|
|
4
4
|
|
|
5
5
|
These English notes preserve the earlier translations. The main changelog contains the consolidated version history.
|
|
6
6
|
|
|
7
|
+
## 0.10.2 (2026-10-05)
|
|
8
|
+
|
|
9
|
+
- Validate real ISO calendar dates, rejecting nonexistent dates, ambiguous locale formats and datetimes without a timezone.
|
|
10
|
+
|
|
7
11
|
## 0.10.0 (2026-10-03)
|
|
8
12
|
|
|
9
13
|
- Import ICS files or pasted content with an explicit preview, selection and batch confirmation. Preserve recurrence rules, excluded dates, moved exceptions and timezone definitions.
|
package/docs/USAGE.en.md
CHANGED
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
[Overview](../README.en.md) · [Changelog](../CHANGELOG.md) · [Validation](VALIDATION.md)
|
|
4
4
|
|
|
5
|
+
## Current improvements
|
|
6
|
+
|
|
7
|
+
Use YYYY-MM-DD for dates and ISO datetimes with Z or a UTC offset. Invalid dates fail before remote creation or updates. Existing events are preserved.
|
|
8
|
+
|
|
5
9
|
## Installation
|
|
6
10
|
|
|
7
11
|
```bash
|
package/docs/USAGE.md
CHANGED
package/docs/VALIDATION.md
CHANGED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# 0.10.1:中英文界面验收
|
|
2
|
+
|
|
3
|
+
2026-10-03,Windows / Node 24.16.0。采用 E 盘独立 DSH_HOME、合成资料与无密钥 profile;用户资料、账号、密钥、桌面窗口与 VPN 保持原状。
|
|
4
|
+
|
|
5
|
+
- 自动测试:192 项全部通过。
|
|
6
|
+
- 可见 Web 操作:英文 ICS 粘贴预览,选择 Asia/Shanghai 后事件可选;浮动时间、DTSTAMP 与缺少连接凭据的引导为英文,事件标题保留中文。示例日历中的日期、星期和重复详情为英文;中文切换时导航与独立悬浮按钮及时更新。
|
|
7
|
+
- 宿主:官方 DSH 0.2.1-alpha.1 Web;官方 RC2 与 alpha SDK 同载 18 组件,110 工具、35 插件技能、19 输出样例注册与 schema 检查通过。无账号的健康结果不代表真实连接。
|
|
8
|
+
- 边界:未导入真实 CalDAV 账号;无账号时的连接引导通过不能等同于服务连接通过。
|
|
9
|
+
|
|
10
|
+
中英文共用同一份资料;界面翻译不会改写标题、正文、路径、引用或项目文件。系统文件选择控件和文件对话框跟随 Windows / 浏览器语言。未知技术诊断与原始引擎日志保留原文;英文界面提供操作恢复引导。
|
|
11
|
+
|
|
12
|
+
此记录不代表原生桌面窗口内的新功能已逐项操作通过。发布包完整性、GitHub CI 与公开 npm 全新安装在发布时另行核对。
|
|
13
|
+
|
|
14
|
+
## English scope
|
|
15
|
+
|
|
16
|
+
The Chinese and English UI share the same data. UI translation preserves original content, paths and project files. Visible interactions were performed in an isolated official alpha Web profile; both RC2 and alpha SDK registration/schema checks passed. Native file controls use the OS/browser language, and original diagnostic logs are retained. Browser results do not certify native desktop interaction or real-service delivery.
|
package/lib/client.js
CHANGED
|
@@ -333,6 +333,62 @@ const UI = {
|
|
|
333
333
|
|
|
334
334
|
var UI_LANG = "zh";
|
|
335
335
|
var relocalize = null;
|
|
336
|
+
var activeLocaleService = null;
|
|
337
|
+
|
|
338
|
+
// Backend guidance is localized separately from event titles and source content.
|
|
339
|
+
const BACKEND_EN = {
|
|
340
|
+
"日期格式不合法": "Invalid date format.",
|
|
341
|
+
"日程包含不存在的日期或时间": "This event contains an invalid date or time.",
|
|
342
|
+
"请先选择或粘贴 ICS 文件内容": "Choose an ICS file or paste its content first.",
|
|
343
|
+
"ICS 文件超过 256 KiB,请分批导出": "The ICS file exceeds 256 KiB. Export smaller batches.",
|
|
344
|
+
"ICS 结构不合法或嵌套过深": "The ICS structure is invalid or nested too deeply.",
|
|
345
|
+
"ICS 文件不完整": "The ICS file is incomplete.",
|
|
346
|
+
"无法读取 ICS,请从日历应用重新导出": "Could not read the ICS file. Export it again from your calendar app.",
|
|
347
|
+
"暂不支持 PERIOD 类型的 RDATE,请改用日期列表后导入": "PERIOD RDATE values are unsupported. Export a list of dates instead.",
|
|
348
|
+
"全天日期不能含 TZID": "All-day dates must not contain TZID.",
|
|
349
|
+
"文件含未标注时区的时间,请选择解释时区后重新预览": "This file contains times without a timezone. Choose an interpretation timezone and preview again.",
|
|
350
|
+
"UTC 时间不能同时标注 TZID": "UTC timestamps must not also contain TZID.",
|
|
351
|
+
"原文件缺少 DTSTAMP,导入时补齐": "DTSTAMP is missing and will be added during import.",
|
|
352
|
+
"作为私人日程复制,不导入参与者或发送邀请": "Imported as a private copy. Attendees are omitted and no invitations are sent.",
|
|
353
|
+
"仅保留屏幕提醒,已移除其他提醒动作": "Only display reminders are retained; other alarm actions were removed.",
|
|
354
|
+
"开始与结束必须同时为全天日期或具体时间": "Start and end must both be all-day dates or both be timestamps.",
|
|
355
|
+
"日程或重复例外的结束时间早于开始时间": "The event or recurring exception ends before it starts.",
|
|
356
|
+
"日程无法解析": "Could not parse this event.",
|
|
357
|
+
"结束时间早于开始时间": "End time precedes start time.",
|
|
358
|
+
"重复规则 UNTIL 需要 UTC 时间,请重新导出": "The recurrence UNTIL must use UTC. Export the event again.",
|
|
359
|
+
"日程格式不合法": "Invalid event format.",
|
|
360
|
+
"日历连接已改变,请重新预览": "The calendar connection changed. Preview again.",
|
|
361
|
+
"请先核对预览,再确认导入": "Review the preview before confirming import.",
|
|
362
|
+
"预览已过期或插件已重启,请重新预览": "The preview expired or the plugin restarted. Preview again.",
|
|
363
|
+
"每次请选择 1–20 个日程系列,其余可分批导入": "Select 1–20 event series per batch.",
|
|
364
|
+
"选择包含不可导入的日程,请重新预览": "Some selected events cannot be imported. Preview again.",
|
|
365
|
+
"另一批日程正在导入,请稍后重试": "Another batch is importing. Try again after it finishes.",
|
|
366
|
+
"不支持此导入操作": "Unsupported import operation.",
|
|
367
|
+
"请求必须是对象": "The request must be an object."
|
|
368
|
+
};
|
|
369
|
+
function backendMessage(error) {
|
|
370
|
+
const message = typeof error === 'string' ? error : error?.message || String(error || '');
|
|
371
|
+
if (UI_LANG !== 'en') return message;
|
|
372
|
+
if (error?.status === 401) return 'Your DSH session expired. Reopen DSH and try again.';
|
|
373
|
+
if (BACKEND_EN[message]) return BACKEND_EN[message];
|
|
374
|
+
if (!/[\u3400-\u9fff]/.test(message)) return message;
|
|
375
|
+
const timezone = message.match(/^(文件重复定义时区|时区定义不完整|时区规则的 DTSTART 必须是本地时间|时区偏移不合法|缺少时区定义):(.+)$/);
|
|
376
|
+
if (timezone) return ({ '文件重复定义时区': 'Duplicate timezone definition', '时区定义不完整': 'Incomplete timezone definition', '时区规则的 DTSTART 必须是本地时间': 'Timezone DTSTART must use local time', '时区偏移不合法': 'Invalid timezone offset', '缺少时区定义': 'Missing timezone definition' })[timezone[1]] + ': ' + timezone[2];
|
|
377
|
+
const floating = message.match(/^原文件未标注时区,按 (.+) 解释$/);
|
|
378
|
+
if (floating) return 'No timezone in the source; interpreted as ' + floating[1] + '.';
|
|
379
|
+
if (/OAuth|Google|凭据|未配置|尚未配置|没有配置|连接设置/.test(message)) return 'Calendar credentials are incomplete. Open Connection settings and configure the calendar account; the DSH model API key cannot be used here.';
|
|
380
|
+
if (/时区/.test(message)) return 'Invalid or missing timezone. Choose an interpretation timezone or export a file with VTIMEZONE.';
|
|
381
|
+
if (/VCALENDAR|VERSION|VEVENT/.test(message)) return 'Use a valid iCalendar 2.0 file containing VEVENT entries.';
|
|
382
|
+
if (/UID|重复/.test(message)) return 'Check event UIDs and recurrence definitions, then export the file again.';
|
|
383
|
+
if (/冲突|修改|改变/.test(message)) return 'The calendar or event changed. Reload it and review your input before retrying.';
|
|
384
|
+
return 'The calendar operation failed. Check the connection and source file, then try again.';
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const languageListeners = new Set();
|
|
388
|
+
function useUILanguage() {
|
|
389
|
+
const [, render] = useState(UI_LANG);
|
|
390
|
+
useEffect(() => { const update = () => render(UI_LANG); languageListeners.add(update); update(); return () => languageListeners.delete(update); }, []);
|
|
391
|
+
}
|
|
336
392
|
|
|
337
393
|
/** 宿主 locale 服务:get('locale') 是正规读法,ctx.locale 兜旧宿主,都没有就是没有。 */
|
|
338
394
|
function localeServiceOf(ctx) {
|
|
@@ -354,6 +410,7 @@ function localeToLang(active) {
|
|
|
354
410
|
/** 订阅语言切换;服务缺失或形状不同都只是留在 zh,绝不把面板拖垮。 */
|
|
355
411
|
function subscribeLocale(ctx) {
|
|
356
412
|
const service = localeServiceOf(ctx);
|
|
413
|
+
activeLocaleService = service;
|
|
357
414
|
if (service === null) return () => {};
|
|
358
415
|
const apply = () => {
|
|
359
416
|
try {
|
|
@@ -399,6 +456,7 @@ async function api(action, payload) {
|
|
|
399
456
|
const error = new Error(message);
|
|
400
457
|
error.status = res.status;
|
|
401
458
|
error.code = body && body.error ? body.error.code : undefined;
|
|
459
|
+
error.message = backendMessage(error);
|
|
402
460
|
throw error;
|
|
403
461
|
}
|
|
404
462
|
return body.value;
|
|
@@ -425,11 +483,13 @@ function daysBetween(a, b) { return Math.round((startOfDay(b) - startOfDay(a)) /
|
|
|
425
483
|
|
|
426
484
|
function formatDay(date, opts) {
|
|
427
485
|
const withYear = opts && opts.withYear;
|
|
486
|
+
if (UI_LANG === "en") return date.toLocaleDateString("en-GB", { year: "numeric", month: "short", day: "numeric" });
|
|
428
487
|
return date.getFullYear() + " 年 " + (date.getMonth() + 1) + " 月 " + date.getDate() + " 日" + (withYear ? "" : "");
|
|
429
488
|
}
|
|
430
|
-
function formatMonthTitle(date) { return date.getFullYear() + " 年 " + (date.getMonth() + 1) + " 月"; }
|
|
489
|
+
function formatMonthTitle(date) { return UI_LANG === "en" ? date.toLocaleDateString("en-GB", { year: "numeric", month: "long" }) : date.getFullYear() + " 年 " + (date.getMonth() + 1) + " 月"; }
|
|
431
490
|
function formatWeekTitle(date) {
|
|
432
491
|
const from = startOfWeek(date), to = addDays(from, 6);
|
|
492
|
+
if (UI_LANG === "en") return formatDay(from) + " – " + formatDay(to);
|
|
433
493
|
return formatMonthTitle(from) + " " + from.getDate() + "–" + (to.getMonth() === from.getMonth() ? "" : (to.getMonth() + 1) + " 月 ") + to.getDate() + " 日";
|
|
434
494
|
}
|
|
435
495
|
function relativeDay(date) {
|
|
@@ -437,7 +497,7 @@ function relativeDay(date) {
|
|
|
437
497
|
if (diff === 0) return t("day.today");
|
|
438
498
|
if (diff === 1) return t("day.tomorrow");
|
|
439
499
|
if (diff === -1) return t("day.yesterday");
|
|
440
|
-
return "周" + WEEKDAYS_ZH[isoWeekday(date) - 1];
|
|
500
|
+
return UI_LANG === "en" ? date.toLocaleDateString("en-GB", { weekday: "short" }) : "周" + WEEKDAYS_ZH[isoWeekday(date) - 1];
|
|
441
501
|
}
|
|
442
502
|
function formatWhen(event) {
|
|
443
503
|
if (event.allDay) return t("format.allday") + " · " + formatDay(startDate(event));
|
|
@@ -448,7 +508,7 @@ function formatWhen(event) {
|
|
|
448
508
|
function formatDuration(event) {
|
|
449
509
|
if (event.allDay) {
|
|
450
510
|
const days = Math.max(1, daysBetween(startDate(event), endDate(event)));
|
|
451
|
-
return days === 1 ? t("format.allday") : days + " 天";
|
|
511
|
+
return days === 1 ? t("format.allday") : days + (UI_LANG === "en" ? " days" : " 天");
|
|
452
512
|
}
|
|
453
513
|
const minutes = Math.max(0, Math.round((endDate(event) - startDate(event)) / 60000));
|
|
454
514
|
const hours = Math.floor(minutes / 60), rest = minutes % 60;
|
|
@@ -467,6 +527,18 @@ function humanRepeat(rrule) {
|
|
|
467
527
|
if (i > 0) parts[piece.slice(0, i).toUpperCase()] = piece.slice(i + 1);
|
|
468
528
|
});
|
|
469
529
|
const freq = parts.FREQ;
|
|
530
|
+
if (UI_LANG === "en") {
|
|
531
|
+
const units = { DAILY: ["Daily", "days"], WEEKLY: ["Weekly", "weeks"], MONTHLY: ["Monthly", "months"], YEARLY: ["Yearly", "years"] };
|
|
532
|
+
if (!units[freq]) return rrule;
|
|
533
|
+
const interval = Number(parts.INTERVAL || "1");
|
|
534
|
+
const every = interval > 1 ? "Every " + interval + " " + units[freq][1] : units[freq][0];
|
|
535
|
+
if (parts.COUNT) return every + " (" + parts.COUNT + " occurrences)";
|
|
536
|
+
if (parts.UNTIL) {
|
|
537
|
+
const raw = String(parts.UNTIL), date = raw.length >= 8 ? raw.slice(0, 4) + "-" + raw.slice(4, 6) + "-" + raw.slice(6, 8) : raw;
|
|
538
|
+
return every + ", until " + date;
|
|
539
|
+
}
|
|
540
|
+
return every;
|
|
541
|
+
}
|
|
470
542
|
const base = freq === "DAILY" ? "每天" : freq === "WEEKLY" ? "每周" : freq === "MONTHLY" ? "每月" : freq === "YEARLY" ? "每年" : "";
|
|
471
543
|
if (base === "") return rrule;
|
|
472
544
|
const interval = Number(parts.INTERVAL || "1");
|
|
@@ -754,7 +826,7 @@ function MonthView(props) {
|
|
|
754
826
|
]));
|
|
755
827
|
}
|
|
756
828
|
return h("div", null, [
|
|
757
|
-
h("div", { className: "dshc-grid", style: { marginBottom: "4px" } }, WEEKDAYS_ZH.map((d) => h("div", { className: "dshc-dow", key: d }, "周" + d))),
|
|
829
|
+
h("div", { className: "dshc-grid", style: { marginBottom: "4px" } }, WEEKDAYS_ZH.map((d, i) => h("div", { className: "dshc-dow", key: d }, UI_LANG === "en" ? ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"][i] : "周" + d))),
|
|
758
830
|
h("div", { className: "dshc-grid" }, cells)
|
|
759
831
|
]);
|
|
760
832
|
}
|
|
@@ -1438,7 +1510,7 @@ function ConnectionForm(props) {
|
|
|
1438
1510
|
const sample = (value.sample || []).join("、");
|
|
1439
1511
|
setResult({ ok: true, text: t("conn.ok", { n: value.count }) + (sample === "" ? "" : t("conn.okSample", { sample })) });
|
|
1440
1512
|
} catch (error) {
|
|
1441
|
-
setResult({ ok: false, text:
|
|
1513
|
+
setResult({ ok: false, text: backendMessage(error) });
|
|
1442
1514
|
} finally {
|
|
1443
1515
|
setBusy("");
|
|
1444
1516
|
}
|
|
@@ -1449,7 +1521,7 @@ function ConnectionForm(props) {
|
|
|
1449
1521
|
try {
|
|
1450
1522
|
onSaved(await api("saveConnection", Object.assign({}, payload(), { test: true })));
|
|
1451
1523
|
} catch (error) {
|
|
1452
|
-
setResult({ ok: false, text:
|
|
1524
|
+
setResult({ ok: false, text: backendMessage(error) });
|
|
1453
1525
|
} finally {
|
|
1454
1526
|
setBusy("");
|
|
1455
1527
|
}
|
|
@@ -1614,7 +1686,7 @@ Object.assign(UI.en, {
|
|
|
1614
1686
|
async function importApi(action, payload, signal) {
|
|
1615
1687
|
const res = await fetch(IMPORT_ROUTE, { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json", "X-DSH-Calendar-Import": "1" }, body: JSON.stringify(Object.assign({ action }, payload)), signal });
|
|
1616
1688
|
let body; try { body = await res.json(); } catch (e) { body = null; }
|
|
1617
|
-
if (!res.ok || !body || body.ok !== true) throw new Error(body && body.message || "HTTP " + res.status);
|
|
1689
|
+
if (!res.ok || !body || body.ok !== true) throw Object.assign(new Error(backendMessage({ message: body && body.message || "HTTP " + res.status, status: res.status })), { status: res.status });
|
|
1618
1690
|
return body.value;
|
|
1619
1691
|
}
|
|
1620
1692
|
function importDate(row) {
|
|
@@ -1642,7 +1714,7 @@ function IcsImportPanel(props) {
|
|
|
1642
1714
|
const file = event.target.files && event.target.files[0]; if (!file) return;
|
|
1643
1715
|
const sequence = ++fileSequence.current; reset(); setContent("");
|
|
1644
1716
|
if (file.size > 256 * 1024) { setError(t("import.errorSize")); return; }
|
|
1645
|
-
try { const value = await file.text(); if (alive.current && sequence === fileSequence.current) setContent(value); } catch (e) { if (alive.current && sequence === fileSequence.current) setError(
|
|
1717
|
+
try { const value = await file.text(); if (alive.current && sequence === fileSequence.current) setContent(value); } catch (e) { if (alive.current && sequence === fileSequence.current) setError(backendMessage(e)); }
|
|
1646
1718
|
};
|
|
1647
1719
|
const eligible = preview ? preview.rows.filter(row => row.status === "ready" && !["imported", "duplicate"].includes(results[row.id] && results[row.id].status)) : [];
|
|
1648
1720
|
const run = async committing => {
|
|
@@ -1655,7 +1727,7 @@ function IcsImportPanel(props) {
|
|
|
1655
1727
|
const next = Object.assign({}, results); value.results.forEach(row => { next[row.id] = row; }); setResults(next);
|
|
1656
1728
|
setSelected(value.results.filter(row => row.status === "failed").map(row => row.id)); props.onImported();
|
|
1657
1729
|
} else { setPreview(value); setResults({}); setSelected([]); }
|
|
1658
|
-
} catch (e) { if (alive.current) setError(abort.signal.aborted ? t("import.stopped") :
|
|
1730
|
+
} catch (e) { if (alive.current) setError(abort.signal.aborted ? t("import.stopped") : backendMessage(e)); }
|
|
1659
1731
|
finally { clearTimeout(timer); controller.current = null; if (alive.current) setBusy(false); }
|
|
1660
1732
|
};
|
|
1661
1733
|
const count = Object.values(results);
|
|
@@ -1668,7 +1740,7 @@ function IcsImportPanel(props) {
|
|
|
1668
1740
|
h("div", { key: "actions", className: "dshc-import-actions" }, [h(Btn, { key: "preview", disabled: busy || !content.trim(), onClick: () => run(false) }, busy ? t("import.busy") : t("import.preview")), h(Btn, { key: "close", disabled: busy, onClick: props.onClose }, t("import.close")), busy ? h(Btn, { key: "stop", onClick: () => controller.current && controller.current.abort() }, t("import.stop")) : null]),
|
|
1669
1741
|
error ? h("div", { key: "error", className: "dshc-alert error", role: "alert" }, error) : null,
|
|
1670
1742
|
preview ? h("div", { key: "preview-data" }, [
|
|
1671
|
-
preview.connectionReady === false ? h("div", { key: "connection", className: "dshc-alert warn" }, [preview.connectionMessage, h(Btn, { onClick: props.onConnection }, t("import.connection"))]) : null,
|
|
1743
|
+
preview.connectionReady === false ? h("div", { key: "connection", className: "dshc-alert warn" }, [backendMessage(preview.connectionMessage), h(Btn, { onClick: props.onConnection }, t("import.connection"))]) : null,
|
|
1672
1744
|
h("p", { key: "scope", className: "dshc-sub" }, t("import.scope")),
|
|
1673
1745
|
preview.ignored ? h("p", { key: "ignored" }, t("import.ignored", { n: preview.ignored })) : null,
|
|
1674
1746
|
h("div", { key: "rows", className: "dshc-import-rows" }, preview.rows.map(row => {
|
|
@@ -1678,8 +1750,8 @@ function IcsImportPanel(props) {
|
|
|
1678
1750
|
h("span", { key: "status", className: "dshc-tag" }, t("import." + status)), h("p", { key: "date" }, importDate(row)),
|
|
1679
1751
|
row.recurring ? h("p", { key: "repeat" }, t("import.recurring", { n: row.exceptions })) : null,
|
|
1680
1752
|
status !== "duplicate" && status !== "imported" && row.conflicts ? h("p", { key: "conflict", className: "dshc-import-warning" }, t("import.conflicts", { n: row.conflicts })) : null,
|
|
1681
|
-
...(status === "duplicate" || status === "imported" ? [] : row.warnings.concat(row.errors)).map((message, i) => h("p", { key: "message-" + i, className: "dshc-import-warning" }, message)),
|
|
1682
|
-
result && result.message ? h("p", { key: "failure", role: "alert" }, result.message) : null
|
|
1753
|
+
...(status === "duplicate" || status === "imported" ? [] : row.warnings.concat(row.errors)).map((message, i) => h("p", { key: "message-" + i, className: "dshc-import-warning" }, backendMessage(message))),
|
|
1754
|
+
result && result.message ? h("p", { key: "failure", role: "alert" }, backendMessage(result.message)) : null
|
|
1683
1755
|
]);
|
|
1684
1756
|
})),
|
|
1685
1757
|
count.length ? h("p", { key: "results", role: "status", "aria-live": "polite" }, t("import.results", { ok: count.filter(r => r.status === "imported").length, duplicate: count.filter(r => r.status === "duplicate").length, failed: count.filter(r => r.status === "failed").length })) : null,
|
|
@@ -1691,6 +1763,7 @@ function IcsImportPanel(props) {
|
|
|
1691
1763
|
}
|
|
1692
1764
|
|
|
1693
1765
|
function CalendarPanel(props) {
|
|
1766
|
+
useUILanguage();
|
|
1694
1767
|
const compact = props.compact === true;
|
|
1695
1768
|
const [view, setView] = useState(loadStoredView);
|
|
1696
1769
|
const [anchor, setAnchor] = useState(() => new Date());
|
|
@@ -1910,7 +1983,7 @@ function CalendarPanel(props) {
|
|
|
1910
1983
|
reload();
|
|
1911
1984
|
notify(form.uid === undefined ? "已创建" : "已保存");
|
|
1912
1985
|
} catch (error) {
|
|
1913
|
-
setFormError(
|
|
1986
|
+
setFormError(backendMessage(error));
|
|
1914
1987
|
} finally {
|
|
1915
1988
|
setBusy(false);
|
|
1916
1989
|
}
|
|
@@ -1926,7 +1999,7 @@ function CalendarPanel(props) {
|
|
|
1926
1999
|
reload();
|
|
1927
2000
|
notify("已删除");
|
|
1928
2001
|
} catch (error) {
|
|
1929
|
-
setBanner(
|
|
2002
|
+
setBanner(backendMessage(error));
|
|
1930
2003
|
} finally {
|
|
1931
2004
|
setBusy(false);
|
|
1932
2005
|
}
|
|
@@ -1989,7 +2062,7 @@ function CalendarPanel(props) {
|
|
|
1989
2062
|
demo === true ? h("div", { className: "dshc-alert demo" }, t("demo.banner")) : null,
|
|
1990
2063
|
data.error !== null && data.error !== undefined && demo === false && configured !== false
|
|
1991
2064
|
? h("div", { className: "dshc-alert error" }, [
|
|
1992
|
-
h("span", null, (data.error
|
|
2065
|
+
h("span", null, backendMessage(data.error) || t("error.load")),
|
|
1993
2066
|
h("span", { className: "dshc-spacer" }),
|
|
1994
2067
|
h(Btn, { onClick: reload }, t("action.retry"))
|
|
1995
2068
|
])
|
|
@@ -2044,6 +2117,7 @@ function addMonthShift(date, delta) {
|
|
|
2044
2117
|
|
|
2045
2118
|
/** 设置页里的那一节。 */
|
|
2046
2119
|
function CalendarSection() {
|
|
2120
|
+
useUILanguage();
|
|
2047
2121
|
return h("div", null, [
|
|
2048
2122
|
h("h2", null, t("section.title")),
|
|
2049
2123
|
h("p", { className: "dshc-sub" }, t("section.intro")),
|
|
@@ -2053,6 +2127,7 @@ function CalendarSection() {
|
|
|
2053
2127
|
|
|
2054
2128
|
/** 对话页右下角的小按钮 + 悬浮面板(自己挂 root,不占用宿主槽位)。 */
|
|
2055
2129
|
function Launcher() {
|
|
2130
|
+
useUILanguage();
|
|
2056
2131
|
const [open, setOpen] = useState(false);
|
|
2057
2132
|
const [layerOpen, setLayerOpen] = useState(false);
|
|
2058
2133
|
useEffect(() => {
|
|
@@ -2109,7 +2184,11 @@ function apply(ctx) {
|
|
|
2109
2184
|
name: "settings.section",
|
|
2110
2185
|
id: "dsh-calendar",
|
|
2111
2186
|
order: 50,
|
|
2112
|
-
label: () =>
|
|
2187
|
+
label: () => {
|
|
2188
|
+
let lang = UI_LANG;
|
|
2189
|
+
try { if (activeLocaleService?.getSnapshot) lang = localeToLang(activeLocaleService.getSnapshot().active); } catch {}
|
|
2190
|
+
return UI[lang]["nav.title"];
|
|
2191
|
+
},
|
|
2113
2192
|
inject: () => ({})
|
|
2114
2193
|
}, CalendarSection));
|
|
2115
2194
|
} catch (e) { /* 没有 slots 服务:仍是合法的插件,只是没有设置页 */ }
|
|
@@ -2142,7 +2221,7 @@ function mountLauncher() {
|
|
|
2142
2221
|
};
|
|
2143
2222
|
}
|
|
2144
2223
|
|
|
2145
|
-
relocalize = () => {
|
|
2224
|
+
relocalize = () => { for (const update of languageListeners) update(); };
|
|
2146
2225
|
|
|
2147
2226
|
exports.apply = apply;
|
|
2148
2227
|
exports.inject = inject;
|
package/lib/ical.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { randomUUID } from 'node:crypto';
|
|
8
8
|
import ICAL from 'ical.js';
|
|
9
|
+
import { assertIsoTime } from './time.js';
|
|
9
10
|
/** 展开重复事件超出迭代预算:显式报错,避免把「没走到窗口」静默当成「窗口内没有实例」。 */
|
|
10
11
|
export class ExpansionLimitError extends Error {
|
|
11
12
|
constructor(message) {
|
|
@@ -445,6 +446,7 @@ export function updateICalString(data, changes) {
|
|
|
445
446
|
}
|
|
446
447
|
/** 把 ISO 字符串解析成 ICAL.Time(全天 YYYY-MM-DD 或转 UTC 的定时时间)。 */
|
|
447
448
|
function parseTime(value, allDay) {
|
|
449
|
+
assertIsoTime(value, '时间');
|
|
448
450
|
if (allDay || isDateOnly(value)) {
|
|
449
451
|
return ICAL.Time.fromDateString(value);
|
|
450
452
|
}
|
package/lib/time.d.ts
ADDED
package/lib/time.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** Validate calendar dates before Date/ical.js can silently normalize them. */
|
|
2
|
+
export const DATE_ONLY_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
3
|
+
const DATETIME_PATTERN = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,9}))?)?(Z|[+-]\d{2}:\d{2})$/;
|
|
4
|
+
export function assertIsoTime(value, label) {
|
|
5
|
+
const dateOnly = DATE_ONLY_PATTERN.exec(value);
|
|
6
|
+
const parts = dateOnly ?? DATETIME_PATTERN.exec(value);
|
|
7
|
+
const invalid = () => new Error(label + ' 不是合法的 ISO 8601 时间:' + value + '。必须是真实存在的日期;定时事件须含时区,例如 2025-01-01T09:00:00+08:00 或 2025-01-01T09:00:00Z。');
|
|
8
|
+
if (!parts)
|
|
9
|
+
throw invalid();
|
|
10
|
+
const year = Number(parts[1]), month = Number(parts[2]), day = Number(parts[3]);
|
|
11
|
+
const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
|
|
12
|
+
const days = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
13
|
+
if (year < 1 || month < 1 || month > 12 || day < 1 || day > days[month - 1])
|
|
14
|
+
throw invalid();
|
|
15
|
+
if (dateOnly)
|
|
16
|
+
return;
|
|
17
|
+
const hour = Number(parts[4]), minute = Number(parts[5]), second = Number(parts[6] ?? 0);
|
|
18
|
+
// ISO 8601 permits 24:00 only as midnight at the end of this valid date.
|
|
19
|
+
if (hour > 24 || minute > 59 || second > 59 || (hour === 24 && (minute !== 0 || second !== 0 || /[1-9]/.test(parts[7] ?? ''))))
|
|
20
|
+
throw invalid();
|
|
21
|
+
const zone = parts[8];
|
|
22
|
+
if (zone !== 'Z' && (Number(zone.slice(1, 3)) > 23 || Number(zone.slice(4)) > 59))
|
|
23
|
+
throw invalid();
|
|
24
|
+
if (Number.isNaN(new Date(value).getTime()))
|
|
25
|
+
throw invalid();
|
|
26
|
+
}
|
package/lib/tools.d.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { type CalendarConfig } from './config.js';
|
|
8
8
|
import { type CalendarEvent } from './ical.js';
|
|
9
|
+
export { assertIsoTime } from './time.js';
|
|
9
10
|
/** 模型可见的内容块。 */
|
|
10
11
|
export interface ContentBlock {
|
|
11
12
|
type: 'text';
|
|
@@ -29,7 +30,6 @@ export interface CalendarToolDefinition {
|
|
|
29
30
|
}
|
|
30
31
|
export declare function asRecord(args: unknown): Record<string, unknown>;
|
|
31
32
|
export declare function optionalString(args: Record<string, unknown>, key: string): string | undefined;
|
|
32
|
-
export declare function assertIsoTime(value: string, label: string): void;
|
|
33
33
|
export declare function assertTimeRange(start: string, end: string): void;
|
|
34
34
|
export declare function isoNoMillis(value: string): string;
|
|
35
35
|
/** 按开始时间升序稳定排序(CalDAV 服务端返回顺序不保证稳定)。 */
|
package/lib/tools.js
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
import { CalendarService } from './caldav.js';
|
|
8
8
|
import { CALENDAR_PROVIDERS, buildCaldavUrl, resolveAuthMethod, resolveConfig, resolveCredentials, validateOAuthUrl, } from './config.js';
|
|
9
9
|
import { compileParameters } from './parameters.js';
|
|
10
|
+
import { assertIsoTime, DATE_ONLY_PATTERN } from './time.js';
|
|
11
|
+
export { assertIsoTime } from './time.js';
|
|
10
12
|
const EVENT_SCHEMA = { type: 'object', additionalProperties: true };
|
|
11
13
|
const TIMEOUT_MS = 60000;
|
|
12
14
|
export function asRecord(args) {
|
|
@@ -48,28 +50,11 @@ function clampedInteger(args, key, fallback, min, max) {
|
|
|
48
50
|
return fallback;
|
|
49
51
|
return Math.min(max, Math.max(min, value));
|
|
50
52
|
}
|
|
51
|
-
const DATE_ONLY_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
52
|
-
export function assertIsoTime(value, label) {
|
|
53
|
-
const dateOnly = DATE_ONLY_PATTERN.exec(value);
|
|
54
|
-
if (dateOnly !== null) {
|
|
55
|
-
const year = Number(dateOnly[1]);
|
|
56
|
-
const month = Number(dateOnly[2]);
|
|
57
|
-
const day = Number(dateOnly[3]);
|
|
58
|
-
const date = new Date(Date.UTC(year, month - 1, day));
|
|
59
|
-
if (date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day) {
|
|
60
|
-
throw new Error(label + ' 不是合法的日期:' + value + '(YYYY-MM-DD 必须是真实存在的日期)。');
|
|
61
|
-
}
|
|
62
|
-
return;
|
|
63
|
-
}
|
|
64
|
-
if (Number.isNaN(new Date(value).getTime())) {
|
|
65
|
-
throw new Error(label + ' 不是合法的 ISO 8601 时间,例如 2025-01-01T09:00:00+08:00 或 2025-01-01T09:00:00Z。');
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
53
|
/** 日期/时间统一转 epoch 毫秒(date-only 按 UTC 零点),用于范围比较。 */
|
|
69
54
|
function toEpochMs(value) {
|
|
70
55
|
const dateOnly = DATE_ONLY_PATTERN.exec(value);
|
|
71
56
|
if (dateOnly !== null) {
|
|
72
|
-
return Date
|
|
57
|
+
return new Date(value + 'T00:00:00Z').getTime();
|
|
73
58
|
}
|
|
74
59
|
return new Date(value).getTime();
|
|
75
60
|
}
|