dsh-tiddlywiki 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +10 -4
- package/cordis.patch.yml +13 -13
- package/lib/client.bundle.js +2173 -1671
- package/lib/client.js +2183 -1681
- package/lib/index.js +2424 -2225
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/client/editor-popup.ts +121 -121
- package/src/client/index.ts +2 -0
- package/src/client/markdown.ts +137 -0
- package/src/client/note-widget.ts +515 -373
- package/src/client/settings-page.ts +31 -1
- package/src/client/sidebar-entry.ts +148 -148
- package/src/client/state.ts +39 -39
- package/src/client/styles.ts +88 -5
- package/src/client/sync-button.ts +188 -0
- package/src/client/toast.ts +22 -22
- package/src/host/admin.ts +408 -408
- package/src/host/config.ts +2 -0
- package/src/host/git.ts +13 -3
- package/src/host/routes.ts +151 -1
- package/src/host/tools.ts +28 -1
- package/src/host/tw-api.ts +157 -157
- package/src/host/wiki.ts +287 -287
- package/src/index.ts +9 -5
- package/src/sdk.ts +198 -198
package/src/host/config.ts
CHANGED
package/src/host/git.ts
CHANGED
|
@@ -118,10 +118,20 @@ export class GitFace {
|
|
|
118
118
|
return { exists: true, branch, dirty, dirtyFiles, remote, ...(lastCommit !== undefined ? { lastCommit } : {}), ...(ahead !== undefined ? { ahead } : {}), ...(behind !== undefined ? { behind } : {}) }
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
-
/** `git pull --rebase --autostash`; on conflict: abort + report files.
|
|
122
|
-
|
|
121
|
+
/** `git pull --rebase --autostash`; on conflict: abort + report files.
|
|
122
|
+
* On success, `changed: true` means HEAD actually moved (files came in /
|
|
123
|
+
* commits were replayed) — callers use it to decide whether a running TW
|
|
124
|
+
* child needs a restart to drop its stale in-memory snapshot. */
|
|
125
|
+
async pull(dir: string): Promise<GitActionResult & { changed?: boolean }> {
|
|
126
|
+
const before = await this.exec(['rev-parse', 'HEAD'], { cwd: dir, timeout: QUICK_TIMEOUT_MS })
|
|
127
|
+
const beforeHead = before.ok ? before.stdout.trim() : ''
|
|
123
128
|
const r = await this.exec(['pull', '--rebase', '--autostash'], { cwd: dir, timeout: HEAVY_TIMEOUT_MS })
|
|
124
|
-
if (r.ok)
|
|
129
|
+
if (r.ok) {
|
|
130
|
+
const after = await this.exec(['rev-parse', 'HEAD'], { cwd: dir, timeout: QUICK_TIMEOUT_MS })
|
|
131
|
+
const afterHead = after.ok ? after.stdout.trim() : ''
|
|
132
|
+
const changed = beforeHead.length > 0 && beforeHead !== afterHead
|
|
133
|
+
return { ok: true, message: r.stdout.trim() || 'pull ok', ...(changed ? { changed: true } : {}) }
|
|
134
|
+
}
|
|
125
135
|
const conflictFiles = await this.unmergedFiles(dir)
|
|
126
136
|
await this.exec(['rebase', '--abort'], { cwd: dir, timeout: HEAVY_TIMEOUT_MS })
|
|
127
137
|
const reason = (r.stderr.trim() || r.stdout.trim()).slice(0, 500)
|
package/src/host/routes.ts
CHANGED
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
* @module dsh-tiddlywiki/host/routes
|
|
16
16
|
*/
|
|
17
17
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
18
|
+
import { mkdir, writeFile, access } from 'node:fs/promises'
|
|
19
|
+
import { basename, extname, join } from 'node:path'
|
|
18
20
|
import type { TiddlyWebClient } from './tw-api.ts'
|
|
19
21
|
import type { WikiServer } from './wiki.ts'
|
|
20
22
|
import type { GitFace } from './git.ts'
|
|
@@ -28,18 +30,28 @@ const MAX_BODY_BYTES = 2 * 1024 * 1024
|
|
|
28
30
|
/** Max passthrough body (tiddler content can be large). */
|
|
29
31
|
const MAX_PROXY_BODY_BYTES = 16 * 1024 * 1024
|
|
30
32
|
|
|
33
|
+
/** Max uploaded file body. */
|
|
34
|
+
const MAX_UPLOAD_BYTES = 64 * 1024 * 1024
|
|
35
|
+
|
|
31
36
|
/** Structural webserver face (a subset of dsh-host-webserver). */
|
|
32
37
|
export interface WebServerFace {
|
|
33
38
|
register(route: { kind: 'exact' | 'prefix'; path: string; handler: (req: IncomingMessage, res: ServerResponse) => void }): () => void
|
|
34
39
|
}
|
|
35
40
|
|
|
41
|
+
/** Effective UI visibility flags returned by /status (mirror index.ts). */
|
|
42
|
+
export interface UiDefaultsPublic {
|
|
43
|
+
showQuickNote: boolean
|
|
44
|
+
showPanelStatus: boolean
|
|
45
|
+
showSyncButton: boolean
|
|
46
|
+
}
|
|
47
|
+
|
|
36
48
|
export interface RouteDeps {
|
|
37
49
|
server: WikiServer
|
|
38
50
|
getClient: () => TiddlyWebClient | undefined
|
|
39
51
|
git: GitFace
|
|
40
52
|
autoCommit: () => void
|
|
41
53
|
noteDefaults: () => { tag: string }
|
|
42
|
-
uiDefaults: () =>
|
|
54
|
+
uiDefaults: () => UiDefaultsPublic
|
|
43
55
|
getWikiPath: () => string
|
|
44
56
|
}
|
|
45
57
|
|
|
@@ -61,6 +73,41 @@ function readBody(req: IncomingMessage, limit = MAX_BODY_BYTES): Promise<string>
|
|
|
61
73
|
})
|
|
62
74
|
}
|
|
63
75
|
|
|
76
|
+
/** Read a raw (binary-safe) request body up to `limit` bytes. */
|
|
77
|
+
function readBodyBuffer(req: IncomingMessage, limit = MAX_UPLOAD_BYTES): Promise<Buffer> {
|
|
78
|
+
return new Promise((resolveP, rejectP) => {
|
|
79
|
+
let size = 0
|
|
80
|
+
const chunks: Buffer[] = []
|
|
81
|
+
req.on('data', (chunk: Buffer) => {
|
|
82
|
+
size += chunk.length
|
|
83
|
+
if (size > limit) {
|
|
84
|
+
rejectP(new Error('file too large'))
|
|
85
|
+
req.destroy()
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
chunks.push(chunk)
|
|
89
|
+
})
|
|
90
|
+
req.on('end', () => resolveP(Buffer.concat(chunks)))
|
|
91
|
+
req.on('error', rejectP)
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Sanitize an uploaded filename into a safe bare name (no path separators,
|
|
97
|
+
* no `..`, no control characters). Returns '' when nothing usable remains.
|
|
98
|
+
*/
|
|
99
|
+
function sanitizeUploadName(input: unknown): string {
|
|
100
|
+
if (typeof input !== 'string') return ''
|
|
101
|
+
const name = basename(input.trim().replace(/[\\/]+/g, '/'))
|
|
102
|
+
.replace(/[\u0000-\u001f\u007f]/g, '')
|
|
103
|
+
.replace(/[<>:"|?*]/g, '_')
|
|
104
|
+
.replace(/^\.+/, '')
|
|
105
|
+
.trim()
|
|
106
|
+
if (name.length === 0 || name === '.' || name === '..') return ''
|
|
107
|
+
if (name.length > 160) return name.slice(0, 160)
|
|
108
|
+
return name
|
|
109
|
+
}
|
|
110
|
+
|
|
64
111
|
function json(res: ServerResponse, payload: unknown, status = 200): void {
|
|
65
112
|
const body = JSON.stringify(payload)
|
|
66
113
|
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
@@ -216,6 +263,107 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
216
263
|
}
|
|
217
264
|
}
|
|
218
265
|
|
|
266
|
+
/** One-click git sync for the floating button / settings page: pull →
|
|
267
|
+
* commit → push, then return the fresh status. Mirrors the agent tool's
|
|
268
|
+
* `action=sync` (design doc §7 conflict policy — rebase conflict aborts).
|
|
269
|
+
* When the pull actually changed the working tree, the running TW child
|
|
270
|
+
* still holds the old in-memory snapshot — restart it (same port) so the
|
|
271
|
+
* UI reflects the pulled files instead of looking stale. */
|
|
272
|
+
const handleSync = async (_req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
273
|
+
const dir = deps.getWikiPath()
|
|
274
|
+
const status = async (): Promise<GitStatusViewPublic | null> => {
|
|
275
|
+
try { return await deps.git.status(dir) } catch { return null }
|
|
276
|
+
}
|
|
277
|
+
try {
|
|
278
|
+
const pulled = await deps.git.pull(dir)
|
|
279
|
+
if (!pulled.ok) {
|
|
280
|
+
json(res, {
|
|
281
|
+
ok: false,
|
|
282
|
+
action: 'sync',
|
|
283
|
+
message: pulled.message,
|
|
284
|
+
...(pulled.conflictFiles !== undefined ? { conflictFiles: pulled.conflictFiles } : {}),
|
|
285
|
+
status: await status(),
|
|
286
|
+
}, 409)
|
|
287
|
+
return
|
|
288
|
+
}
|
|
289
|
+
let restarted = false
|
|
290
|
+
let restartError: string | undefined
|
|
291
|
+
if (pulled.changed === true) {
|
|
292
|
+
try {
|
|
293
|
+
await deps.server.restart()
|
|
294
|
+
restarted = true
|
|
295
|
+
} catch (err) {
|
|
296
|
+
restartError = err instanceof Error ? err.message : String(err)
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
const committed = await deps.git.commit(dir, `sync ${new Date().toISOString()}`)
|
|
300
|
+
const pushed = await deps.git.push(dir)
|
|
301
|
+
const fresh = await status()
|
|
302
|
+
json(res, {
|
|
303
|
+
ok: pushed.ok,
|
|
304
|
+
action: 'sync',
|
|
305
|
+
message: pushed.ok ? '同步完成' : pushed.message,
|
|
306
|
+
pull: 'ok',
|
|
307
|
+
...(pulled.changed === true ? { changed: true } : {}),
|
|
308
|
+
restarted,
|
|
309
|
+
...(restartError !== undefined ? { restartError } : {}),
|
|
310
|
+
commit: committed.message,
|
|
311
|
+
push: pushed.message,
|
|
312
|
+
status: fresh,
|
|
313
|
+
lastSync: new Date().toISOString(),
|
|
314
|
+
}, pushed.ok ? 200 : 502)
|
|
315
|
+
} catch (err) {
|
|
316
|
+
json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, 500)
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Save an uploaded file into the wiki's `files/` folder (git-tracked; TW's
|
|
322
|
+
* core server serves it at `/files/<name>`, get-file.js — no restart
|
|
323
|
+
* needed). Body is the raw file; the name arrives in `X-Filename`. A
|
|
324
|
+
* collision appends `-1`, `-2`, … so nothing is ever overwritten.
|
|
325
|
+
*/
|
|
326
|
+
const handleUpload = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
327
|
+
try {
|
|
328
|
+
const buf = await readBodyBuffer(req)
|
|
329
|
+
// Name comes from ?name= (URL-encoded) or the X-Filename header.
|
|
330
|
+
const url = new URL(req.url ?? '/', 'http://127.0.0.1')
|
|
331
|
+
const nameParam = url.searchParams.get('name')
|
|
332
|
+
let name = sanitizeUploadName(nameParam ?? '')
|
|
333
|
+
if (name.length === 0 && typeof req.headers['x-filename'] === 'string') {
|
|
334
|
+
let decoded = ''
|
|
335
|
+
try { decoded = decodeURIComponent(req.headers['x-filename'] as string) } catch { decoded = req.headers['x-filename'] as string }
|
|
336
|
+
name = sanitizeUploadName(decoded)
|
|
337
|
+
}
|
|
338
|
+
if (name.length === 0) {
|
|
339
|
+
json(res, { ok: false, error: 'missing or invalid filename' }, 400)
|
|
340
|
+
return
|
|
341
|
+
}
|
|
342
|
+
const filesDir = join(deps.getWikiPath(), 'files')
|
|
343
|
+
await mkdir(filesDir, { recursive: true })
|
|
344
|
+
const ext = extname(name)
|
|
345
|
+
const stem = ext.length > 0 ? name.slice(0, -ext.length) : name
|
|
346
|
+
// Collision avoidance: files/some.txt, files/some-1.txt, …
|
|
347
|
+
let candidate = name
|
|
348
|
+
for (let i = 1; ; i++) {
|
|
349
|
+
try { await access(join(filesDir, candidate)) } catch { break }
|
|
350
|
+
candidate = `${stem}-${i}${ext}`
|
|
351
|
+
}
|
|
352
|
+
await writeFile(join(filesDir, candidate), buf)
|
|
353
|
+
deps.autoCommit()
|
|
354
|
+
json(res, {
|
|
355
|
+
ok: true,
|
|
356
|
+
name: candidate,
|
|
357
|
+
path: `files/${candidate}`,
|
|
358
|
+
url: `/files/${encodeURIComponent(candidate)}`,
|
|
359
|
+
size: buf.length,
|
|
360
|
+
type: req.headers['content-type'] ?? 'application/octet-stream',
|
|
361
|
+
})
|
|
362
|
+
} catch (err) {
|
|
363
|
+
json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, err instanceof Error && /too large/.test(err.message) ? 413 : 500)
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
219
367
|
/** Passthrough /dsh-tiddlywiki/api/<rest> → TW root /<rest>. */
|
|
220
368
|
const handleApiProxy = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
221
369
|
const client = deps.getClient()
|
|
@@ -251,6 +399,8 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
251
399
|
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/note`, handler: (req, res) => { void handleNote(req, res) } }),
|
|
252
400
|
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/edit`, handler: (req, res) => { void handleEdit(req, res) } }),
|
|
253
401
|
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/tags`, handler: (req, res) => { void handleTags(req, res) } }),
|
|
402
|
+
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/sync`, handler: (req, res) => { void handleSync(req, res) } }),
|
|
403
|
+
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/upload`, handler: (req, res) => { void handleUpload(req, res) } }),
|
|
254
404
|
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/restart`, handler: (req, res) => { void handleRestart(req, res) } }),
|
|
255
405
|
ctx.webServer.register({ kind: 'prefix', path: `${ROUTE_PREFIX}/api`, handler: (req, res) => { void handleApiProxy(req, res) } }),
|
|
256
406
|
]
|
package/src/host/tools.ts
CHANGED
|
@@ -27,6 +27,10 @@ export interface ToolsDeps {
|
|
|
27
27
|
noteTag: () => string
|
|
28
28
|
/** Debounced auto-commit touch (fires after our writes). */
|
|
29
29
|
autoCommit: () => void
|
|
30
|
+
/** Restart the TW child (same port). Called after a pull that changed the
|
|
31
|
+
* working tree, so the server drops its stale in-memory snapshot and the
|
|
32
|
+
* agent sees the pulled content. Optional — absent in headless contexts. */
|
|
33
|
+
restartWiki?: () => Promise<void>
|
|
30
34
|
}
|
|
31
35
|
|
|
32
36
|
function snippetOf(text: string, max = 160): string {
|
|
@@ -180,10 +184,21 @@ export function registerTiddlywikiTools(ctx: ToolsCtx, deps: ToolsDeps): Array<(
|
|
|
180
184
|
},
|
|
181
185
|
execute: async (args: { action: 'pull' | 'push' | 'sync'; message?: string }): Promise<SyncResult> => {
|
|
182
186
|
const dir = deps.wikiPath()
|
|
187
|
+
/** Restart TW after a pull that changed the tree (stale snapshot drop). */
|
|
188
|
+
const restartIfChanged = async (pulled: { changed?: boolean }): Promise<{ restarted?: boolean; restartError?: string }> => {
|
|
189
|
+
if (pulled.changed !== true || deps.restartWiki === undefined) return {}
|
|
190
|
+
try {
|
|
191
|
+
await deps.restartWiki()
|
|
192
|
+
return { restarted: true }
|
|
193
|
+
} catch (err) {
|
|
194
|
+
return { restartError: err instanceof Error ? err.message : String(err) }
|
|
195
|
+
}
|
|
196
|
+
}
|
|
183
197
|
switch (args.action) {
|
|
184
198
|
case 'pull': {
|
|
185
199
|
const r = await deps.git.pull(dir)
|
|
186
|
-
|
|
200
|
+
const restart = await restartIfChanged(r)
|
|
201
|
+
return { action: args.action, ok: r.ok, message: r.message, ...(r.conflictFiles !== undefined ? { conflictFiles: r.conflictFiles } : {}), ...(r.changed === true ? { changed: true } : {}), ...restart }
|
|
187
202
|
}
|
|
188
203
|
case 'push': {
|
|
189
204
|
const r = await deps.git.push(dir)
|
|
@@ -192,6 +207,7 @@ export function registerTiddlywikiTools(ctx: ToolsCtx, deps: ToolsDeps): Array<(
|
|
|
192
207
|
case 'sync': {
|
|
193
208
|
const pulled = await deps.git.pull(dir)
|
|
194
209
|
if (!pulled.ok) return { action: args.action, ok: false, message: pulled.message, ...(pulled.conflictFiles !== undefined ? { conflictFiles: pulled.conflictFiles } : {}) }
|
|
210
|
+
const restart = await restartIfChanged(pulled)
|
|
195
211
|
const committed = await deps.git.commit(dir, args.message ?? `sync ${new Date().toISOString()}`)
|
|
196
212
|
const pushed = await deps.git.push(dir)
|
|
197
213
|
const status = await deps.git.status(dir)
|
|
@@ -200,6 +216,8 @@ export function registerTiddlywikiTools(ctx: ToolsCtx, deps: ToolsDeps): Array<(
|
|
|
200
216
|
ok: pushed.ok,
|
|
201
217
|
message: pushed.ok ? '同步完成' : pushed.message,
|
|
202
218
|
pull: 'ok',
|
|
219
|
+
...(pulled.changed === true ? { changed: true } : {}),
|
|
220
|
+
...restart,
|
|
203
221
|
commit: committed.message,
|
|
204
222
|
push: pushed.message,
|
|
205
223
|
status,
|
|
@@ -227,6 +245,9 @@ interface SyncResult {
|
|
|
227
245
|
pull?: string
|
|
228
246
|
commit?: string
|
|
229
247
|
push?: string
|
|
248
|
+
changed?: boolean
|
|
249
|
+
restarted?: boolean
|
|
250
|
+
restartError?: string
|
|
230
251
|
status?: { branch: string; dirty: boolean; dirtyFiles: string[]; remote: string; lastCommit?: string; ahead?: number; behind?: number }
|
|
231
252
|
}
|
|
232
253
|
|
|
@@ -240,6 +261,12 @@ function renderSync(value: SyncResult): Array<{ type: 'text'; text: string }> {
|
|
|
240
261
|
}
|
|
241
262
|
if (value.commit !== undefined) lines.push(`本地 commit: ${value.commit}`)
|
|
242
263
|
if (value.push !== undefined) lines.push(`远端 push: ${value.push}`)
|
|
264
|
+
if (value.changed === true) {
|
|
265
|
+
lines.push(value.restarted === true
|
|
266
|
+
? '本次 pull 拉取了新内容,TW 服务已自动重启(同端口),读取/搜索均为最新快照。'
|
|
267
|
+
: '本次 pull 拉取了新内容,但 TW 服务未能自动重启(如需最新快照,请手动重启 TW)。')
|
|
268
|
+
}
|
|
269
|
+
if (value.restartError !== undefined) lines.push(`TW 重启失败: ${value.restartError}`)
|
|
243
270
|
if (value.status !== undefined) {
|
|
244
271
|
const s = value.status
|
|
245
272
|
const bits = [`分支 ${s.branch}`]
|
package/src/host/tw-api.ts
CHANGED
|
@@ -1,157 +1,157 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* TiddlyWeb REST client (design doc §5) — the ONLY way every writer reaches
|
|
3
|
-
* the wiki (quick notes, agent tools, editor saves all go through the TW
|
|
4
|
-
* service, D1), so there is never a second write path.
|
|
5
|
-
*
|
|
6
|
-
* ROUTES ARE EMPIRICALLY VERIFIED against tiddlywiki 5.4.1's core-server
|
|
7
|
-
* (`core-server/server/routes/`):
|
|
8
|
-
* GET /recipes/default/tiddlers.json[?exclude=...] list (skinny)
|
|
9
|
-
* GET /recipes/default/tiddlers/<title> read one (404 absent)
|
|
10
|
-
* PUT /recipes/default/tiddlers/<title> write one (204)
|
|
11
|
-
* DELETE /bags/default/tiddlers/<title> delete one (204)
|
|
12
|
-
* Writes require the `X-Requested-With: TiddlyWiki` header (TW CSRF), which
|
|
13
|
-
* this client always sends. Tags arrive as a whitespace-joined STRING and are
|
|
14
|
-
* normalized to arrays here.
|
|
15
|
-
*
|
|
16
|
-
* SEARCH (R2): the server blocks arbitrary `filter=` queries with 403 unless
|
|
17
|
-
* the exact filter is whitelisted in $:/config/Server/ExternalFilters. So
|
|
18
|
-
* `search()` fetches the default listing WITH text (`?exclude=` a sentinel)
|
|
19
|
-
* and matches locally — one request, no 403, no per-tiddler round-trips.
|
|
20
|
-
*
|
|
21
|
-
* @module dsh-tiddlywiki/host/tw-api
|
|
22
|
-
*/
|
|
23
|
-
|
|
24
|
-
/** A tiddler's readable fields (loose on purpose). */
|
|
25
|
-
export interface Tiddler {
|
|
26
|
-
title: string
|
|
27
|
-
text?: string
|
|
28
|
-
tags?: string[]
|
|
29
|
-
type?: string
|
|
30
|
-
created?: string
|
|
31
|
-
modified?: string
|
|
32
|
-
/** Extra custom fields returned by the server are folded under `fields`. */
|
|
33
|
-
fields?: Record<string, unknown>
|
|
34
|
-
[key: string]: unknown
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
const REQUEST_TIMEOUT_MS = 10_000
|
|
38
|
-
|
|
39
|
-
/** TW's CSRF gate: writes must carry this header (TW's own UI always does). */
|
|
40
|
-
const CSRF_HEADER = { 'x-requested-with': 'TiddlyWiki' }
|
|
41
|
-
|
|
42
|
-
/** Sentinel `exclude` value: excludes nothing, so `text` stays in the list. */
|
|
43
|
-
const LIST_WITH_TEXT_EXCLUDE = '__dsh_tw_none__'
|
|
44
|
-
|
|
45
|
-
/** Split TW's whitespace-joined tags string into an array. */
|
|
46
|
-
function normalizeTags(tags: unknown): string[] | undefined {
|
|
47
|
-
if (tags === undefined) return undefined
|
|
48
|
-
if (Array.isArray(tags)) return tags.map(String)
|
|
49
|
-
if (typeof tags === 'string') {
|
|
50
|
-
const parts = tags.trim().split(/\s+/).filter(Boolean)
|
|
51
|
-
return parts.length > 0 ? parts : []
|
|
52
|
-
}
|
|
53
|
-
return []
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/** Normalize a raw server tiddler (tags string → array, unknown fields nested). */
|
|
57
|
-
function normalizeTiddler(raw: Record<string, unknown>): Tiddler {
|
|
58
|
-
const out = { ...raw } as Tiddler
|
|
59
|
-
const tags = normalizeTags(raw.tags)
|
|
60
|
-
if (tags !== undefined) out.tags = tags
|
|
61
|
-
return out
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
export class TiddlyWebClient {
|
|
65
|
-
constructor(private readonly baseUrl: string) {}
|
|
66
|
-
|
|
67
|
-
private async request(path: string, init?: RequestInit): Promise<Response> {
|
|
68
|
-
return fetch(`${this.baseUrl}${path}`, {
|
|
69
|
-
...init,
|
|
70
|
-
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
71
|
-
})
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/** GET /status → { username, anonymous, space, tiddlywiki_version, ... }. */
|
|
75
|
-
async status(): Promise<Record<string, unknown>> {
|
|
76
|
-
const res = await this.request('/status')
|
|
77
|
-
if (!res.ok) throw new Error(`TiddlyWeb /status HTTP ${res.status}`)
|
|
78
|
-
return res.json() as Promise<Record<string, unknown>>
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/** Read one tiddler; undefined when it does not exist (404). */
|
|
82
|
-
async get(title: string): Promise<Tiddler | undefined> {
|
|
83
|
-
const res = await this.request(`/recipes/default/tiddlers/${encodeURIComponent(title)}`)
|
|
84
|
-
if (res.status === 404) return undefined
|
|
85
|
-
if (!res.ok) throw new Error(`TiddlyWeb GET /recipes/default/tiddlers/${title} HTTP ${res.status}`)
|
|
86
|
-
return normalizeTiddler((await res.json()) as Record<string, unknown>)
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/** Write (create or overwrite) one tiddler via PUT (204 on success). */
|
|
90
|
-
async put(tiddler: Tiddler): Promise<Tiddler> {
|
|
91
|
-
const title = tiddler.title
|
|
92
|
-
const res = await this.request(`/recipes/default/tiddlers/${encodeURIComponent(title)}`, {
|
|
93
|
-
method: 'PUT',
|
|
94
|
-
headers: { 'content-type': 'application/json', ...CSRF_HEADER },
|
|
95
|
-
body: JSON.stringify(tiddler),
|
|
96
|
-
})
|
|
97
|
-
if (!res.ok) {
|
|
98
|
-
const detail = await res.text().catch(() => '')
|
|
99
|
-
throw new Error(`TiddlyWeb PUT /recipes/default/tiddlers/${title} HTTP ${res.status}: ${detail.slice(0, 300)}`)
|
|
100
|
-
}
|
|
101
|
-
return tiddler
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/** Delete one tiddler via the bags route (204); a missing one is a no-op. */
|
|
105
|
-
async delete(title: string): Promise<void> {
|
|
106
|
-
const res = await this.request(`/bags/default/tiddlers/${encodeURIComponent(title)}`, {
|
|
107
|
-
method: 'DELETE',
|
|
108
|
-
headers: CSRF_HEADER,
|
|
109
|
-
})
|
|
110
|
-
if (res.status === 404) return
|
|
111
|
-
if (!res.ok) throw new Error(`TiddlyWeb DELETE /bags/default/tiddlers/${title} HTTP ${res.status}`)
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/**
|
|
115
|
-
* List tiddlers via the default server filter. Arbitrary `filter=` queries
|
|
116
|
-
* are blocked by the server (403) unless whitelisted, so callers needing a
|
|
117
|
-
* subset should use search(); a supplied filter that is 403-blocked falls
|
|
118
|
-
* back to the default listing.
|
|
119
|
-
*/
|
|
120
|
-
async list(filter?: string, includeText = false): Promise<Tiddler[]> {
|
|
121
|
-
const params = new URLSearchParams()
|
|
122
|
-
if (includeText) params.set('exclude', LIST_WITH_TEXT_EXCLUDE)
|
|
123
|
-
if (filter !== undefined && filter.length > 0) params.set('filter', filter)
|
|
124
|
-
const query = params.toString()
|
|
125
|
-
let res = await this.request(`/recipes/default/tiddlers.json${query.length > 0 ? `?${query}` : ''}`)
|
|
126
|
-
if (!res.ok && res.status === 403 && filter !== undefined && filter.length > 0) {
|
|
127
|
-
// Filter not whitelisted → refetch with the default filter.
|
|
128
|
-
const retry = new URLSearchParams()
|
|
129
|
-
if (includeText) retry.set('exclude', LIST_WITH_TEXT_EXCLUDE)
|
|
130
|
-
const retryQuery = retry.toString()
|
|
131
|
-
res = await this.request(`/recipes/default/tiddlers.json${retryQuery.length > 0 ? `?${retryQuery}` : ''}`)
|
|
132
|
-
}
|
|
133
|
-
if (!res.ok) throw new Error(`TiddlyWeb recipe list HTTP ${res.status}`)
|
|
134
|
-
const data = (await res.json()) as Array<Record<string, unknown>> | { tiddlers?: Array<Record<string, unknown>> }
|
|
135
|
-
const items = Array.isArray(data) ? data : (data.tiddlers ?? [])
|
|
136
|
-
return items.map(normalizeTiddler)
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
/**
|
|
140
|
-
* Search non-system tiddlers: one request (default listing with text) plus
|
|
141
|
-
* local case-insensitive substring matching on title + text, optional exact
|
|
142
|
-
* tag, capped at `limit`. Robust against the server's external-filter 403.
|
|
143
|
-
*/
|
|
144
|
-
async search(query: string, tag?: string, limit = 30): Promise<Tiddler[]> {
|
|
145
|
-
const items = await this.list(undefined, true)
|
|
146
|
-
const needle = query.toLowerCase()
|
|
147
|
-
const matched = items.filter((t) => {
|
|
148
|
-
if (!t.title.toLowerCase().includes(needle) && !(t.text ?? '').toLowerCase().includes(needle)) return false
|
|
149
|
-
if (tag !== undefined && tag.length > 0) {
|
|
150
|
-
const tags = t.tags ?? []
|
|
151
|
-
if (!tags.some((t2) => t2.toLowerCase() === tag.toLowerCase())) return false
|
|
152
|
-
}
|
|
153
|
-
return true
|
|
154
|
-
})
|
|
155
|
-
return matched.slice(0, limit)
|
|
156
|
-
}
|
|
157
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* TiddlyWeb REST client (design doc §5) — the ONLY way every writer reaches
|
|
3
|
+
* the wiki (quick notes, agent tools, editor saves all go through the TW
|
|
4
|
+
* service, D1), so there is never a second write path.
|
|
5
|
+
*
|
|
6
|
+
* ROUTES ARE EMPIRICALLY VERIFIED against tiddlywiki 5.4.1's core-server
|
|
7
|
+
* (`core-server/server/routes/`):
|
|
8
|
+
* GET /recipes/default/tiddlers.json[?exclude=...] list (skinny)
|
|
9
|
+
* GET /recipes/default/tiddlers/<title> read one (404 absent)
|
|
10
|
+
* PUT /recipes/default/tiddlers/<title> write one (204)
|
|
11
|
+
* DELETE /bags/default/tiddlers/<title> delete one (204)
|
|
12
|
+
* Writes require the `X-Requested-With: TiddlyWiki` header (TW CSRF), which
|
|
13
|
+
* this client always sends. Tags arrive as a whitespace-joined STRING and are
|
|
14
|
+
* normalized to arrays here.
|
|
15
|
+
*
|
|
16
|
+
* SEARCH (R2): the server blocks arbitrary `filter=` queries with 403 unless
|
|
17
|
+
* the exact filter is whitelisted in $:/config/Server/ExternalFilters. So
|
|
18
|
+
* `search()` fetches the default listing WITH text (`?exclude=` a sentinel)
|
|
19
|
+
* and matches locally — one request, no 403, no per-tiddler round-trips.
|
|
20
|
+
*
|
|
21
|
+
* @module dsh-tiddlywiki/host/tw-api
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** A tiddler's readable fields (loose on purpose). */
|
|
25
|
+
export interface Tiddler {
|
|
26
|
+
title: string
|
|
27
|
+
text?: string
|
|
28
|
+
tags?: string[]
|
|
29
|
+
type?: string
|
|
30
|
+
created?: string
|
|
31
|
+
modified?: string
|
|
32
|
+
/** Extra custom fields returned by the server are folded under `fields`. */
|
|
33
|
+
fields?: Record<string, unknown>
|
|
34
|
+
[key: string]: unknown
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const REQUEST_TIMEOUT_MS = 10_000
|
|
38
|
+
|
|
39
|
+
/** TW's CSRF gate: writes must carry this header (TW's own UI always does). */
|
|
40
|
+
const CSRF_HEADER = { 'x-requested-with': 'TiddlyWiki' }
|
|
41
|
+
|
|
42
|
+
/** Sentinel `exclude` value: excludes nothing, so `text` stays in the list. */
|
|
43
|
+
const LIST_WITH_TEXT_EXCLUDE = '__dsh_tw_none__'
|
|
44
|
+
|
|
45
|
+
/** Split TW's whitespace-joined tags string into an array. */
|
|
46
|
+
function normalizeTags(tags: unknown): string[] | undefined {
|
|
47
|
+
if (tags === undefined) return undefined
|
|
48
|
+
if (Array.isArray(tags)) return tags.map(String)
|
|
49
|
+
if (typeof tags === 'string') {
|
|
50
|
+
const parts = tags.trim().split(/\s+/).filter(Boolean)
|
|
51
|
+
return parts.length > 0 ? parts : []
|
|
52
|
+
}
|
|
53
|
+
return []
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Normalize a raw server tiddler (tags string → array, unknown fields nested). */
|
|
57
|
+
function normalizeTiddler(raw: Record<string, unknown>): Tiddler {
|
|
58
|
+
const out = { ...raw } as Tiddler
|
|
59
|
+
const tags = normalizeTags(raw.tags)
|
|
60
|
+
if (tags !== undefined) out.tags = tags
|
|
61
|
+
return out
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export class TiddlyWebClient {
|
|
65
|
+
constructor(private readonly baseUrl: string) {}
|
|
66
|
+
|
|
67
|
+
private async request(path: string, init?: RequestInit): Promise<Response> {
|
|
68
|
+
return fetch(`${this.baseUrl}${path}`, {
|
|
69
|
+
...init,
|
|
70
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** GET /status → { username, anonymous, space, tiddlywiki_version, ... }. */
|
|
75
|
+
async status(): Promise<Record<string, unknown>> {
|
|
76
|
+
const res = await this.request('/status')
|
|
77
|
+
if (!res.ok) throw new Error(`TiddlyWeb /status HTTP ${res.status}`)
|
|
78
|
+
return res.json() as Promise<Record<string, unknown>>
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Read one tiddler; undefined when it does not exist (404). */
|
|
82
|
+
async get(title: string): Promise<Tiddler | undefined> {
|
|
83
|
+
const res = await this.request(`/recipes/default/tiddlers/${encodeURIComponent(title)}`)
|
|
84
|
+
if (res.status === 404) return undefined
|
|
85
|
+
if (!res.ok) throw new Error(`TiddlyWeb GET /recipes/default/tiddlers/${title} HTTP ${res.status}`)
|
|
86
|
+
return normalizeTiddler((await res.json()) as Record<string, unknown>)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Write (create or overwrite) one tiddler via PUT (204 on success). */
|
|
90
|
+
async put(tiddler: Tiddler): Promise<Tiddler> {
|
|
91
|
+
const title = tiddler.title
|
|
92
|
+
const res = await this.request(`/recipes/default/tiddlers/${encodeURIComponent(title)}`, {
|
|
93
|
+
method: 'PUT',
|
|
94
|
+
headers: { 'content-type': 'application/json', ...CSRF_HEADER },
|
|
95
|
+
body: JSON.stringify(tiddler),
|
|
96
|
+
})
|
|
97
|
+
if (!res.ok) {
|
|
98
|
+
const detail = await res.text().catch(() => '')
|
|
99
|
+
throw new Error(`TiddlyWeb PUT /recipes/default/tiddlers/${title} HTTP ${res.status}: ${detail.slice(0, 300)}`)
|
|
100
|
+
}
|
|
101
|
+
return tiddler
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Delete one tiddler via the bags route (204); a missing one is a no-op. */
|
|
105
|
+
async delete(title: string): Promise<void> {
|
|
106
|
+
const res = await this.request(`/bags/default/tiddlers/${encodeURIComponent(title)}`, {
|
|
107
|
+
method: 'DELETE',
|
|
108
|
+
headers: CSRF_HEADER,
|
|
109
|
+
})
|
|
110
|
+
if (res.status === 404) return
|
|
111
|
+
if (!res.ok) throw new Error(`TiddlyWeb DELETE /bags/default/tiddlers/${title} HTTP ${res.status}`)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* List tiddlers via the default server filter. Arbitrary `filter=` queries
|
|
116
|
+
* are blocked by the server (403) unless whitelisted, so callers needing a
|
|
117
|
+
* subset should use search(); a supplied filter that is 403-blocked falls
|
|
118
|
+
* back to the default listing.
|
|
119
|
+
*/
|
|
120
|
+
async list(filter?: string, includeText = false): Promise<Tiddler[]> {
|
|
121
|
+
const params = new URLSearchParams()
|
|
122
|
+
if (includeText) params.set('exclude', LIST_WITH_TEXT_EXCLUDE)
|
|
123
|
+
if (filter !== undefined && filter.length > 0) params.set('filter', filter)
|
|
124
|
+
const query = params.toString()
|
|
125
|
+
let res = await this.request(`/recipes/default/tiddlers.json${query.length > 0 ? `?${query}` : ''}`)
|
|
126
|
+
if (!res.ok && res.status === 403 && filter !== undefined && filter.length > 0) {
|
|
127
|
+
// Filter not whitelisted → refetch with the default filter.
|
|
128
|
+
const retry = new URLSearchParams()
|
|
129
|
+
if (includeText) retry.set('exclude', LIST_WITH_TEXT_EXCLUDE)
|
|
130
|
+
const retryQuery = retry.toString()
|
|
131
|
+
res = await this.request(`/recipes/default/tiddlers.json${retryQuery.length > 0 ? `?${retryQuery}` : ''}`)
|
|
132
|
+
}
|
|
133
|
+
if (!res.ok) throw new Error(`TiddlyWeb recipe list HTTP ${res.status}`)
|
|
134
|
+
const data = (await res.json()) as Array<Record<string, unknown>> | { tiddlers?: Array<Record<string, unknown>> }
|
|
135
|
+
const items = Array.isArray(data) ? data : (data.tiddlers ?? [])
|
|
136
|
+
return items.map(normalizeTiddler)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Search non-system tiddlers: one request (default listing with text) plus
|
|
141
|
+
* local case-insensitive substring matching on title + text, optional exact
|
|
142
|
+
* tag, capped at `limit`. Robust against the server's external-filter 403.
|
|
143
|
+
*/
|
|
144
|
+
async search(query: string, tag?: string, limit = 30): Promise<Tiddler[]> {
|
|
145
|
+
const items = await this.list(undefined, true)
|
|
146
|
+
const needle = query.toLowerCase()
|
|
147
|
+
const matched = items.filter((t) => {
|
|
148
|
+
if (!t.title.toLowerCase().includes(needle) && !(t.text ?? '').toLowerCase().includes(needle)) return false
|
|
149
|
+
if (tag !== undefined && tag.length > 0) {
|
|
150
|
+
const tags = t.tags ?? []
|
|
151
|
+
if (!tags.some((t2) => t2.toLowerCase() === tag.toLowerCase())) return false
|
|
152
|
+
}
|
|
153
|
+
return true
|
|
154
|
+
})
|
|
155
|
+
return matched.slice(0, limit)
|
|
156
|
+
}
|
|
157
|
+
}
|