dsh-tiddlywiki 0.2.0 → 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 +12 -6
- package/cordis.patch.yml +13 -13
- package/lib/client.bundle.js +2173 -1652
- package/lib/client.js +2183 -1662
- package/lib/index.js +2424 -2263
- 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/panel.ts +51 -25
- package/src/client/settings-page.ts +32 -2
- package/src/client/sidebar-entry.ts +148 -148
- package/src/client/state.ts +39 -39
- package/src/client/styles.ts +111 -28
- 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 +4 -2
- package/src/host/git.ts +13 -3
- package/src/host/routes.ts +151 -1
- package/src/host/seed-notes.ts +79 -122
- 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 +13 -12
- package/src/sdk.ts +198 -198
package/src/host/config.ts
CHANGED
|
@@ -23,8 +23,10 @@ export interface PluginConfigShape {
|
|
|
23
23
|
ui?: {
|
|
24
24
|
/** 是否在界面右下角显示「快速笔记」悬浮按钮(默认 true)。 */
|
|
25
25
|
showQuickNote?: boolean
|
|
26
|
-
/**
|
|
27
|
-
|
|
26
|
+
/** 是否显示 TW 面板右下角的「状态/重载」悬浮按钮(默认 true)。 */
|
|
27
|
+
showPanelStatus?: boolean
|
|
28
|
+
/** 是否在界面右下角显示「同步」悬浮按钮(默认 true)。 */
|
|
29
|
+
showSyncButton?: boolean
|
|
28
30
|
}
|
|
29
31
|
uiLanguage?: string
|
|
30
32
|
[key: string]: unknown
|
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/seed-notes.ts
CHANGED
|
@@ -1,122 +1,79 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Built-in doc note
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
*
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
*
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
await client
|
|
81
|
-
.put({ title: SEED_MARKER_TITLE, text: 'seeded-once', type: 'text/plain', tags: [] })
|
|
82
|
-
.catch(() => undefined)
|
|
83
|
-
return wrote
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/** Sidebar-left CSS tiddler: moves TW's sidebar to the LEFT (mirror of the
|
|
87
|
-
* `centralised` default), so dsh-better-sidebar's top-right toggle cluster
|
|
88
|
-
* no longer sits over TW's own right sidebar. A normal tiddler (tag
|
|
89
|
-
* `$:/tags/Stylesheet`, type text/css) — same pattern as hand-made
|
|
90
|
-
* `编辑界面微调:小标题·大编辑器.css`, easy to find and delete. */
|
|
91
|
-
export const SIDEBAR_LEFT_CSS_TITLE = '侧边移到左侧.css'
|
|
92
|
-
|
|
93
|
-
/** The stylesheet body. Hardcoded 960px breakpoint (vanilla default
|
|
94
|
-
* sidebarbreakpoint) — plain text/css tiddlers don't process `{{...}}`. */
|
|
95
|
-
export const SIDEBAR_LEFT_CSS = `/* 由 dsh-tiddlywiki 插件默认安装:把 TW 右侧栏镜像到左侧(与 centralised 主题默认对称)。
|
|
96
|
-
删除后,只要设置页「侧边栏移左 CSS」补丁开关开着,插件会在下次启动时重建本 tiddler。 */
|
|
97
|
-
@media (min-width: 960px) {
|
|
98
|
-
html .tc-sidebar-scrollable {
|
|
99
|
-
left: 0 !important;
|
|
100
|
-
right: 50% !important;
|
|
101
|
-
margin-left: 0 !important;
|
|
102
|
-
margin-right: 343px !important;
|
|
103
|
-
}
|
|
104
|
-
}`
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* Patch-repair the sidebar-left CSS while the option is enabled: write it
|
|
108
|
-
* whenever it is missing (fresh installs and deleted-by-user both re-seed).
|
|
109
|
-
* Returns whether it was written. Never throws.
|
|
110
|
-
*/
|
|
111
|
-
export async function seedSidebarLeftCss(client: TiddlyWebClient, enabled: boolean): Promise<boolean> {
|
|
112
|
-
if (!enabled) return false
|
|
113
|
-
const existing = await client.get(SIDEBAR_LEFT_CSS_TITLE).catch(() => undefined)
|
|
114
|
-
if (existing !== undefined) return false
|
|
115
|
-
await client.put({
|
|
116
|
-
title: SIDEBAR_LEFT_CSS_TITLE,
|
|
117
|
-
text: SIDEBAR_LEFT_CSS,
|
|
118
|
-
type: 'text/css',
|
|
119
|
-
tags: ['$:/tags/Stylesheet'],
|
|
120
|
-
})
|
|
121
|
-
return true
|
|
122
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Built-in doc note for the plugin (design doc §14): a short user-facing
|
|
3
|
+
* "how to use dsh-tiddlywiki" note seeded into the wiki the first time the
|
|
4
|
+
* plugin runs on a wiki.
|
|
5
|
+
*
|
|
6
|
+
* ONE-SHOT seed: a marker tiddler (`seed-doc-note`) records that the note has
|
|
7
|
+
* been offered once; after that the note is the user's own content — deleting
|
|
8
|
+
* it and restarting dsh web does NOT recreate it, and edits are never
|
|
9
|
+
* overwritten.
|
|
10
|
+
*
|
|
11
|
+
* @module dsh-tiddlywiki/host/seed-notes
|
|
12
|
+
*/
|
|
13
|
+
import type { TiddlyWebClient } from './tw-api.ts'
|
|
14
|
+
|
|
15
|
+
/** Note tiddler title (a normal, searchable note — not a system tiddler). */
|
|
16
|
+
export const DOC_NOTE_TITLE = 'dsh-tiddlywiki 插件说明'
|
|
17
|
+
|
|
18
|
+
/** Tag that makes the note easy to find via `tiddlywiki_search tag=docs`. */
|
|
19
|
+
export const DOC_NOTE_TAG = 'docs'
|
|
20
|
+
|
|
21
|
+
/** One-time marker: its presence means "the note was offered once — hands off". */
|
|
22
|
+
export const SEED_MARKER_TITLE = '$:/plugins/dsh-tiddlywiki/seed-doc-note'
|
|
23
|
+
|
|
24
|
+
/** The note body, TiddlyWiki wiki-text. */
|
|
25
|
+
export const DOC_NOTE_TEXT = `! dsh-tiddlywiki 插件说明
|
|
26
|
+
|
|
27
|
+
本插件把 **TiddlyWiki 5** 作为 DSH 的持久知识库(wiki 文件夹本身就是一个 git 仓库,随内容自动提交/同步)。
|
|
28
|
+
|
|
29
|
+
!! 它能做什么
|
|
30
|
+
|
|
31
|
+
* **5 个 agent 工具**:\`tiddlywiki_search\`(检索)/ \`tiddlywiki_get\`(读)/ \`tiddlywiki_put\`(写)/ \`tiddlywiki_delete\`(删)/ \`tiddlywiki_git_sync\`(git 同步)。
|
|
32
|
+
* **TW 编辑器面板**:侧边栏「TiddlyWiki」按钮 → 在界面中央打开完整版 TW 编辑器。
|
|
33
|
+
* **快速笔记**:右下角悬浮「📝 快速笔记」写随手记(可多选/自动补全 tag),\`Ctrl+Enter\` 保存;点「✏️ 在 TW 中编辑」会弹出独立小窗用 TW 原生编辑器编辑。
|
|
34
|
+
* **git 同步**:写入自动防抖 commit(默认 60 秒);手动 \`tiddlywiki_git_sync action=sync\` 做 pull → commit → push。
|
|
35
|
+
* **设置页**:DSH 设置 → 「TiddlyWiki 知识库」管理插件/主题/语言与运行配置(含「快速笔记」显示开关、面板「状态/重载」悬浮按钮开关)。
|
|
36
|
+
|
|
37
|
+
!! 知识库纪律(三条)
|
|
38
|
+
|
|
39
|
+
1. 开工先 \`tiddlywiki_git_sync action=pull\`(rebase + autostash,真冲突会自动 abort 并报文件)。
|
|
40
|
+
2. 收工 \`tiddlywiki_git_sync action=sync\`。
|
|
41
|
+
3. 插件自动 commit 兜底,手动 sync 用于需要主动推送的场合。
|
|
42
|
+
|
|
43
|
+
!! 主题与语言
|
|
44
|
+
|
|
45
|
+
* **主题**分两层:每行一个「☑ 加载」(多选 = TW 里可用的主题,依赖链自动带上)和「◉ 活动」(单选 = 当前视觉主题)。应用后自动重启 TW。
|
|
46
|
+
* **语言**:设置页勾选 \`zh-Hans\`(简体)并应用,TW 界面即切换为中文。
|
|
47
|
+
|
|
48
|
+
!! 说明
|
|
49
|
+
|
|
50
|
+
* 本笔记由插件在**首次启动**时自动写入(一次性:只写一次)。删除后重启 dsh web **不会自动恢复**——它从此归你所有。
|
|
51
|
+
* 更多细节见插件仓库 README。`
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Seed the doc note exactly once per wiki. A marker tiddler records that the
|
|
55
|
+
* note has been offered; from then on the note is user-owned and is never
|
|
56
|
+
* re-created (deleting it survives restarts). Returns whether a note was
|
|
57
|
+
* written this call. Never throws.
|
|
58
|
+
*/
|
|
59
|
+
export async function seedDocNote(client: TiddlyWebClient): Promise<boolean> {
|
|
60
|
+
const marker = await client.get(SEED_MARKER_TITLE).catch(() => undefined)
|
|
61
|
+
if (marker !== undefined) return false
|
|
62
|
+
const existing = await client.get(DOC_NOTE_TITLE).catch(() => undefined)
|
|
63
|
+
let wrote = false
|
|
64
|
+
if (existing === undefined) {
|
|
65
|
+
await client.put({
|
|
66
|
+
title: DOC_NOTE_TITLE,
|
|
67
|
+
text: DOC_NOTE_TEXT,
|
|
68
|
+
type: 'text/vnd.tiddlywiki',
|
|
69
|
+
tags: [DOC_NOTE_TAG],
|
|
70
|
+
})
|
|
71
|
+
wrote = true
|
|
72
|
+
}
|
|
73
|
+
// Record the offer regardless, so an existing note (upgrade from an older
|
|
74
|
+
// create-if-missing version) also becomes user-owned from here on.
|
|
75
|
+
await client
|
|
76
|
+
.put({ title: SEED_MARKER_TITLE, text: 'seeded-once', type: 'text/plain', tags: [] })
|
|
77
|
+
.catch(() => undefined)
|
|
78
|
+
return wrote
|
|
79
|
+
}
|
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}`]
|