dsh-calendar 0.5.4 → 0.7.0

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/index.d.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import { type CalendarConfig } from './config.js';
11
11
  import { type CalendarToolDefinition } from './tools.js';
12
+ import { type CalendarSettingsFace } from './web.js';
12
13
  /** cordis 服务注入:apply 里要用 ctx.tools,必须显式声明注入,否则宿主会抛 cannot get property without inject。 */
13
14
  export declare const name = "calendar";
14
15
  export declare const inject: string[];
@@ -18,7 +19,31 @@ export interface CalendarPluginContext {
18
19
  register(definition: CalendarToolDefinition): () => void;
19
20
  };
20
21
  on?(event: string, listener: () => void): () => void;
22
+ /** 设置页面板需要挂路由;隔离环境(测试/headless)可以没有。 */
23
+ inject?(services: string[], callback: (ctx: any) => void): void;
21
24
  }
25
+ /**
26
+ * 同步注册 settings 命名空间并返回读写面。
27
+ *
28
+ * 时机是关键:宿主的 register 内部会 `ctx.effect(...)`,必须在插件加载的**同步阶段**调用 ——
29
+ * 在请求处理里注册会失败,而失败一旦被吞掉,就会在保存时才暴露成「namespace is not registered」。
30
+ * 拿不到服务 / 注册失败都只 warn:面板退化为只读,配置仍可写在 cordis.patch.yml。
31
+ */
32
+ /** 兜底:把连接配置存进插件自己的文件(宿主 settings 不可用/注册失败时)。 */
33
+ export declare function fileSettingsFace(file?: string): CalendarSettingsFace;
34
+ /**
35
+ * 接上配置存储:首选宿主 settings 命名空间,不行就退到插件自己的文件。
36
+ *
37
+ * 三步走,每一步失败都往下退而不是抛:
38
+ * 1. 拿到 settings 服务 → 在**插件加载阶段**同步注册命名空间(宿主的 register 要活动作用域);
39
+ * 2. 注册失败但 describe() 里已有我们这个命名空间 → 搭既有注册的车(重复 apply 的第二个实例);
40
+ * 3. 服务不在、或注册失败且没人注册过 → 用兜底文件。
41
+ * 返回 reason 是为了让面板说清「为什么不是宿主设置」,而不是笼统一句「不可用」。
42
+ */
43
+ export declare function attachSettings(ctx: CalendarPluginContext, cfg: CalendarConfig): {
44
+ face: CalendarSettingsFace;
45
+ reason?: string;
46
+ };
22
47
  /**
23
48
  * 插件入口:惰性解析配置并注册五个日历工具。
24
49
  * @param ctx - 宿主上下文(至少含 tools.register)。
@@ -30,3 +55,6 @@ export * from './config.js';
30
55
  export * from './ical.js';
31
56
  export * from './caldav.js';
32
57
  export * from './tools.js';
58
+ export * from './web.js';
59
+ export * from './settings.js';
60
+ export * from './store.js';
package/lib/index.js CHANGED
@@ -9,9 +9,88 @@
9
9
  */
10
10
  import { resolveConfig } from './config.js';
11
11
  import { buildCalendarTools } from './tools.js';
12
+ import { CalendarSettingsSchema, SETTINGS_NAMESPACE, toCalendarConfig, toSettingsBase, validateSettingsValue } from './settings.js';
13
+ import { CalendarSettingsBackend, installCalendarSettingsWeb, settingsFaceOf } from './web.js';
14
+ import { connectionFile, readConnectionFile, writeConnectionFile } from './store.js';
12
15
  /** cordis 服务注入:apply 里要用 ctx.tools,必须显式声明注入,否则宿主会抛 cannot get property without inject。 */
13
16
  export const name = 'calendar';
14
17
  export const inject = ['tools'];
18
+ /**
19
+ * 同步注册 settings 命名空间并返回读写面。
20
+ *
21
+ * 时机是关键:宿主的 register 内部会 `ctx.effect(...)`,必须在插件加载的**同步阶段**调用 ——
22
+ * 在请求处理里注册会失败,而失败一旦被吞掉,就会在保存时才暴露成「namespace is not registered」。
23
+ * 拿不到服务 / 注册失败都只 warn:面板退化为只读,配置仍可写在 cordis.patch.yml。
24
+ */
25
+ /** 兜底:把连接配置存进插件自己的文件(宿主 settings 不可用/注册失败时)。 */
26
+ export function fileSettingsFace(file = connectionFile()) {
27
+ return {
28
+ kind: 'file',
29
+ read: () => readConnectionFile(file).value,
30
+ descriptor: () => ({ revision: readConnectionFile(file).revision }),
31
+ replace: async (value) => {
32
+ const current = readConnectionFile(file);
33
+ writeConnectionFile({ revision: current.revision + 1, value }, file);
34
+ },
35
+ };
36
+ }
37
+ /**
38
+ * 接上配置存储:首选宿主 settings 命名空间,不行就退到插件自己的文件。
39
+ *
40
+ * 三步走,每一步失败都往下退而不是抛:
41
+ * 1. 拿到 settings 服务 → 在**插件加载阶段**同步注册命名空间(宿主的 register 要活动作用域);
42
+ * 2. 注册失败但 describe() 里已有我们这个命名空间 → 搭既有注册的车(重复 apply 的第二个实例);
43
+ * 3. 服务不在、或注册失败且没人注册过 → 用兜底文件。
44
+ * 返回 reason 是为了让面板说清「为什么不是宿主设置」,而不是笼统一句「不可用」。
45
+ */
46
+ export function attachSettings(ctx, cfg) {
47
+ let provider;
48
+ try {
49
+ provider = typeof ctx.get === 'function' ? ctx.get('settings') : undefined;
50
+ }
51
+ catch (error) {
52
+ provider = undefined;
53
+ }
54
+ if (provider === undefined || provider === null) {
55
+ try {
56
+ provider = ctx.settings;
57
+ }
58
+ catch (error) {
59
+ provider = undefined;
60
+ }
61
+ }
62
+ const usable = provider !== undefined && provider !== null && typeof provider.register === 'function' && typeof provider.replace === 'function';
63
+ if (usable !== true) {
64
+ const reason = '插件未注入宿主 settings 服务';
65
+ console.warn('dsh-calendar: ' + reason + ',连接配置改存插件自己的文件:' + connectionFile());
66
+ return { face: fileSettingsFace(), reason };
67
+ }
68
+ let scope;
69
+ let failure;
70
+ try {
71
+ scope = provider.register(SETTINGS_NAMESPACE, CalendarSettingsSchema, {
72
+ base: toSettingsBase(cfg),
73
+ applies: 'live',
74
+ validate: (value) => validateSettingsValue(value),
75
+ });
76
+ }
77
+ catch (error) {
78
+ failure = error instanceof Error ? error.message : String(error);
79
+ }
80
+ const known = (typeof provider.describe === 'function' ? provider.describe() ?? [] : []).some((row) => row !== undefined && row !== null && row.ns === SETTINGS_NAMESPACE);
81
+ if (failure !== undefined && known === true) {
82
+ // 已被注册(重复 apply 的第二个实例):不注册,搭既有注册的车。
83
+ console.warn('dsh-calendar: settings 命名空间注册失败(' + failure + '),改为搭既有注册');
84
+ return { face: settingsFaceOf(provider, undefined) };
85
+ }
86
+ if (failure !== undefined) {
87
+ // 注册失败且没人注册过:写进去也会报 namespace is not registered,直接换兜底文件。
88
+ const reason = 'settings 命名空间注册失败:' + failure;
89
+ console.warn('dsh-calendar: ' + reason + ',连接配置改存插件自己的文件:' + connectionFile());
90
+ return { face: fileSettingsFace(), reason };
91
+ }
92
+ return { face: settingsFaceOf(provider, scope) };
93
+ }
15
94
  /**
16
95
  * 插件入口:惰性解析配置并注册五个日历工具。
17
96
  * @param ctx - 宿主上下文(至少含 tools.register)。
@@ -25,10 +104,29 @@ export function apply(ctx, config) {
25
104
  catch (error) {
26
105
  console.warn('dsh-calendar: ' + (error instanceof Error ? error.message : String(error)));
27
106
  }
107
+ // 面板把连接配置写进 settings 之后,工具的下一次调用就该用新配置 —— 所以工具与面板
108
+ // 拿到的是一个 getter,而不是启动时那一份静态对象。
109
+ const attached = attachSettings(ctx, cfg);
110
+ const configOf = () => ({ ...cfg, ...toCalendarConfig(attached.face.read(), null) });
28
111
  const disposers = [];
29
- for (const definition of buildCalendarTools(cfg)) {
112
+ for (const definition of buildCalendarTools(configOf)) {
30
113
  disposers.push(ctx.tools.register(definition));
31
114
  }
115
+ // 面板路由挂上即用:settings 服务由 backend 在第一个请求上懒接入(见 web.ts 的
116
+ // ensureSettings),所以这里不依赖任何子 fiber 的时序,也不会因为重复 apply 而失效。
117
+ const backend = new CalendarSettingsBackend({
118
+ config: configOf,
119
+ ctx,
120
+ settings: attached.face,
121
+ settingsBase: toSettingsBase(cfg),
122
+ ...(attached.reason !== undefined ? { settingsReason: attached.reason } : {}),
123
+ });
124
+ try {
125
+ installCalendarSettingsWeb(ctx, backend);
126
+ }
127
+ catch (error) {
128
+ console.warn('dsh-calendar: 面板路由未挂载:' + (error instanceof Error ? error.message : String(error)));
129
+ }
32
130
  if (typeof ctx.on === 'function') {
33
131
  ctx.on('dispose', () => {
34
132
  for (const dispose of disposers)
@@ -41,3 +139,6 @@ export * from './config.js';
41
139
  export * from './ical.js';
42
140
  export * from './caldav.js';
43
141
  export * from './tools.js';
142
+ export * from './web.js';
143
+ export * from './settings.js';
144
+ export * from './store.js';
@@ -0,0 +1,83 @@
1
+ /**
2
+ * 面板里的「连接设置」写进哪儿:宿主的 settings 命名空间(settings.yaml)。
3
+ *
4
+ * 与 dsh-email 同一套契约,理由也相同:
5
+ * - 面板是唯一的常规入口,用户不该为了填四个格子去编辑 cordis.patch.yml;
6
+ * - 但 cordis.patch.yml 仍是基座:settings 里没碰过的字段由它兜底,所以老用户的
7
+ * YAML 配置不会因为装上这个面板就失效(toCalendarConfig 只投影「用户真的设过」
8
+ * 的字段,schema 默认值绝不覆盖 YAML 与预设);
9
+ * - 密码走 role(secret),与邮箱授权码同等对待:不进日志、不进导出、不进诊断。
10
+ */
11
+ import z from 'schemastery';
12
+ import { type CalendarAuthMethod, type CalendarConfig, type CalendarProvider } from './config.js';
13
+ /** 本插件独占的 settings 命名空间。 */
14
+ export declare const SETTINGS_NAMESPACE = "dsh-calendar";
15
+ /**
16
+ * 面板表单的形状。空串一律表示「没设置」:一行 patch 里的 YAML 配置与面板写下的值
17
+ * 因此可以共存 —— 谁设过谁说了算。
18
+ */
19
+ export declare const CalendarSettingsSchema: z<Schemastery.ObjectS<{
20
+ provider: z<string, string>;
21
+ caldavUrl: z<string, string>;
22
+ username: z<string, string>;
23
+ password: z<string, string>;
24
+ host: z<string, string>;
25
+ user: z<string, string>;
26
+ calendar: z<string, string>;
27
+ calendarId: z<string, string>;
28
+ authMethod: z<string, string>;
29
+ clientId: z<string, string>;
30
+ clientSecret: z<string, string>;
31
+ refreshToken: z<string, string>;
32
+ tokenUrl: z<string, string>;
33
+ proxyUrl: z<string, string>;
34
+ }>, Schemastery.ObjectT<{
35
+ provider: z<string, string>;
36
+ caldavUrl: z<string, string>;
37
+ username: z<string, string>;
38
+ password: z<string, string>;
39
+ host: z<string, string>;
40
+ user: z<string, string>;
41
+ calendar: z<string, string>;
42
+ calendarId: z<string, string>;
43
+ authMethod: z<string, string>;
44
+ clientId: z<string, string>;
45
+ clientSecret: z<string, string>;
46
+ refreshToken: z<string, string>;
47
+ tokenUrl: z<string, string>;
48
+ proxyUrl: z<string, string>;
49
+ }>>;
50
+ export interface CalendarSettingsValue {
51
+ provider: string;
52
+ caldavUrl: string;
53
+ username: string;
54
+ password: string;
55
+ host: string;
56
+ user: string;
57
+ calendar: string;
58
+ calendarId: string;
59
+ authMethod: string;
60
+ clientId: string;
61
+ clientSecret: string;
62
+ refreshToken: string;
63
+ tokenUrl: string;
64
+ proxyUrl: string;
65
+ }
66
+ /** 把一行 cordis.patch.yml 的配置投影成 schema 的 base(只带有值的字段)。 */
67
+ export declare function toSettingsBase(config: CalendarConfig | undefined): Partial<CalendarSettingsValue>;
68
+ /**
69
+ * settings 值 → CalendarConfig。
70
+ *
71
+ * user 是「用户真的改过哪些键」的投影:只有它列出的字段才会被投影出去,因此 schema
72
+ * 的默认值(空串)不会擦掉 cordis.patch.yml 里的配置。传 null 表示按草稿全量投影
73
+ * (保存与测试连接的路径)。
74
+ */
75
+ export declare function toCalendarConfig(value: Partial<CalendarSettingsValue> | undefined, user?: Partial<CalendarSettingsValue> | null): CalendarConfig;
76
+ /** 连接测试用的「拟议配置」:草稿全量投影,不经过已存值。 */
77
+ export declare function draftToConfig(draft: Partial<CalendarSettingsValue> | undefined): CalendarConfig;
78
+ /**
79
+ * 写入前的温和校验:结构错误立刻报(中文,面板直接显示),不完整的配置放行 ——
80
+ * 具体地址拼装留给 resolveConfig,它会在工具调用时给出带指引的错误。
81
+ */
82
+ export declare function validateSettingsValue(value: Partial<CalendarSettingsValue> | undefined): void;
83
+ export type { CalendarAuthMethod, CalendarProvider };
@@ -0,0 +1,139 @@
1
+ /**
2
+ * 面板里的「连接设置」写进哪儿:宿主的 settings 命名空间(settings.yaml)。
3
+ *
4
+ * 与 dsh-email 同一套契约,理由也相同:
5
+ * - 面板是唯一的常规入口,用户不该为了填四个格子去编辑 cordis.patch.yml;
6
+ * - 但 cordis.patch.yml 仍是基座:settings 里没碰过的字段由它兜底,所以老用户的
7
+ * YAML 配置不会因为装上这个面板就失效(toCalendarConfig 只投影「用户真的设过」
8
+ * 的字段,schema 默认值绝不覆盖 YAML 与预设);
9
+ * - 密码走 role(secret),与邮箱授权码同等对待:不进日志、不进导出、不进诊断。
10
+ */
11
+ import z from 'schemastery';
12
+ import { CALENDAR_PROVIDERS } from './config.js';
13
+ /** 本插件独占的 settings 命名空间。 */
14
+ export const SETTINGS_NAMESPACE = 'dsh-calendar';
15
+ /**
16
+ * 面板表单的形状。空串一律表示「没设置」:一行 patch 里的 YAML 配置与面板写下的值
17
+ * 因此可以共存 —— 谁设过谁说了算。
18
+ */
19
+ export const CalendarSettingsSchema = z.object({
20
+ provider: z.string().default(''),
21
+ caldavUrl: z.string().default(''),
22
+ username: z.string().default(''),
23
+ password: z.string().role('secret').default(''),
24
+ host: z.string().default(''),
25
+ user: z.string().default(''),
26
+ calendar: z.string().default(''),
27
+ calendarId: z.string().default(''),
28
+ authMethod: z.string().default(''),
29
+ clientId: z.string().default(''),
30
+ clientSecret: z.string().role('secret').default(''),
31
+ refreshToken: z.string().role('secret').default(''),
32
+ tokenUrl: z.string().default(''),
33
+ proxyUrl: z.string().default(''),
34
+ });
35
+ const TEXT_KEYS = [
36
+ 'provider', 'caldavUrl', 'username', 'password', 'host', 'user', 'calendar',
37
+ 'calendarId', 'authMethod', 'clientId', 'clientSecret', 'refreshToken', 'tokenUrl', 'proxyUrl',
38
+ ];
39
+ /** 把一行 cordis.patch.yml 的配置投影成 schema 的 base(只带有值的字段)。 */
40
+ export function toSettingsBase(config) {
41
+ const out = {};
42
+ const cfg = (config ?? {});
43
+ for (const key of TEXT_KEYS) {
44
+ const value = cfg[key];
45
+ if (typeof value === 'string' && value !== '')
46
+ out[key] = value;
47
+ }
48
+ return out;
49
+ }
50
+ function isSet(value) {
51
+ return value !== undefined && value !== null;
52
+ }
53
+ /**
54
+ * settings 值 → CalendarConfig。
55
+ *
56
+ * user 是「用户真的改过哪些键」的投影:只有它列出的字段才会被投影出去,因此 schema
57
+ * 的默认值(空串)不会擦掉 cordis.patch.yml 里的配置。传 null 表示按草稿全量投影
58
+ * (保存与测试连接的路径)。
59
+ */
60
+ export function toCalendarConfig(value, user) {
61
+ const draft = (value ?? {});
62
+ const has = (key) => user === null || (user !== undefined && user[key] !== undefined);
63
+ const out = {};
64
+ for (const key of TEXT_KEYS) {
65
+ if (!has(key))
66
+ continue;
67
+ const raw = draft[key];
68
+ if (!isSet(raw) || raw === '')
69
+ continue;
70
+ out[key] = raw;
71
+ }
72
+ return out;
73
+ }
74
+ /** 连接测试用的「拟议配置」:草稿全量投影,不经过已存值。 */
75
+ export function draftToConfig(draft) {
76
+ return toCalendarConfig(draft, null);
77
+ }
78
+ function describe(value) {
79
+ if (typeof value === 'string')
80
+ return '"' + value + '"';
81
+ if (value === undefined)
82
+ return 'undefined';
83
+ try {
84
+ return JSON.stringify(value) ?? String(value);
85
+ }
86
+ catch {
87
+ return String(value);
88
+ }
89
+ }
90
+ /**
91
+ * 写入前的温和校验:结构错误立刻报(中文,面板直接显示),不完整的配置放行 ——
92
+ * 具体地址拼装留给 resolveConfig,它会在工具调用时给出带指引的错误。
93
+ */
94
+ export function validateSettingsValue(value) {
95
+ const draft = (value ?? {});
96
+ const provider = typeof draft.provider === 'string' ? draft.provider : '';
97
+ if (provider !== '' && !CALENDAR_PROVIDERS.includes(provider)) {
98
+ throw new Error('未知的日历服务商 ' + describe(provider) + ',可选:' + CALENDAR_PROVIDERS.join(' / '));
99
+ }
100
+ const authMethod = typeof draft.authMethod === 'string' ? draft.authMethod : '';
101
+ if (authMethod !== '' && authMethod !== 'oauth' && authMethod !== 'basic') {
102
+ throw new Error('认证方式只能是 oauth 或 basic,收到 ' + describe(authMethod));
103
+ }
104
+ if (provider === 'google' && authMethod === 'basic') {
105
+ throw new Error('Google 不接受 Basic(应用专用密码):请把认证方式设为 oauth 并填 clientId / clientSecret / refreshToken');
106
+ }
107
+ const url = typeof draft.caldavUrl === 'string' ? draft.caldavUrl.trim() : '';
108
+ if (url !== '' && !/^https?:\/\//i.test(url)) {
109
+ throw new Error('日历集合 URL 必须以 http:// 或 https:// 开头,收到 ' + describe(url));
110
+ }
111
+ const tokenUrl = typeof draft.tokenUrl === 'string' ? draft.tokenUrl.trim() : '';
112
+ if (tokenUrl !== '' && !/^https?:\/\//i.test(tokenUrl)) {
113
+ throw new Error('令牌地址必须以 http:// 或 https:// 开头,收到 ' + describe(tokenUrl));
114
+ }
115
+ const proxyUrl = typeof draft.proxyUrl === 'string' ? draft.proxyUrl.trim() : '';
116
+ if (proxyUrl !== '' && !/^https?:\/\//i.test(proxyUrl)) {
117
+ throw new Error('代理地址必须以 http:// 或 https:// 开头,收到 ' + describe(proxyUrl));
118
+ }
119
+ if (provider === 'google') {
120
+ if ((draft.calendarId ?? '') === '')
121
+ throw new Error('Google 需要日历 ID(通常是你的邮箱地址)');
122
+ if ((draft.clientId ?? '') === '')
123
+ throw new Error('Google 需要 clientId');
124
+ if ((draft.clientSecret ?? '') === '' || (draft.refreshToken ?? '') === '') {
125
+ throw new Error('Google 需要 clientSecret 与 refreshToken:两者都由 OAuth 换来,缺一不可');
126
+ }
127
+ }
128
+ if (provider === 'nextcloud') {
129
+ if ((draft.host ?? '') === '')
130
+ throw new Error('Nextcloud 需要 host(如 https://cloud.example.com)');
131
+ if ((draft.user ?? '') === '')
132
+ throw new Error('Nextcloud 需要 user');
133
+ if ((draft.calendar ?? '') === '')
134
+ throw new Error('Nextcloud 需要 calendar(日历名,如 personal)');
135
+ }
136
+ if ((provider === 'icloud' || provider === 'custom') && url === '') {
137
+ throw new Error((provider === 'icloud' ? 'iCloud' : '自建 CalDAV') + '需要完整的日历集合 URL(caldavUrl)');
138
+ }
139
+ }
package/lib/store.d.ts ADDED
@@ -0,0 +1,10 @@
1
+ /** $DSH_HOME/data/dsh-calendar/connection.json(DSH_HOME 缺省 ~/.dsh)。 */
2
+ export declare function connectionFile(env?: NodeJS.ProcessEnv): string;
3
+ export interface StoredConnection {
4
+ revision: number;
5
+ value: Record<string, unknown>;
6
+ }
7
+ /** 读兜底文件;不存在、坏了、被改成非对象都当「没有配置」处理(面板会显示引导态)。 */
8
+ export declare function readConnectionFile(file?: string): StoredConnection;
9
+ /** 先写临时文件再改名:中途失败不会留下半份配置。 */
10
+ export declare function writeConnectionFile(stored: StoredConnection, file?: string): void;
package/lib/store.js ADDED
@@ -0,0 +1,42 @@
1
+ /**
2
+ * 连接配置的兜底存放地。
3
+ *
4
+ * 首选是宿主的 settings 命名空间(设置页可读、可导出、可诊断);但社区插件没法保证
5
+ * 宿主一定提供、也保证不了注册一定成功。所以再加一层:插件自己的 JSON 文件,位置与
6
+ * dsh-email 的 oauth2-tokens.json 同源($DSH_HOME/data/<插件>/),写入 0600。
7
+ * 两条路都只存明文(settings.yaml 本身也是明文),差别只在「谁读得到」。
8
+ */
9
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
10
+ import { homedir } from 'node:os';
11
+ import { dirname, join } from 'node:path';
12
+ /** $DSH_HOME/data/dsh-calendar/connection.json(DSH_HOME 缺省 ~/.dsh)。 */
13
+ export function connectionFile(env = process.env) {
14
+ const home = env.DSH_HOME !== undefined && env.DSH_HOME !== '' ? env.DSH_HOME : join(homedir(), '.dsh');
15
+ return join(home, 'data', 'dsh-calendar', 'connection.json');
16
+ }
17
+ /** 读兜底文件;不存在、坏了、被改成非对象都当「没有配置」处理(面板会显示引导态)。 */
18
+ export function readConnectionFile(file = connectionFile()) {
19
+ try {
20
+ if (!existsSync(file))
21
+ return { revision: 0, value: {} };
22
+ const parsed = JSON.parse(readFileSync(file, 'utf8'));
23
+ const raw = parsed.value;
24
+ const value = raw !== null && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
25
+ const revision = typeof parsed.revision === 'number' && Number.isFinite(parsed.revision) ? Math.trunc(parsed.revision) : 0;
26
+ return { revision, value };
27
+ }
28
+ catch (error) {
29
+ return { revision: 0, value: {} };
30
+ }
31
+ }
32
+ /** 先写临时文件再改名:中途失败不会留下半份配置。 */
33
+ export function writeConnectionFile(stored, file = connectionFile()) {
34
+ mkdirSync(dirname(file), { recursive: true });
35
+ const tmp = file + '.tmp';
36
+ writeFileSync(tmp, JSON.stringify({ revision: stored.revision, value: stored.value }, null, 2) + '\n', { mode: 0o600 });
37
+ try {
38
+ chmodSync(tmp, 0o600);
39
+ }
40
+ catch (error) { /* Windows 上权限位无效,忽略 */ }
41
+ renameSync(tmp, file);
42
+ }
package/lib/tools.d.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  * @module dsh-calendar/tools
6
6
  */
7
7
  import { type CalendarConfig } from './config.js';
8
+ import { type CalendarEvent } from './ical.js';
8
9
  /** 模型可见的内容块。 */
9
10
  export interface ContentBlock {
10
11
  type: 'text';
@@ -26,5 +27,14 @@ export interface CalendarToolDefinition {
26
27
  execute(args: unknown, exec: unknown): Promise<unknown>;
27
28
  timeoutMs?: number;
28
29
  }
30
+ export declare function asRecord(args: unknown): Record<string, unknown>;
31
+ export declare function optionalString(args: Record<string, unknown>, key: string): string | undefined;
32
+ export declare function assertIsoTime(value: string, label: string): void;
33
+ export declare function assertTimeRange(start: string, end: string): void;
34
+ export declare function isoNoMillis(value: string): string;
35
+ /** 按开始时间升序稳定排序(CalDAV 服务端返回顺序不保证稳定)。 */
36
+ export declare function sortEvents(events: CalendarEvent[]): CalendarEvent[];
29
37
  /** 构建六个工具定义;每个 execute 惰性解析配置,缺失时抛出中文指引。 */
30
- export declare function buildCalendarTools(config: CalendarConfig | undefined, env?: NodeJS.ProcessEnv): CalendarToolDefinition[];
38
+ /** 配置来源:静态对象,或一个 getter(面板把新配置写进 settings 后,工具下一次调用就该用新的)。 */
39
+ export type CalendarConfigSource = CalendarConfig | undefined | (() => CalendarConfig | undefined);
40
+ export declare function buildCalendarTools(config: CalendarConfigSource, env?: NodeJS.ProcessEnv): CalendarToolDefinition[];
package/lib/tools.js CHANGED
@@ -9,7 +9,7 @@ import { CALENDAR_PROVIDERS, buildCaldavUrl, resolveAuthMethod, resolveConfig, r
9
9
  import { compileParameters } from './parameters.js';
10
10
  const EVENT_SCHEMA = { type: 'object', additionalProperties: true };
11
11
  const TIMEOUT_MS = 60000;
12
- function asRecord(args) {
12
+ export function asRecord(args) {
13
13
  return typeof args === 'object' && args !== null ? args : {};
14
14
  }
15
15
  function executionSignal(exec) {
@@ -18,7 +18,7 @@ function executionSignal(exec) {
18
18
  const signal = exec.signal;
19
19
  return signal instanceof AbortSignal ? signal : undefined;
20
20
  }
21
- function optionalString(args, key) {
21
+ export function optionalString(args, key) {
22
22
  const value = args[key];
23
23
  return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined;
24
24
  }
@@ -49,7 +49,7 @@ function clampedInteger(args, key, fallback, min, max) {
49
49
  return Math.min(max, Math.max(min, value));
50
50
  }
51
51
  const DATE_ONLY_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/;
52
- function assertIsoTime(value, label) {
52
+ export function assertIsoTime(value, label) {
53
53
  const dateOnly = DATE_ONLY_PATTERN.exec(value);
54
54
  if (dateOnly !== null) {
55
55
  const year = Number(dateOnly[1]);
@@ -73,16 +73,16 @@ function toEpochMs(value) {
73
73
  }
74
74
  return new Date(value).getTime();
75
75
  }
76
- function assertTimeRange(start, end) {
76
+ export function assertTimeRange(start, end) {
77
77
  if (toEpochMs(start) > toEpochMs(end)) {
78
78
  throw new Error('end 不能早于 start:' + end + ' < ' + start + '。请确认起止时间顺序。');
79
79
  }
80
80
  }
81
- function isoNoMillis(value) {
81
+ export function isoNoMillis(value) {
82
82
  return value.replace(/\.\d{3}Z$/, 'Z');
83
83
  }
84
84
  /** 按开始时间升序稳定排序(CalDAV 服务端返回顺序不保证稳定)。 */
85
- function sortEvents(events) {
85
+ export function sortEvents(events) {
86
86
  return events
87
87
  .map((event, index) => ({ event, index, startMs: toEpochMs(event.start) }))
88
88
  .sort((a, b) => a.startMs - b.startMs || a.index - b.index)
@@ -102,12 +102,12 @@ function buildSearchFilter(query) {
102
102
  return haystack.includes(needle);
103
103
  };
104
104
  }
105
- /** 构建六个工具定义;每个 execute 惰性解析配置,缺失时抛出中文指引。 */
106
105
  export function buildCalendarTools(config, env = process.env) {
106
+ const configOf = () => (typeof config === 'function' ? config() : config);
107
107
  let cachedKey;
108
108
  let cachedService;
109
109
  const service = () => {
110
- const resolved = resolveConfig(config, env);
110
+ const resolved = resolveConfig(configOf(), env);
111
111
  // 闭包内比较,不写日志或磁盘;凭据/端点变化时不复用旧 token。
112
112
  const key = JSON.stringify(resolved);
113
113
  if (cachedService === undefined || cachedKey !== key) {
@@ -365,13 +365,15 @@ export function buildCalendarTools(config, env = process.env) {
365
365
  async execute(_args, exec) {
366
366
  executionSignal(exec)?.throwIfAborted();
367
367
  const checks = [];
368
- const rawProvider = config?.provider;
368
+ // 每次自检都重新取配置:面板刚保存的地址应当立刻反映在这里。
369
+ const current = configOf();
370
+ const rawProvider = current?.provider;
369
371
  const provider = rawProvider === undefined || rawProvider === null || rawProvider === '' ? 'custom' : String(rawProvider);
370
372
  const providerOk = CALENDAR_PROVIDERS.includes(provider);
371
373
  checks.push({ name: '服务商', ok: providerOk, detail: providerOk ? provider : '不支持 ' + provider + ';只支持 ' + CALENDAR_PROVIDERS.join(' / ') });
372
374
  try {
373
- const endpoint = buildCaldavUrl(config ?? {}, provider);
374
- if (resolveAuthMethod(config) === 'oauth')
375
+ const endpoint = buildCaldavUrl(current ?? {}, provider);
376
+ if (resolveAuthMethod(current) === 'oauth')
375
377
  validateOAuthUrl(endpoint, 'caldavUrl');
376
378
  checks.push({ name: '日历地址', ok: true, detail: '已解析为 ' + endpoint });
377
379
  }
@@ -379,11 +381,11 @@ export function buildCalendarTools(config, env = process.env) {
379
381
  checks.push({ name: '日历地址', ok: false, detail: error instanceof Error ? error.message : String(error) });
380
382
  }
381
383
  try {
382
- if (resolveAuthMethod(config) === 'oauth') {
384
+ if (resolveAuthMethod(current) === 'oauth') {
383
385
  checks.push({ name: '认证方式', ok: true, detail: 'OAuth 2.0(无需 username/password)' });
384
386
  }
385
387
  else {
386
- const hasUser = typeof config?.username === 'string' && config.username.trim() !== '';
388
+ const hasUser = typeof current?.username === 'string' && current.username.trim() !== '';
387
389
  checks.push({ name: '账号', ok: hasUser, detail: hasUser ? '已配置 Basic 账号' : '未配置:请填 username(iCloud 为账号邮箱)' });
388
390
  }
389
391
  }
@@ -391,7 +393,7 @@ export function buildCalendarTools(config, env = process.env) {
391
393
  checks.push({ name: '认证方式', ok: false, detail: error instanceof Error ? error.message : String(error) });
392
394
  }
393
395
  try {
394
- const credentials = resolveCredentials(config, env);
396
+ const credentials = resolveCredentials(current, env);
395
397
  checks.push({ name: '认证凭据', ok: true, detail: credentials.oauth === undefined
396
398
  ? '已配置 Basic 凭据;仅检查配置,未联网验证'
397
399
  : '已配置 clientId、clientSecret、refreshToken 与 tokenUrl;仅检查配置,未联网验证' });