@perrylink/dsh-ticktick 0.1.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.
Files changed (71) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/LICENSE +201 -0
  3. package/README.es.md +41 -0
  4. package/README.hi.md +33 -0
  5. package/README.md +111 -0
  6. package/README.pt.md +41 -0
  7. package/README.zh.md +111 -0
  8. package/cordis.patch.yml +35 -0
  9. package/lib/client.js +6233 -0
  10. package/lib/client.js.map +1 -0
  11. package/lib/index.js +1349 -0
  12. package/lib/typert.host.js +26 -0
  13. package/lib/types/client/TicktickAction.d.ts +29 -0
  14. package/lib/types/client/TicktickAction.d.ts.map +1 -0
  15. package/lib/types/client/TicktickSettingsCard.d.ts +27 -0
  16. package/lib/types/client/TicktickSettingsCard.d.ts.map +1 -0
  17. package/lib/types/client/api.d.ts +34 -0
  18. package/lib/types/client/api.d.ts.map +1 -0
  19. package/lib/types/client/dates.d.ts +26 -0
  20. package/lib/types/client/dates.d.ts.map +1 -0
  21. package/lib/types/client/index.d.ts +34 -0
  22. package/lib/types/client/index.d.ts.map +1 -0
  23. package/lib/types/client/locales.d.ts +61 -0
  24. package/lib/types/client/locales.d.ts.map +1 -0
  25. package/lib/types/client/order.d.ts +25 -0
  26. package/lib/types/client/order.d.ts.map +1 -0
  27. package/lib/types/client/remote.d.ts +260 -0
  28. package/lib/types/client/remote.d.ts.map +1 -0
  29. package/lib/types/client/styles.d.ts +13 -0
  30. package/lib/types/client/styles.d.ts.map +1 -0
  31. package/lib/types/config.d.ts +64 -0
  32. package/lib/types/config.d.ts.map +1 -0
  33. package/lib/types/domain.d.ts +70 -0
  34. package/lib/types/domain.d.ts.map +1 -0
  35. package/lib/types/index.d.ts +56 -0
  36. package/lib/types/index.d.ts.map +1 -0
  37. package/lib/types/mcp.d.ts +94 -0
  38. package/lib/types/mcp.d.ts.map +1 -0
  39. package/lib/types/service.d.ts +160 -0
  40. package/lib/types/service.d.ts.map +1 -0
  41. package/lib/types/tools.d.ts +18 -0
  42. package/lib/types/tools.d.ts.map +1 -0
  43. package/lib/types/typert.host.d.ts +216 -0
  44. package/lib/types/typert.host.d.ts.map +1 -0
  45. package/lib/types/wire.d.ts +561 -0
  46. package/lib/types/wire.d.ts.map +1 -0
  47. package/lib/wire-C-vDxxnC.js +5288 -0
  48. package/package.json +187 -0
  49. package/probes/lib.mjs +65 -0
  50. package/probes/probe-bootstrap.mjs +10 -0
  51. package/probes/probe-crud.mjs +15 -0
  52. package/probes/probe-due.mjs +28 -0
  53. package/probes/probe-queries.mjs +18 -0
  54. package/probes/probe-reorder.mjs +24 -0
  55. package/src/client/TicktickAction.tsx +356 -0
  56. package/src/client/TicktickSettingsCard.tsx +182 -0
  57. package/src/client/api.ts +56 -0
  58. package/src/client/dates.ts +61 -0
  59. package/src/client/index.ts +119 -0
  60. package/src/client/locales.ts +113 -0
  61. package/src/client/order.ts +37 -0
  62. package/src/client/remote.ts +76 -0
  63. package/src/client/styles.ts +48 -0
  64. package/src/config.ts +136 -0
  65. package/src/domain.ts +224 -0
  66. package/src/index.ts +139 -0
  67. package/src/mcp.ts +213 -0
  68. package/src/service.ts +403 -0
  69. package/src/tools.ts +336 -0
  70. package/src/typert.host.ts +25 -0
  71. package/src/wire.ts +401 -0
package/src/service.ts ADDED
@@ -0,0 +1,403 @@
1
+ /**
2
+ * The TickTick bridge's host service: drives one lazily bootstrapped MCP
3
+ * streamable-HTTP client against the TickTick endpoint and serves the eight
4
+ * panel/model operations under the `ticktick` Typert Remote namespace.
5
+ *
6
+ * Measured wire facts (live endpoint, 0.1.0 era) encoded here:
7
+ * - list order is descending `sortOrder`; reorder = move_task with the new
8
+ * sortOrder (project tasks) or update_task (inbox tasks);
9
+ * - `update_task` crashes server-side for tasks inside regular projects
10
+ * ("Expecting value: line 1 column 1") — project due dates take the
11
+ * direct-update attempt first and fall back to the move-to-inbox →
12
+ * update → move-back detour on that signature;
13
+ * - clearing a due date writes the epoch sentinel;
14
+ * - a project whose response fails server-side validation (historical
15
+ * `repeatFrom: ''` data) is skipped and reported as a warning, never
16
+ * silently dropped.
17
+ *
18
+ * @module dsh-ticktick/service
19
+ */
20
+
21
+ import type { Context } from '@deepseek-ai/cordis'
22
+ import { RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
23
+ import type { ToolPins } from './config.ts'
24
+ import {
25
+ isInboxProject,
26
+ normalizeProjects,
27
+ normalizeTasks,
28
+ resolveTools,
29
+ type TicktickProject,
30
+ type TicktickTask,
31
+ type ToolResolver,
32
+ } from './domain.ts'
33
+ import { McpStreamableClient, type McpCallResult, type McpClientFace } from './mcp.ts'
34
+ import type { TicktickAddResult, TicktickOkResult, TicktickProbeResult, TicktickStatus, TicktickTaskWire, TicktickTasksResult } from './wire.ts'
35
+
36
+ declare module '@deepseek-ai/dsh-typert-protocol' {
37
+ interface RemoteErrorDetailsMap {
38
+ /** No Bearer token is resolvable from the token file or settings. */
39
+ 'ticktick/no-token': Record<string, never>
40
+ /** The operation named a task on the protected-id list. */
41
+ 'ticktick/protected': { readonly taskId: string }
42
+ /** A required argument is missing or malformed. */
43
+ 'ticktick/bad-request': { readonly field: string }
44
+ /** The MCP endpoint rejected or failed the tool call. */
45
+ 'ticktick/tool-error': { readonly tool: string }
46
+ }
47
+ }
48
+
49
+ declare module '@deepseek-ai/cordis' {
50
+ interface Context {
51
+ /** TickTick task bridge service (this package). */
52
+ ticktick: TicktickService
53
+ }
54
+ }
55
+
56
+ /** Runtime policy the service reads; the token getter re-reads at call time. */
57
+ export interface TicktickServiceConfig {
58
+ /** Resolve the current Bearer token (file or settings); `null` = unconfigured. */
59
+ getToken(): string | null
60
+ /** TickTick MCP endpoint. */
61
+ readonly mcpUrl: string
62
+ /** Per-tools/call deadline in milliseconds. */
63
+ readonly toolCallTimeoutMs: number
64
+ /** Task ids every mutating operation refuses. */
65
+ readonly protectedTaskIds: readonly string[]
66
+ /** Raw tool-name pins. */
67
+ readonly pins: ToolPins
68
+ }
69
+
70
+ /** Default client factory: the real streamable-HTTP client. */
71
+ export type ClientFactory = (url: string, token: string, timeoutMs: number) => McpClientFace
72
+
73
+ /** The epoch sentinel TickTick documents for clearing a due date. */
74
+ const EPOCH_SENTINEL = '1970-01-01T00:00:00.000+0000'
75
+
76
+ /** update_task server-side crash signature (Bug A): project tasks only. */
77
+ const UPDATE_CRASH_SIGNATURE = /expecting value|line 1 column 1|char 0|json/i
78
+
79
+ /** Whether an error message names an auth failure. */
80
+ const AUTH_SIGNATURE = /401|unauthor|invalid token|token/i
81
+
82
+ /** Project one normalized task into the wire shape (`null` for absent fields). */
83
+ function toWireTask(task: TicktickTask): TicktickTaskWire {
84
+ return {
85
+ id: task.id,
86
+ title: task.title,
87
+ done: task.done,
88
+ projectId: task.projectId ?? null,
89
+ dueDate: task.dueDate ?? null,
90
+ sortOrder: task.sortOrder ?? null,
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Host service for the TickTick bridge: status plus the seven task
96
+ * operations, exported over the `ticktick` Remote namespace (hand-written
97
+ * `./typert` manifest, no decorators).
98
+ */
99
+ export class TicktickService extends TypertRemoteService {
100
+ /** The MCP client, lazily bootstrapped per token. */
101
+ private client: McpClientFace | undefined
102
+ /** Resolved tool names for the bootstrapped client. */
103
+ private resolver: ToolResolver | undefined
104
+ /** Token the current client was built for. */
105
+ private tokenAtBoot: string | undefined
106
+ /** Most recent bridge error message; `null` when the last call settled. */
107
+ private lastError: string | null = null
108
+ /** Whether the current token has produced a boot error (avoid hot loops). */
109
+ private bootFailed = false
110
+
111
+ /**
112
+ * @param ctx - owning Cordis context.
113
+ * @param config - runtime policy (token getter, endpoint, timeout, guards, pins).
114
+ * @param createClient - test seam: client factory (defaults to the real MCP client).
115
+ */
116
+ constructor(
117
+ ctx: Context,
118
+ private readonly config: TicktickServiceConfig,
119
+ private readonly createClient: ClientFactory = (url, token, timeoutMs) => new McpStreamableClient(url, token, timeoutMs),
120
+ ) {
121
+ super(ctx, 'ticktick')
122
+ }
123
+
124
+ /** Connection and configuration facts (no handshake forced). */
125
+ status(): TicktickStatus {
126
+ const resolver = this.resolver
127
+ return {
128
+ configured: this.config.getToken() !== null,
129
+ connected: this.client !== undefined,
130
+ mcpUrl: this.config.mcpUrl,
131
+ lastError: this.lastError,
132
+ toolNames: {
133
+ projects: resolver?.projects ?? '',
134
+ tasks: resolver?.tasks ?? '',
135
+ create: resolver?.create ?? '',
136
+ complete: resolver?.complete ?? '',
137
+ remove: resolver?.remove ?? '',
138
+ update: resolver?.update ?? '',
139
+ move: resolver?.move ?? '',
140
+ },
141
+ }
142
+ }
143
+
144
+ /** Drop the bootstrapped client so the next call re-reads the token. */
145
+ reset(): void {
146
+ this.client = undefined
147
+ this.resolver = undefined
148
+ this.tokenAtBoot = undefined
149
+ this.bootFailed = false
150
+ }
151
+
152
+ /**
153
+ * One-shot connectivity check over a throwaway client (the cached bridge
154
+ * client is untouched): initialize + tool listing against the current
155
+ * token. Feeds the settings card's "test connection" button.
156
+ */
157
+ async probe(): Promise<TicktickProbeResult> {
158
+ const token = this.config.getToken()
159
+ if (token === null) return { ok: false, toolCount: 0, error: 'no token configured' }
160
+ const client = this.createClient(this.config.mcpUrl, token, this.config.toolCallTimeoutMs)
161
+ try {
162
+ await client.initialize()
163
+ const tools = await client.listTools()
164
+ return { ok: true, toolCount: tools.length, error: null }
165
+ } catch (error) {
166
+ return { ok: false, toolCount: 0, error: error instanceof Error ? error.message : String(error) }
167
+ }
168
+ }
169
+
170
+ /** List the user's projects (the virtual inbox included). */
171
+ async projects(): Promise<{ projects: readonly TicktickProject[] }> {
172
+ const { client, resolver } = await this.ensure()
173
+ const projects = normalizeProjects(await this.call(client, resolver.projects, {}))
174
+ return { projects }
175
+ }
176
+
177
+ /**
178
+ * Aggregate undone tasks: one project when named, else every project.
179
+ * Projects whose response fails server-side validation are skipped and
180
+ * reported as warnings; each project's chunk stays sorted by descending
181
+ * sortOrder (the measured list order).
182
+ */
183
+ async tasks(projectId?: string): Promise<TicktickTasksResult> {
184
+ const { client, resolver } = await this.ensure()
185
+ const projects = normalizeProjects(await this.call(client, resolver.projects, {}))
186
+ .filter(project => projectId === undefined || projectId === '' || project.id === projectId)
187
+ const tasks: TicktickTaskWire[] = []
188
+ const warnings: string[] = []
189
+ for (const project of projects) {
190
+ try {
191
+ const result = await this.call(client, resolver.tasks, { project_id: project.id })
192
+ const chunk = normalizeTasks(result)
193
+ .map(task => toWireTask(task.projectId === undefined ? { ...task, projectId: project.id } : task))
194
+ .sort((a, b) => (b.sortOrder ?? 0) - (a.sortOrder ?? 0))
195
+ tasks.push(...chunk)
196
+ } catch (error) {
197
+ warnings.push(`${project.name}: ${error instanceof Error ? error.message : String(error)}`)
198
+ }
199
+ }
200
+ return { tasks, warnings }
201
+ }
202
+
203
+ /**
204
+ * Completed tasks within a window (P2): `list_completed_tasks_by_date`
205
+ * with a startDate `days` back (default 30) and an optional project.
206
+ */
207
+ async completed(projectId?: string, days = 30): Promise<TicktickTasksResult> {
208
+ const { client, resolver } = await this.ensure()
209
+ if (resolver.completed === '') {
210
+ throw new RemoteError('ticktick/tool-error', 'the MCP endpoint does not advertise a completed-tasks tool', { tool: 'completed' })
211
+ }
212
+ const search: Record<string, unknown> = { startDate: new Date(Date.now() - days * 86_400_000).toISOString().slice(0, 10) }
213
+ if (projectId !== undefined && projectId !== '') search.projectIds = [projectId]
214
+ const result = await this.call(client, resolver.completed, { search })
215
+ return { tasks: normalizeTasks(result).map(toWireTask), warnings: [] }
216
+ }
217
+
218
+ /** Full-text search over TickTick tasks (P2): `search` / `search_task`. */
219
+ async search(query: string): Promise<TicktickTasksResult> {
220
+ const clean = query.trim()
221
+ if (clean === '') throw new RemoteError('ticktick/bad-request', 'query required', { field: 'query' })
222
+ const { client, resolver } = await this.ensure()
223
+ if (resolver.search === '') {
224
+ throw new RemoteError('ticktick/tool-error', 'the MCP endpoint does not advertise a search tool', { tool: 'search' })
225
+ }
226
+ const result = await this.call(client, resolver.search, { query: clean })
227
+ return { tasks: normalizeTasks(result).map(toWireTask), warnings: [] }
228
+ }
229
+
230
+ /** Batch-create tasks (P2): `batch_add_tasks` over the OpenTask rows. */
231
+ async batchAdd(tasks: readonly { title: string, projectId?: string, dueDate?: string }[]): Promise<{ created: number }> {
232
+ const rows = tasks
233
+ .map(task => ({ ...task, title: task.title.trim() }))
234
+ .filter(task => task.title !== '')
235
+ if (rows.length === 0) throw new RemoteError('ticktick/bad-request', 'at least one non-empty title required', { field: 'tasks' })
236
+ const { client, resolver } = await this.ensure()
237
+ if (resolver.batchAdd === '') {
238
+ throw new RemoteError('ticktick/tool-error', 'the MCP endpoint does not advertise a batch-add tool', { tool: 'batchAdd' })
239
+ }
240
+ await this.call(client, resolver.batchAdd, {
241
+ tasks: rows.map(row => ({
242
+ title: row.title,
243
+ ...(row.projectId !== undefined && row.projectId !== '' ? { projectId: row.projectId } : {}),
244
+ ...(row.dueDate !== undefined && row.dueDate !== '' ? { dueDate: row.dueDate } : {}),
245
+ })),
246
+ })
247
+ return { created: rows.length }
248
+ }
249
+
250
+ /** Create one task; a named project places it there, otherwise the Inbox. */
251
+ async add(title: string, projectId?: string, dueDate?: string): Promise<TicktickAddResult> {
252
+ const clean = title.trim()
253
+ if (clean === '') throw new RemoteError('ticktick/bad-request', 'title required', { field: 'title' })
254
+ const { client, resolver } = await this.ensure()
255
+ const task: Record<string, unknown> = { title: clean }
256
+ if (projectId !== undefined && projectId !== '') task.projectId = projectId
257
+ if (dueDate !== undefined && dueDate !== '') task.dueDate = dueDate
258
+ const result = await this.call(client, resolver.create, { task })
259
+ const created = normalizeTasks(result)[0]
260
+ // Read-after-write verification (P2): re-read the task when the
261
+ // endpoint advertises a by-id lookup and surface a mismatch warning.
262
+ let verifyWarning: string | null = null
263
+ if (created !== undefined && resolver.getTask !== '') {
264
+ try {
265
+ const readback = await this.call(client, resolver.getTask, { task_id: created.id })
266
+ const rows = normalizeTasks(readback)
267
+ if (rows.length === 0) verifyWarning = 'readback returned no task'
268
+ else if (rows[0]!.title !== clean) verifyWarning = `readback title mismatch: ${rows[0]!.title}`
269
+ } catch {
270
+ verifyWarning = 'readback failed (verification skipped)'
271
+ }
272
+ }
273
+ return { task: created === undefined ? null : toWireTask(created), verifyWarning }
274
+ }
275
+
276
+ /** Mark one task complete (TickTick needs both the project and the task id). */
277
+ async complete(id: string, projectId: string): Promise<TicktickOkResult> {
278
+ this.requireIds(id, projectId)
279
+ const { client, resolver } = await this.ensure()
280
+ await this.call(client, resolver.complete, { project_id: projectId, task_id: id })
281
+ return { ok: true }
282
+ }
283
+
284
+ /** Delete one task (TickTick needs both the project and the task id). */
285
+ async remove(id: string, projectId: string): Promise<TicktickOkResult> {
286
+ this.requireIds(id, projectId)
287
+ const { client, resolver } = await this.ensure()
288
+ await this.call(client, resolver.remove, { project_id: projectId, task_id: id })
289
+ return { ok: true }
290
+ }
291
+
292
+ /**
293
+ * Set (or clear) one task's due date. Inbox tasks update directly.
294
+ * Project tasks try the direct update first and fall back to the measured
295
+ * move-to-inbox → update → move-back detour when the server answers with
296
+ * its update_task crash signature.
297
+ */
298
+ async setDue(id: string, projectId: string | undefined, dueDate?: string): Promise<TicktickOkResult> {
299
+ if (id === '') throw new RemoteError('ticktick/bad-request', 'id required', { field: 'id' })
300
+ this.assertNotProtected(id)
301
+ const effective = dueDate !== undefined && dueDate !== '' ? dueDate : EPOCH_SENTINEL
302
+ const { client, resolver } = await this.ensure()
303
+ const inProject = projectId !== undefined && projectId !== '' && !isInboxProject(projectId)
304
+ if (!inProject) {
305
+ await this.call(client, resolver.update, { task_id: id, task: { dueDate: effective } })
306
+ return { ok: true }
307
+ }
308
+ try {
309
+ await this.call(client, resolver.update, { task_id: id, task: { dueDate: effective } })
310
+ } catch (error) {
311
+ if (!UPDATE_CRASH_SIGNATURE.test(error instanceof Error ? error.message : String(error))) throw error
312
+ await this.call(client, resolver.move, { moves: [{ fromProjectId: projectId, toProjectId: 'inbox', taskId: id }] })
313
+ try {
314
+ await this.call(client, resolver.update, { task_id: id, task: { dueDate: effective } })
315
+ } finally {
316
+ await this.call(client, resolver.move, { moves: [{ fromProjectId: 'inbox', toProjectId: projectId, taskId: id }] })
317
+ }
318
+ }
319
+ return { ok: true }
320
+ }
321
+
322
+ /**
323
+ * Move one task to a new position: inbox tasks take update_task with the
324
+ * new sortOrder; project tasks take a same-project move carrying the new
325
+ * sortOrder (the list order is descending sortOrder).
326
+ */
327
+ async reorder(id: string, projectId: string | undefined, sortOrder: number): Promise<TicktickOkResult> {
328
+ if (id === '') throw new RemoteError('ticktick/bad-request', 'id required', { field: 'id' })
329
+ if (!Number.isFinite(sortOrder) || !Number.isInteger(sortOrder)) {
330
+ throw new RemoteError('ticktick/bad-request', 'sortOrder must be an integer', { field: 'sortOrder' })
331
+ }
332
+ this.assertNotProtected(id)
333
+ const { client, resolver } = await this.ensure()
334
+ if (projectId !== undefined && projectId !== '' && !isInboxProject(projectId)) {
335
+ await this.call(client, resolver.move, {
336
+ moves: [{ fromProjectId: projectId, toProjectId: projectId, taskId: id, sortOrder }],
337
+ })
338
+ } else {
339
+ await this.call(client, resolver.update, { task_id: id, task: { sortOrder } })
340
+ }
341
+ return { ok: true }
342
+ }
343
+
344
+ /** Reject a mutating operation naming an id on the protected list. */
345
+ private assertNotProtected(id: string): void {
346
+ if (this.config.protectedTaskIds.includes(id)) {
347
+ throw new RemoteError('ticktick/protected', `task ${id} is protected`, { taskId: id })
348
+ }
349
+ }
350
+
351
+ /** Validate the id pair required by complete/delete. */
352
+ private requireIds(id: string, projectId: string): void {
353
+ if (id === '') throw new RemoteError('ticktick/bad-request', 'id required', { field: 'id' })
354
+ if (projectId === '') throw new RemoteError('ticktick/bad-request', 'projectId required', { field: 'projectId' })
355
+ this.assertNotProtected(id)
356
+ }
357
+
358
+ /**
359
+ * Lazily bootstrap the MCP client and tool resolver for the active token.
360
+ * A token change or a 401-triggered reset rebuilds the client.
361
+ */
362
+ private async ensure(): Promise<{ client: McpClientFace, resolver: ToolResolver }> {
363
+ const token = this.config.getToken()
364
+ if (token === null) throw new RemoteError('ticktick/no-token', 'no token: set it in the TickTick settings card or write the API 口令 to the token file', {})
365
+ if (this.client === undefined || this.tokenAtBoot !== token || this.bootFailed) {
366
+ this.client = undefined
367
+ this.resolver = undefined
368
+ this.tokenAtBoot = undefined
369
+ this.bootFailed = false
370
+ const client = this.createClient(this.config.mcpUrl, token, this.config.toolCallTimeoutMs)
371
+ try {
372
+ await client.initialize()
373
+ const tools = await client.listTools()
374
+ this.resolver = resolveTools(tools, this.config.pins)
375
+ this.client = client
376
+ this.tokenAtBoot = token
377
+ this.lastError = null
378
+ } catch (error) {
379
+ this.bootFailed = true
380
+ this.lastError = error instanceof Error ? error.message : String(error)
381
+ throw new RemoteError('ticktick/tool-error', this.lastError, { tool: 'initialize' }, { cause: error })
382
+ }
383
+ }
384
+ return { client: this.client, resolver: this.resolver! }
385
+ }
386
+
387
+ /**
388
+ * One tools/call with error mapping: tool-level failures carry their raw
389
+ * tool name; a 401 signature resets the client once so the next call
390
+ * re-reads the token file.
391
+ */
392
+ private async call(client: McpClientFace, tool: string, args: Record<string, unknown>): Promise<McpCallResult> {
393
+ try {
394
+ this.lastError = null
395
+ return await client.callTool(tool, args)
396
+ } catch (error) {
397
+ const message = error instanceof Error ? error.message : String(error)
398
+ this.lastError = message
399
+ if (AUTH_SIGNATURE.test(message)) this.reset()
400
+ throw new RemoteError('ticktick/tool-error', message, { tool }, { cause: error })
401
+ }
402
+ }
403
+ }