@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.
- package/CHANGELOG.md +24 -0
- package/LICENSE +201 -0
- package/README.es.md +41 -0
- package/README.hi.md +33 -0
- package/README.md +111 -0
- package/README.pt.md +41 -0
- package/README.zh.md +111 -0
- package/cordis.patch.yml +35 -0
- package/lib/client.js +6233 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +1349 -0
- package/lib/typert.host.js +26 -0
- package/lib/types/client/TicktickAction.d.ts +29 -0
- package/lib/types/client/TicktickAction.d.ts.map +1 -0
- package/lib/types/client/TicktickSettingsCard.d.ts +27 -0
- package/lib/types/client/TicktickSettingsCard.d.ts.map +1 -0
- package/lib/types/client/api.d.ts +34 -0
- package/lib/types/client/api.d.ts.map +1 -0
- package/lib/types/client/dates.d.ts +26 -0
- package/lib/types/client/dates.d.ts.map +1 -0
- package/lib/types/client/index.d.ts +34 -0
- package/lib/types/client/index.d.ts.map +1 -0
- package/lib/types/client/locales.d.ts +61 -0
- package/lib/types/client/locales.d.ts.map +1 -0
- package/lib/types/client/order.d.ts +25 -0
- package/lib/types/client/order.d.ts.map +1 -0
- package/lib/types/client/remote.d.ts +260 -0
- package/lib/types/client/remote.d.ts.map +1 -0
- package/lib/types/client/styles.d.ts +13 -0
- package/lib/types/client/styles.d.ts.map +1 -0
- package/lib/types/config.d.ts +64 -0
- package/lib/types/config.d.ts.map +1 -0
- package/lib/types/domain.d.ts +70 -0
- package/lib/types/domain.d.ts.map +1 -0
- package/lib/types/index.d.ts +56 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/mcp.d.ts +94 -0
- package/lib/types/mcp.d.ts.map +1 -0
- package/lib/types/service.d.ts +160 -0
- package/lib/types/service.d.ts.map +1 -0
- package/lib/types/tools.d.ts +18 -0
- package/lib/types/tools.d.ts.map +1 -0
- package/lib/types/typert.host.d.ts +216 -0
- package/lib/types/typert.host.d.ts.map +1 -0
- package/lib/types/wire.d.ts +561 -0
- package/lib/types/wire.d.ts.map +1 -0
- package/lib/wire-C-vDxxnC.js +5288 -0
- package/package.json +187 -0
- package/probes/lib.mjs +65 -0
- package/probes/probe-bootstrap.mjs +10 -0
- package/probes/probe-crud.mjs +15 -0
- package/probes/probe-due.mjs +28 -0
- package/probes/probe-queries.mjs +18 -0
- package/probes/probe-reorder.mjs +24 -0
- package/src/client/TicktickAction.tsx +356 -0
- package/src/client/TicktickSettingsCard.tsx +182 -0
- package/src/client/api.ts +56 -0
- package/src/client/dates.ts +61 -0
- package/src/client/index.ts +119 -0
- package/src/client/locales.ts +113 -0
- package/src/client/order.ts +37 -0
- package/src/client/remote.ts +76 -0
- package/src/client/styles.ts +48 -0
- package/src/config.ts +136 -0
- package/src/domain.ts +224 -0
- package/src/index.ts +139 -0
- package/src/mcp.ts +213 -0
- package/src/service.ts +403 -0
- package/src/tools.ts +336 -0
- package/src/typert.host.ts +25 -0
- 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
|
+
}
|