@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
@@ -0,0 +1,356 @@
1
+ /**
2
+ * The TickTick Session-header action: a button that opens the task panel
3
+ * popup. The panel browses lists, filters by list, toggles between undone
4
+ * and completed views, runs full-text search, adds tasks, completes,
5
+ * deletes, sets/clears due dates, and drag-reorders (undone, single-list
6
+ * views only — cross-list ordering has no TickTick semantics). All data
7
+ * flows through the injected {@link TicktickApi}; the panel holds no other
8
+ * RPC.
9
+ *
10
+ * @module dsh-ticktick/client/TicktickAction
11
+ */
12
+
13
+ import { createElement as h, Fragment, useEffect, useRef, useState } from 'react'
14
+ import type { ChangeEvent, DragEvent, ReactElement } from 'react'
15
+ import type { TicktickApi } from './api.ts'
16
+ import type { TicktickTaskWire } from '../wire.ts'
17
+ import { formatDue } from './dates.ts'
18
+ import { sortOrderAtEnd, sortOrderBefore } from './order.ts'
19
+ import { en, type TicktickLocaleKey } from './locales.ts'
20
+
21
+ /** Translator face (bound to this plugin's locale namespace by the renderer). */
22
+ export type TicktickTranslator = (key: TicktickLocaleKey) => string
23
+
24
+ /** Props the header-actions slot injects. */
25
+ export interface TicktickActionInjected {
26
+ api: TicktickApi
27
+ /** Write the API token into the settings namespace (one-step panel setup). */
28
+ setToken: (token: string) => Promise<void>
29
+ t?: TicktickTranslator
30
+ }
31
+
32
+ /** One project for the filter dropdown. */
33
+ interface ProjectOption {
34
+ id: string
35
+ name: string
36
+ }
37
+
38
+ /** All lists sentinel. */
39
+ const ALL = '__all__'
40
+
41
+ /** Panel view modes. */
42
+ type ViewMode = 'undone' | 'completed'
43
+
44
+ /**
45
+ * The header action component: button + popup panel.
46
+ * @param props - injected api and optional translator.
47
+ */
48
+ export function TicktickAction(props: TicktickActionInjected): ReactElement {
49
+ const { api, setToken } = props
50
+ const t: TicktickTranslator = props.t ?? (key => en[key])
51
+ const [open, setOpen] = useState(false)
52
+ const [projects, setProjects] = useState<readonly ProjectOption[]>([])
53
+ const [tasks, setTasks] = useState<readonly TicktickTaskWire[]>([])
54
+ const [warnings, setWarnings] = useState<readonly string[]>([])
55
+ const [selected, setSelected] = useState<string>(ALL)
56
+ const [viewMode, setViewMode] = useState<ViewMode>('undone')
57
+ const [searchQuery, setSearchQuery] = useState('')
58
+ const [busy, setBusy] = useState(false)
59
+ const [error, setError] = useState<string | null>(null)
60
+ const [addTitle, setAddTitle] = useState('')
61
+ const [addDue, setAddDue] = useState('')
62
+ const [draggingId, setDraggingId] = useState<string | null>(null)
63
+ const [status, setStatus] = useState<{ configured: boolean, connected: boolean } | null>(null)
64
+ const [tokenInput, setTokenInput] = useState('')
65
+ const panelRef = useRef<HTMLDivElement>(null)
66
+
67
+ const searching = searchQuery.trim() !== ''
68
+ const singleList = selected !== ALL
69
+ const editable = !searching && viewMode === 'undone'
70
+
71
+ const projectName = (id: string | null): string => {
72
+ if (id === null) return '?'
73
+ return projects.find(project => project.id === id)?.name ?? id
74
+ }
75
+
76
+ const refreshStatus = async (): Promise<void> => {
77
+ try {
78
+ const current = await api.status()
79
+ setStatus({ configured: current.configured, connected: current.connected })
80
+ } catch {
81
+ setStatus({ configured: false, connected: false })
82
+ }
83
+ }
84
+
85
+ const saveToken = async (): Promise<void> => {
86
+ const token = tokenInput.trim()
87
+ if (token === '') return
88
+ setBusy(true)
89
+ setError(null)
90
+ try {
91
+ await setToken(token)
92
+ setTokenInput('')
93
+ await refreshStatus()
94
+ await load()
95
+ } catch (cause) {
96
+ setError(cause instanceof Error ? cause.message : String(cause))
97
+ } finally {
98
+ setBusy(false)
99
+ }
100
+ }
101
+
102
+ const load = async (): Promise<void> => {
103
+ setBusy(true)
104
+ setError(null)
105
+ try {
106
+ const projectId = selected === ALL ? undefined : selected
107
+ let taskResult
108
+ if (searching) {
109
+ taskResult = await api.search(searchQuery.trim())
110
+ } else if (viewMode === 'completed') {
111
+ taskResult = await api.completed(projectId, 30)
112
+ } else {
113
+ taskResult = await api.tasks(projectId)
114
+ }
115
+ const projectResult = await api.projects()
116
+ setProjects(projectResult.projects)
117
+ setTasks(taskResult.tasks)
118
+ setWarnings(taskResult.warnings)
119
+ } catch (cause) {
120
+ // The inline token setup explains the unconfigured state; keep other
121
+ // failures visible.
122
+ if (!/no-token|no token/i.test(cause instanceof Error ? cause.message : String(cause))) {
123
+ setError(cause instanceof Error ? cause.message : String(cause))
124
+ }
125
+ } finally {
126
+ setBusy(false)
127
+ }
128
+ }
129
+
130
+ useEffect(() => {
131
+ if (!open) return
132
+ void load()
133
+ void refreshStatus()
134
+ const onPointerDown = (event: MouseEvent): void => {
135
+ if (panelRef.current !== null && event.target instanceof Node && !panelRef.current.contains(event.target)) {
136
+ setOpen(false)
137
+ }
138
+ }
139
+ document.addEventListener('mousedown', onPointerDown)
140
+ return () => document.removeEventListener('mousedown', onPointerDown)
141
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- user actions reload explicitly.
142
+ }, [open])
143
+
144
+ const selectProject = async (value: string): Promise<void> => {
145
+ setSelected(value)
146
+ await load()
147
+ }
148
+
149
+ const switchView = async (mode: ViewMode): Promise<void> => {
150
+ setViewMode(mode)
151
+ setSearchQuery('')
152
+ await load()
153
+ }
154
+
155
+ const runSearch = async (query: string): Promise<void> => {
156
+ setSearchQuery(query)
157
+ if (query.trim() === '') {
158
+ await load()
159
+ return
160
+ }
161
+ setBusy(true)
162
+ setError(null)
163
+ try {
164
+ const result = await api.search(query.trim())
165
+ setTasks(result.tasks)
166
+ setWarnings(result.warnings)
167
+ } catch (cause) {
168
+ setError(cause instanceof Error ? cause.message : String(cause))
169
+ } finally {
170
+ setBusy(false)
171
+ }
172
+ }
173
+
174
+ const submitAdd = async (): Promise<void> => {
175
+ const title = addTitle.trim()
176
+ if (title === '') return
177
+ setBusy(true)
178
+ setError(null)
179
+ try {
180
+ await api.add(title, selected === ALL ? undefined : selected, addDue === '' ? undefined : addDue)
181
+ setAddTitle('')
182
+ setAddDue('')
183
+ await load()
184
+ } catch (cause) {
185
+ setError(cause instanceof Error ? cause.message : String(cause))
186
+ } finally {
187
+ setBusy(false)
188
+ }
189
+ }
190
+
191
+ const runTask = async (action: () => Promise<void>): Promise<void> => {
192
+ setBusy(true)
193
+ setError(null)
194
+ try {
195
+ await action()
196
+ await load()
197
+ } catch (cause) {
198
+ setError(cause instanceof Error ? cause.message : String(cause))
199
+ } finally {
200
+ setBusy(false)
201
+ }
202
+ }
203
+
204
+ const completeTask = (task: TicktickTaskWire): Promise<void> => runTask(async () => {
205
+ await api.complete(task.id, task.projectId ?? '')
206
+ })
207
+
208
+ const removeTask = (task: TicktickTaskWire): Promise<void> => runTask(async () => {
209
+ if (!window.confirm(t('confirmDelete'))) return
210
+ await api.remove(task.id, task.projectId ?? '')
211
+ })
212
+
213
+ const applyDue = (task: TicktickTaskWire, value: string): Promise<void> => runTask(async () => {
214
+ await api.setDue(task.id, task.projectId ?? undefined, value === '' ? undefined : value)
215
+ })
216
+
217
+ const clearDue = (task: TicktickTaskWire): Promise<void> => runTask(async () => {
218
+ await api.setDue(task.id, task.projectId ?? undefined)
219
+ })
220
+
221
+ const dropOnTask = (target: TicktickTaskWire): Promise<void> => {
222
+ if (draggingId === null || draggingId === target.id) return Promise.resolve()
223
+ const order = sortOrderBefore(target)
224
+ if (order === null) return Promise.resolve()
225
+ return runTask(async () => {
226
+ await api.reorder(draggingId, target.projectId ?? undefined, order)
227
+ }).then(() => { setDraggingId(null) })
228
+ }
229
+
230
+ const dropAtEnd = (): Promise<void> => {
231
+ if (draggingId === null) return Promise.resolve()
232
+ const order = sortOrderAtEnd(tasks)
233
+ if (order === null) return Promise.resolve()
234
+ const dragged = tasks.find(task => task.id === draggingId)
235
+ return runTask(async () => {
236
+ await api.reorder(draggingId, dragged?.projectId ?? undefined, order)
237
+ }).then(() => { setDraggingId(null) })
238
+ }
239
+
240
+ const viewToggle = (mode: ViewMode, label: string): ReactElement =>
241
+ h('button', {
242
+ className: 'tkt-iconbtn',
243
+ type: 'button',
244
+ disabled: busy,
245
+ style: viewMode === mode ? { fontWeight: 600, textDecoration: 'underline' } : undefined,
246
+ onClick: () => { void switchView(mode) },
247
+ }, label)
248
+
249
+ return h('div', { className: 'tkt-anchor' },
250
+ h('button', { className: 'tkt-button', type: 'button', title: t('open'), onClick: () => { setOpen(!open) } },
251
+ h('span', null, '☑'),
252
+ h('span', null, t('title'))),
253
+ open && h('div', { className: 'tkt-panel', ref: panelRef },
254
+ h('div', { className: 'tkt-row', style: { gap: '6px' } },
255
+ h('select', { className: 'tkt-select', value: selected, disabled: busy, onChange: (event: ChangeEvent<HTMLSelectElement>) => { void selectProject(event.target.value) } },
256
+ h('option', { value: ALL }, t('allLists')),
257
+ projects.map(project => h('option', { key: project.id, value: project.id }, project.name))),
258
+ viewToggle('undone', t('viewUndone')),
259
+ viewToggle('completed', t('viewCompleted')),
260
+ h('button', { className: 'tkt-iconbtn', type: 'button', title: t('refresh'), disabled: busy, onClick: () => { void load() } }, '↻'),
261
+ status !== null && h('span', {
262
+ className: `tkt-chip ${status.configured ? (status.connected ? 'today' : 'overdue') : 'later'}`,
263
+ title: status.configured ? (status.connected ? t('statusConnected') : t('statusNotConnected')) : t('statusUnconfigured'),
264
+ }, '●')),
265
+ h('div', { className: 'tkt-row', style: { gap: '6px' } },
266
+ h('input', {
267
+ className: 'tkt-input',
268
+ placeholder: t('searchPlaceholder'),
269
+ value: searchQuery,
270
+ onChange: (event: ChangeEvent<HTMLInputElement>) => { void runSearch(event.target.value) },
271
+ })),
272
+ status?.configured === false && h('div', { className: 'tkt-warning', style: { display: 'flex', flexDirection: 'column', gap: '6px' } },
273
+ h('div', null, t('panelTokenHint')),
274
+ h('div', { style: { display: 'flex', gap: '6px' } },
275
+ h('input', {
276
+ className: 'tkt-input',
277
+ type: 'password',
278
+ placeholder: t('panelTokenPlaceholder'),
279
+ value: tokenInput,
280
+ onChange: (event: ChangeEvent<HTMLInputElement>) => { setTokenInput(event.target.value) },
281
+ onKeyDown: event => { if (event.key === 'Enter') void saveToken() },
282
+ }),
283
+ h('button', { className: 'tkt-iconbtn', type: 'button', disabled: busy || tokenInput.trim() === '', onClick: () => { void saveToken() } }, t('panelTokenSave')))),
284
+ editable && h('div', { className: 'tkt-row', style: { gap: '6px' } },
285
+ h('input', {
286
+ className: 'tkt-input',
287
+ placeholder: t('addPlaceholder'),
288
+ value: addTitle,
289
+ onChange: (event: ChangeEvent<HTMLInputElement>) => { setAddTitle(event.target.value) },
290
+ onKeyDown: event => { if (event.key === 'Enter') void submitAdd() },
291
+ }),
292
+ h('input', {
293
+ className: 'tkt-date',
294
+ type: 'date',
295
+ value: addDue,
296
+ onChange: (event: ChangeEvent<HTMLInputElement>) => { setAddDue(event.target.value) },
297
+ }),
298
+ h('button', { className: 'tkt-iconbtn', type: 'button', disabled: busy || addTitle.trim() === '', onClick: () => { void submitAdd() } }, t('add'))),
299
+ error !== null && h('div', { className: 'tkt-error' }, t('loadError') + error),
300
+ warnings.map(warning => h('div', { key: warning, className: 'tkt-warning' }, `${t('warning')}: ${warning}`)),
301
+ tasks.length === 0 && !busy && h('div', { className: 'tkt-warning' }, searching ? t('searchEmpty') : t('empty')),
302
+ h('div', { className: 'tkt-list' },
303
+ tasks.map(task => {
304
+ const due = formatDue(task.dueDate)
305
+ const chipClass = due.kind === 'none' ? 'later' : due.kind
306
+ return h('div', {
307
+ key: task.id,
308
+ className: `tkt-row${draggingId === task.id ? ' dragging' : ''}`,
309
+ draggable: editable && singleList && !busy,
310
+ onDragStart: () => { setDraggingId(task.id) },
311
+ onDragOver: (event: DragEvent<HTMLDivElement>) => { event.preventDefault() },
312
+ onDrop: (event: DragEvent<HTMLDivElement>) => { event.preventDefault(); void dropOnTask(task) },
313
+ },
314
+ h('input', {
315
+ type: 'checkbox',
316
+ checked: task.done,
317
+ disabled: busy || !editable,
318
+ onChange: () => { void completeTask(task) },
319
+ title: t('complete'),
320
+ }),
321
+ h('span', { className: `tkt-title${task.done ? ' done' : ''}`, title: task.title }, task.title),
322
+ due.kind !== 'none' && h('span', { className: `tkt-chip ${chipClass}` }, due.text),
323
+ !singleList && h('span', { className: 'tkt-chip later' }, projectName(task.projectId)),
324
+ h(Fragment, { key: 'controls' },
325
+ editable && h('input', {
326
+ className: 'tkt-date',
327
+ type: 'date',
328
+ value: task.dueDate === null ? '' : task.dueDate.slice(0, 10),
329
+ disabled: busy,
330
+ onChange: (event: ChangeEvent<HTMLInputElement>) => { void applyDue(task, event.target.value) },
331
+ title: t('setDue'),
332
+ }),
333
+ editable && task.dueDate !== null && h('button', {
334
+ className: 'tkt-iconbtn',
335
+ type: 'button',
336
+ title: t('clearDue'),
337
+ disabled: busy,
338
+ onClick: () => { void clearDue(task) },
339
+ }, '✕'),
340
+ h('button', {
341
+ className: 'tkt-iconbtn',
342
+ type: 'button',
343
+ title: t('delete'),
344
+ disabled: busy,
345
+ onClick: () => { void removeTask(task) },
346
+ }, '🗑')),
347
+ )
348
+ })),
349
+ editable && singleList && tasks.length > 0 && h('div', {
350
+ className: 'tkt-row',
351
+ style: { minHeight: '14px', borderBottom: 'none' },
352
+ onDragOver: (event: DragEvent<HTMLDivElement>) => { event.preventDefault() },
353
+ onDrop: (event: DragEvent<HTMLDivElement>) => { event.preventDefault(); void dropAtEnd() },
354
+ })),
355
+ )
356
+ }
@@ -0,0 +1,182 @@
1
+ /**
2
+ * The TickTick plugin settings card (`settings.plugin.item`, key
3
+ * `ticktick`). The card owns its own staging form — the shipped card-form
4
+ * helper is in-repo and cross-plugin value imports are rejected by the
5
+ * bundle-purity gate, so this card renders its fields directly over the
6
+ * bound `SettingsScope` and writes each field through `scope.set`.
7
+ *
8
+ * @module dsh-ticktick/client/TicktickSettingsCard
9
+ */
10
+
11
+ import { createElement as h, useEffect, useState } from 'react'
12
+ import type { ChangeEvent } from 'react'
13
+ import type { SettingsScope } from '@deepseek-ai/dsh-client-ui-settings/client'
14
+ import { en, type TicktickLocaleKey } from './locales.ts'
15
+ import type { TicktickProbeResult, TicktickSettings } from '../wire.ts'
16
+
17
+ /** Translator face (bound to this plugin's locale namespace by the renderer). */
18
+ export type TicktickSettingsTranslator = (key: TicktickLocaleKey) => string
19
+
20
+ /** Props the settings card slot injects. */
21
+ export interface TicktickSettingsCardInjected {
22
+ scope: SettingsScope<TicktickSettings>
23
+ probe: () => Promise<TicktickProbeResult>
24
+ t?: TicktickSettingsTranslator
25
+ }
26
+
27
+ /** Staged form values (strings only; the scope writes the typed values). */
28
+ interface FormState {
29
+ token: string
30
+ tokenFile: string
31
+ mcpUrl: string
32
+ protectedTaskIds: string
33
+ }
34
+
35
+ /**
36
+ * The plugin configuration card: token (secret), token file, endpoint, and
37
+ * protected ids, staged locally and written field-by-field on Save.
38
+ * @param props - bound scope and optional translator.
39
+ */
40
+ export function TicktickSettingsCard(props: TicktickSettingsCardInjected): React.ReactElement {
41
+ const { scope, probe } = props
42
+ const t: TicktickSettingsTranslator = props.t ?? (key => en[key])
43
+ const [form, setForm] = useState<FormState>({ token: '', tokenFile: '', mcpUrl: '', protectedTaskIds: '' })
44
+ const [saved, setSaved] = useState(false)
45
+ const [cleared, setCleared] = useState(false)
46
+ const [error, setError] = useState<string | null>(null)
47
+ const [probeResult, setProbeResult] = useState<TicktickProbeResult | null>(null)
48
+ const [probing, setProbing] = useState(false)
49
+
50
+ useEffect(() => scope.subscribe(() => {
51
+ const value = scope.getSnapshot().value
52
+ setForm(current => ({
53
+ token: current.token,
54
+ tokenFile: value?.tokenFile ?? '',
55
+ mcpUrl: value?.mcpUrl ?? '',
56
+ protectedTaskIds: (value?.protectedTaskIds ?? []).join(', '),
57
+ }))
58
+ }), [scope])
59
+
60
+ useEffect(() => {
61
+ const value = scope.getSnapshot().value
62
+ if (value === undefined) return
63
+ setForm({
64
+ token: value.token ?? '',
65
+ tokenFile: value.tokenFile ?? '',
66
+ mcpUrl: value.mcpUrl ?? '',
67
+ protectedTaskIds: (value.protectedTaskIds ?? []).join(', '),
68
+ })
69
+ }, [scope])
70
+
71
+ const save = async (): Promise<void> => {
72
+ setError(null)
73
+ setSaved(false)
74
+ setCleared(false)
75
+ try {
76
+ await scope.set('token', form.token.trim())
77
+ await scope.set('tokenFile', form.tokenFile.trim())
78
+ await scope.set('mcpUrl', form.mcpUrl.trim())
79
+ await scope.set('protectedTaskIds', form.protectedTaskIds.split(',').map(id => id.trim()).filter(id => id !== ''))
80
+ setSaved(true)
81
+ } catch (cause) {
82
+ setError(cause instanceof Error ? cause.message : String(cause))
83
+ }
84
+ }
85
+
86
+ const test = async (): Promise<void> => {
87
+ setProbing(true)
88
+ setProbeResult(null)
89
+ setError(null)
90
+ try {
91
+ const result = await probe()
92
+ setProbeResult(result)
93
+ } catch (cause) {
94
+ setError(cause instanceof Error ? cause.message : String(cause))
95
+ } finally {
96
+ setProbing(false)
97
+ }
98
+ }
99
+
100
+ const clear = async (): Promise<void> => {
101
+ setError(null)
102
+ setSaved(false)
103
+ try {
104
+ await scope.set('token', '')
105
+ await scope.set('tokenFile', '')
106
+ setForm({ ...form, token: '', tokenFile: '' })
107
+ setCleared(true)
108
+ } catch (cause) {
109
+ setError(cause instanceof Error ? cause.message : String(cause))
110
+ }
111
+ }
112
+
113
+ const field = (key: keyof FormState, label: string, hint: string, type = 'text', placeholder = ''): React.ReactElement =>
114
+ h('div', { style: { marginBottom: '8px' } },
115
+ h('label', { style: { display: 'block', fontSize: '12px', marginBottom: '3px' } }, label),
116
+ h('input', {
117
+ type,
118
+ placeholder,
119
+ style: { width: '100%', boxSizing: 'border-box', padding: '5px 7px', borderRadius: '5px', border: '1px solid #ccc' },
120
+ value: form[key],
121
+ onChange: event => { setForm({ ...form, [key]: event.target.value }) },
122
+ }),
123
+ h('div', { style: { fontSize: '11px', color: '#888', marginTop: '2px' } }, hint))
124
+
125
+ const presetOf = (url: string): 'cn' | 'intl' | 'custom' => {
126
+ if (url === 'https://mcp.dida365.com') return 'cn'
127
+ if (url === 'https://mcp.ticktick.com') return 'intl'
128
+ return 'custom'
129
+ }
130
+
131
+ const endpointField = h('div', { style: { marginBottom: '8px' } },
132
+ h('label', { style: { display: 'block', fontSize: '12px', marginBottom: '3px' } }, t('endpointPreset')),
133
+ h('select', {
134
+ style: { width: '100%', boxSizing: 'border-box', padding: '5px 7px', borderRadius: '5px', border: '1px solid #ccc' },
135
+ value: presetOf(form.mcpUrl),
136
+ onChange: (event: ChangeEvent<HTMLSelectElement>) => {
137
+ if (event.target.value === 'cn') setForm({ ...form, mcpUrl: 'https://mcp.dida365.com' })
138
+ else if (event.target.value === 'intl') setForm({ ...form, mcpUrl: 'https://mcp.ticktick.com' })
139
+ },
140
+ },
141
+ h('option', { value: 'cn' }, t('endpointCn')),
142
+ h('option', { value: 'intl' }, t('endpointIntl')),
143
+ h('option', { value: 'custom' }, t('endpointCustom'))),
144
+ h('input', {
145
+ type: 'text',
146
+ style: { width: '100%', boxSizing: 'border-box', padding: '5px 7px', borderRadius: '5px', border: '1px solid #ccc', marginTop: '4px' },
147
+ value: form.mcpUrl,
148
+ onChange: (event: ChangeEvent<HTMLInputElement>) => { setForm({ ...form, mcpUrl: event.target.value }) },
149
+ }),
150
+ h('div', { style: { fontSize: '11px', color: '#888', marginTop: '2px' } }, t('settingsMcpUrlHint')))
151
+
152
+ return h('div', { style: { padding: '10px 0' } },
153
+ h('div', { style: { fontSize: '14px', fontWeight: 600, marginBottom: '8px' } }, t('settingsName')),
154
+ field('token', t('settingsToken'), t('settingsTokenHint'), 'password'),
155
+ field('tokenFile', t('settingsTokenFile'), t('settingsTokenFileHint')),
156
+ endpointField,
157
+ field('protectedTaskIds', t('settingsProtected'), t('settingsProtectedHint')),
158
+ error !== null && h('div', { style: { color: '#c62828', fontSize: '12px', margin: '6px 0' } }, error),
159
+ saved && h('div', { style: { color: '#2e7d32', fontSize: '12px', margin: '6px 0' } }, t('settingsSaved')),
160
+ cleared && h('div', { style: { color: '#2e7d32', fontSize: '12px', margin: '6px 0' } }, t('settingsCleared')),
161
+ probeResult !== null && h('div', {
162
+ style: { color: probeResult.ok ? '#2e7d32' : '#c62828', fontSize: '12px', margin: '6px 0' },
163
+ }, probeResult.ok ? t('settingsTestOk').replace('N', String(probeResult.toolCount)) : t('settingsTestFail') + (probeResult.error ?? '')),
164
+ h('div', { style: { display: 'flex', gap: '8px' } },
165
+ h('button', {
166
+ type: 'button',
167
+ style: { padding: '6px 14px', borderRadius: '6px', border: '1px solid #ccc', cursor: 'pointer', background: '#f5f5f5' },
168
+ onClick: () => { void save() },
169
+ }, t('settingsSave')),
170
+ h('button', {
171
+ type: 'button',
172
+ disabled: probing,
173
+ style: { padding: '6px 14px', borderRadius: '6px', border: '1px solid #ccc', cursor: 'pointer', background: '#f5f5f5' },
174
+ onClick: () => { void test() },
175
+ }, t('settingsTest')),
176
+ h('button', {
177
+ type: 'button',
178
+ style: { padding: '6px 14px', borderRadius: '6px', border: '1px solid #ccc', cursor: 'pointer', background: '#fff3f3', color: '#c62828' },
179
+ onClick: () => { void clear() },
180
+ }, t('settingsClear'))),
181
+ )
182
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * The TickTick panel API face the widget receives: typed wrappers over the
3
+ * `remote.ticktick` namespace that unwrap `RemoteResult` and throw the
4
+ * RemoteError for a failed invocation.
5
+ *
6
+ * @module dsh-ticktick/client/api
7
+ */
8
+
9
+ import type { Context } from '@deepseek-ai/cordis'
10
+ import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
11
+ import type { TicktickAddResult, TicktickProbeResult, TicktickStatus, TicktickTasksResult } from '../wire.ts'
12
+ import type { TicktickBatchAddRow, TicktickProjectsResult } from './remote.ts'
13
+
14
+ /** Widget-facing data contract (injected into the header action). */
15
+ export interface TicktickApi {
16
+ status(): Promise<TicktickStatus>
17
+ projects(): Promise<TicktickProjectsResult>
18
+ tasks(projectId?: string): Promise<TicktickTasksResult>
19
+ add(title: string, projectId?: string, dueDate?: string): Promise<TicktickAddResult>
20
+ complete(id: string, projectId: string): Promise<void>
21
+ remove(id: string, projectId: string): Promise<void>
22
+ setDue(id: string, projectId: string | undefined, dueDate?: string): Promise<void>
23
+ reorder(id: string, projectId: string | undefined, sortOrder: number): Promise<void>
24
+ completed(projectId?: string, days?: number): Promise<TicktickTasksResult>
25
+ search(query: string): Promise<TicktickTasksResult>
26
+ batchAdd(tasks: readonly TicktickBatchAddRow[]): Promise<{ created: number }>
27
+ probe(): Promise<TicktickProbeResult>
28
+ }
29
+
30
+ /**
31
+ * Build the widget API over a scope carrying `remote.ticktick`.
32
+ * @param scope - client scope with the mounted Remote namespace.
33
+ * @returns the typed API.
34
+ */
35
+ export function createTicktickApi(scope: Context): TicktickApi {
36
+ const unwrap = <T>(result: RemoteResult<T>, method: string): T => {
37
+ if (!result.ok) {
38
+ throw new Error(`ticktick.${method} failed: ${result.error.code}: ${result.error.message}`)
39
+ }
40
+ return result.value
41
+ }
42
+ return {
43
+ status: async () => unwrap(await scope.remote.ticktick.status(), 'status'),
44
+ projects: async () => unwrap(await scope.remote.ticktick.projects(), 'projects'),
45
+ tasks: async (projectId) => unwrap(await scope.remote.ticktick.tasks(projectId), 'tasks'),
46
+ add: async (title, projectId, dueDate) => unwrap(await scope.remote.ticktick.add(title, projectId, dueDate), 'add'),
47
+ complete: async (id, projectId) => { unwrap(await scope.remote.ticktick.complete(id, projectId), 'complete') },
48
+ remove: async (id, projectId) => { unwrap(await scope.remote.ticktick.remove(id, projectId), 'remove') },
49
+ setDue: async (id, projectId, dueDate) => { unwrap(await scope.remote.ticktick.setDue(id, projectId, dueDate), 'setDue') },
50
+ reorder: async (id, projectId, sortOrder) => { unwrap(await scope.remote.ticktick.reorder(id, projectId, sortOrder), 'reorder') },
51
+ completed: async (projectId, days) => unwrap(await scope.remote.ticktick.completed(projectId, days), 'completed'),
52
+ search: async (query) => unwrap(await scope.remote.ticktick.search(query), 'search'),
53
+ batchAdd: async (tasks) => unwrap(await scope.remote.ticktick.batchAdd([...tasks]), 'batchAdd'),
54
+ probe: async () => unwrap(await scope.remote.ticktick.probe(), 'probe'),
55
+ }
56
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Due-date presentation helpers: today / tomorrow / overdue / "M月D日"
3
+ * (locale-neutral date formatting) plus the ISO date-only string sent to
4
+ * the bridge for date-picker values.
5
+ *
6
+ * @module dsh-ticktick/client/dates
7
+ */
8
+
9
+ /** Presentation of one due date: the label plus its urgency kind. */
10
+ export interface DueLabel {
11
+ readonly text: string
12
+ readonly kind: 'overdue' | 'today' | 'tomorrow' | 'later' | 'none'
13
+ }
14
+
15
+ /** Zero-pad a number to two digits. */
16
+ function pad(value: number): string {
17
+ return String(value).padStart(2, '0')
18
+ }
19
+
20
+ /** Local date key (YYYY-MM-DD) for a Date. */
21
+ export function dateKey(date: Date): string {
22
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
23
+ }
24
+
25
+ /** Local date key for today. */
26
+ export function todayKey(): string {
27
+ return dateKey(new Date())
28
+ }
29
+
30
+ /** Local date key for tomorrow. */
31
+ export function tomorrowKey(): string {
32
+ const tomorrow = new Date()
33
+ tomorrow.setDate(tomorrow.getDate() + 1)
34
+ return dateKey(tomorrow)
35
+ }
36
+
37
+ /** Parse the date-only prefix of an ISO due date as a local date key. */
38
+ function keyOfIso(dueDate: string): string | null {
39
+ const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(dueDate)
40
+ if (match === null) return null
41
+ return `${match[1]}-${match[2]}-${match[3]}`
42
+ }
43
+
44
+ /**
45
+ * Format one ISO due date against today.
46
+ * @param dueDate - ISO date or date-time; `null`/empty = no due date.
47
+ * @param now - optional clock injection for tests (epoch ms).
48
+ * @returns the presentation.
49
+ */
50
+ export function formatDue(dueDate: string | null, now: number = Date.now()): DueLabel {
51
+ if (dueDate === null || dueDate === '') return { text: '', kind: 'none' }
52
+ const key = keyOfIso(dueDate)
53
+ if (key === null) return { text: dueDate.slice(0, 10), kind: 'later' }
54
+ const today = dateKey(new Date(now))
55
+ if (key === today) return { text: 'today', kind: 'today' }
56
+ const tomorrow = new Date(now)
57
+ tomorrow.setDate(tomorrow.getDate() + 1)
58
+ if (key === dateKey(tomorrow)) return { text: 'tomorrow', kind: 'tomorrow' }
59
+ if (key < today) return { text: key, kind: 'overdue' }
60
+ return { text: key, kind: 'later' }
61
+ }