@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
|
@@ -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
|
+
}
|