dsh-tiddlywiki 0.1.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 -0
- package/README.md +172 -0
- package/cordis.patch.yml +13 -0
- package/lib/client.bundle.js +1417 -0
- package/lib/client.js +1427 -0
- package/lib/index.js +2153 -0
- package/lib/index.js.map +1 -0
- package/package.json +63 -0
- package/src/client/editor-popup.ts +121 -0
- package/src/client/index.ts +76 -0
- package/src/client/note-widget.ts +210 -0
- package/src/client/panel.ts +304 -0
- package/src/client/settings-page.ts +397 -0
- package/src/client/sidebar-entry.ts +148 -0
- package/src/client/state.ts +39 -0
- package/src/client/styles.ts +235 -0
- package/src/client/toast.ts +22 -0
- package/src/host/admin.ts +408 -0
- package/src/host/config.ts +86 -0
- package/src/host/git.ts +218 -0
- package/src/host/routes.ts +233 -0
- package/src/host/seed-notes.ts +62 -0
- package/src/host/tools.ts +254 -0
- package/src/host/tw-api.ts +157 -0
- package/src/host/wiki.ts +287 -0
- package/src/index.ts +331 -0
- package/src/sdk.ts +198 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extensible plugin config (design doc §13, config panel).
|
|
3
|
+
*
|
|
4
|
+
* Two layers:
|
|
5
|
+
* base — the cordis `config:` block (profile composition, defaults);
|
|
6
|
+
* overrides — a user-editable config tiddler ($:/plugins/dsh-tiddlywiki/config,
|
|
7
|
+
* a JSON string) written by the settings page.
|
|
8
|
+
* The tiddler overlays the base (tiddler wins), so future config fields just
|
|
9
|
+
* extend the shape — no schema, no @deepseek-ai dependency, and the config
|
|
10
|
+
* travels with the wiki's git history.
|
|
11
|
+
*
|
|
12
|
+
* @module dsh-tiddlywiki/host/config
|
|
13
|
+
*/
|
|
14
|
+
import type { TiddlyWebClient } from './tw-api.ts'
|
|
15
|
+
|
|
16
|
+
/** Config tiddler (JSON string) where the settings page stores overrides. */
|
|
17
|
+
export const CONFIG_TIDDLER = '$:/plugins/dsh-tiddlywiki/config'
|
|
18
|
+
|
|
19
|
+
/** Extensible, loose plugin config shape (future fields just appear here). */
|
|
20
|
+
export interface PluginConfigShape {
|
|
21
|
+
note?: { tag?: string }
|
|
22
|
+
git?: { autoCommit?: boolean; debounceMs?: number; remote?: string; branch?: string }
|
|
23
|
+
uiLanguage?: string
|
|
24
|
+
[key: string]: unknown
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
28
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Deep-merge: `over` wins; nested plain objects merge recursively. */
|
|
32
|
+
export function deepMerge(base: Record<string, unknown>, over: Record<string, unknown>): Record<string, unknown> {
|
|
33
|
+
const out: Record<string, unknown> = { ...base }
|
|
34
|
+
for (const [key, value] of Object.entries(over)) {
|
|
35
|
+
if (value === undefined) continue
|
|
36
|
+
if (isPlainObject(value) && isPlainObject(out[key])) {
|
|
37
|
+
out[key] = deepMerge(out[key] as Record<string, unknown>, value)
|
|
38
|
+
} else {
|
|
39
|
+
out[key] = value
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return out
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Runtime config store: caches the override tiddler and exposes the effective
|
|
47
|
+
* (merged) config. `load` runs at startup and after every write/restart.
|
|
48
|
+
*/
|
|
49
|
+
export class ConfigStore {
|
|
50
|
+
private overrides: PluginConfigShape = {}
|
|
51
|
+
|
|
52
|
+
constructor(private readonly base: PluginConfigShape) {}
|
|
53
|
+
|
|
54
|
+
/** Effective config = cordis base overlaid with the user override tiddler. */
|
|
55
|
+
get(): PluginConfigShape {
|
|
56
|
+
return deepMerge(this.base, this.overrides) as PluginConfigShape
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Reload the override tiddler (no-op when the wiki is unavailable). */
|
|
60
|
+
async load(client: TiddlyWebClient | undefined): Promise<void> {
|
|
61
|
+
this.overrides = {}
|
|
62
|
+
if (client === undefined) return
|
|
63
|
+
try {
|
|
64
|
+
const tiddler = await client.get(CONFIG_TIDDLER)
|
|
65
|
+
if (tiddler !== undefined && typeof tiddler.text === 'string') {
|
|
66
|
+
const parsed = JSON.parse(tiddler.text) as unknown
|
|
67
|
+
if (isPlainObject(parsed)) this.overrides = parsed as PluginConfigShape
|
|
68
|
+
}
|
|
69
|
+
} catch {
|
|
70
|
+
// Wiki not ready or config tiddler unreadable → keep empty overrides.
|
|
71
|
+
this.overrides = {}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Merge a patch into the overrides and persist the tiddler. */
|
|
76
|
+
async set(client: TiddlyWebClient, patch: PluginConfigShape): Promise<PluginConfigShape> {
|
|
77
|
+
this.overrides = deepMerge(this.overrides, patch) as PluginConfigShape
|
|
78
|
+
await client.put({
|
|
79
|
+
title: CONFIG_TIDDLER,
|
|
80
|
+
text: JSON.stringify(this.overrides, null, 2),
|
|
81
|
+
type: 'application/json',
|
|
82
|
+
tags: [],
|
|
83
|
+
})
|
|
84
|
+
return this.get()
|
|
85
|
+
}
|
|
86
|
+
}
|
package/src/host/git.ts
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git face (design doc §7, D11) — the ONLY place dsh-tiddlywiki shells out to
|
|
3
|
+
* git. The wiki folder itself is the repository; the folder is pure text
|
|
4
|
+
* (FileSystemAdaptor writes one file per tiddler), so git is a natural sync /
|
|
5
|
+
* backup channel.
|
|
6
|
+
*
|
|
7
|
+
* Sync model is the single-thread alternating one:
|
|
8
|
+
* 1. start of work: `git pull --rebase --autostash`
|
|
9
|
+
* 2. end of work: `git add -A && git commit && git push`
|
|
10
|
+
* 3. auto-commit: debounced 60s commit after wiki writes (AutoCommitter)
|
|
11
|
+
*
|
|
12
|
+
* Conflict policy (user-confirmed, no complex handling): a rebase conflict
|
|
13
|
+
* (only reachable by "forgot to pull before writing") → `git rebase --abort`
|
|
14
|
+
* + report the unmerged files. Never auto-merge data.
|
|
15
|
+
*
|
|
16
|
+
* @module dsh-tiddlywiki/host/git
|
|
17
|
+
*/
|
|
18
|
+
import { execFile } from 'node:child_process'
|
|
19
|
+
import { promisify } from 'node:util'
|
|
20
|
+
|
|
21
|
+
const execFileP = promisify(execFile)
|
|
22
|
+
|
|
23
|
+
/** Timeout for quick read-only queries. */
|
|
24
|
+
const QUICK_TIMEOUT_MS = 5_000
|
|
25
|
+
|
|
26
|
+
/** Timeout for structural/network operations. */
|
|
27
|
+
const HEAVY_TIMEOUT_MS = 60_000
|
|
28
|
+
|
|
29
|
+
export interface ExecResult { ok: boolean; stdout: string; stderr: string }
|
|
30
|
+
export type ExecFn = (args: string[], options: { cwd?: string; timeout?: number }) => Promise<ExecResult>
|
|
31
|
+
|
|
32
|
+
/** Default exec layer: run `git <args>` under a cwd with a timeout. */
|
|
33
|
+
const defaultExec: ExecFn = async (args, options) => {
|
|
34
|
+
try {
|
|
35
|
+
const { stdout, stderr } = await execFileP('git', args, {
|
|
36
|
+
cwd: options.cwd,
|
|
37
|
+
timeout: options.timeout ?? QUICK_TIMEOUT_MS,
|
|
38
|
+
windowsHide: true,
|
|
39
|
+
encoding: 'utf8',
|
|
40
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
41
|
+
})
|
|
42
|
+
return { ok: true, stdout, stderr }
|
|
43
|
+
} catch (err) {
|
|
44
|
+
const e = err as { stdout?: string; stderr?: string; message?: string }
|
|
45
|
+
return { ok: false, stdout: e.stdout ?? '', stderr: e.stderr ?? String(e.message ?? err) }
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface GitStatusView {
|
|
50
|
+
exists: boolean
|
|
51
|
+
branch: string
|
|
52
|
+
dirty: boolean
|
|
53
|
+
dirtyFiles: string[]
|
|
54
|
+
remote: string
|
|
55
|
+
lastCommit?: string
|
|
56
|
+
ahead?: number
|
|
57
|
+
behind?: number
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface GitActionResult { ok: boolean; message: string; conflictFiles?: string[] }
|
|
61
|
+
|
|
62
|
+
function parseCount(line: string, re: RegExp): number | undefined {
|
|
63
|
+
const m = line.match(re)
|
|
64
|
+
return m === null ? undefined : Number(m[1])
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export class GitFace {
|
|
68
|
+
constructor(private readonly exec: ExecFn = defaultExec) {}
|
|
69
|
+
|
|
70
|
+
async isRepo(dir: string): Promise<boolean> {
|
|
71
|
+
const r = await this.exec(['rev-parse', '--is-inside-work-tree'], { cwd: dir, timeout: 2_000 })
|
|
72
|
+
return r.ok && r.stdout.trim() === 'true'
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async init(dir: string, branch = 'main'): Promise<boolean> {
|
|
76
|
+
const r = await this.exec(['init', '-b', branch], { cwd: dir, timeout: HEAVY_TIMEOUT_MS })
|
|
77
|
+
return r.ok
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Initial commit for a fresh repo (tolerates an empty index). */
|
|
81
|
+
async initialCommit(dir: string): Promise<boolean> {
|
|
82
|
+
await this.exec(['add', '-A'], { cwd: dir, timeout: HEAVY_TIMEOUT_MS })
|
|
83
|
+
const r = await this.exec([...identity(), 'commit', '-m', 'chore(dsh-tiddlywiki): initial commit'], { cwd: dir, timeout: HEAVY_TIMEOUT_MS })
|
|
84
|
+
return r.ok || /nothing to commit/.test(r.stderr + r.stdout)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Stage everything and commit; a local identity is always provided so the
|
|
89
|
+
* plugin never depends on the machine's global git config. Returns whether
|
|
90
|
+
* a commit actually happened.
|
|
91
|
+
*/
|
|
92
|
+
async commit(dir: string, message: string): Promise<{ committed: boolean; message: string }> {
|
|
93
|
+
await this.exec(['add', '-A'], { cwd: dir, timeout: HEAVY_TIMEOUT_MS })
|
|
94
|
+
const staged = await this.exec(['diff', '--cached', '--quiet'], { cwd: dir, timeout: QUICK_TIMEOUT_MS })
|
|
95
|
+
// `diff --cached --quiet` exits 0 when nothing is staged → nothing to commit.
|
|
96
|
+
if (staged.ok) return { committed: false, message: 'nothing to commit' }
|
|
97
|
+
const r = await this.exec([...identity(), 'commit', '-m', message], { cwd: dir, timeout: HEAVY_TIMEOUT_MS })
|
|
98
|
+
return r.ok
|
|
99
|
+
? { committed: true, message }
|
|
100
|
+
: { committed: false, message: `commit failed: ${(r.stderr.trim() || r.stdout.trim()).slice(0, 500)}` }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async status(dir: string): Promise<GitStatusView> {
|
|
104
|
+
const empty: GitStatusView = { exists: false, branch: '', dirty: false, dirtyFiles: [], remote: '' }
|
|
105
|
+
const r = await this.exec(['status', '--porcelain', '-b'], { cwd: dir, timeout: QUICK_TIMEOUT_MS })
|
|
106
|
+
if (!r.ok) return empty
|
|
107
|
+
const lines = r.stdout.split('\n').filter((l) => l.length > 0)
|
|
108
|
+
const branchLine = lines.find((l) => l.startsWith('## '))
|
|
109
|
+
const branch = branchLine === undefined ? '' : branchLine.slice(3).split('...')[0] ?? ''
|
|
110
|
+
const ahead = branchLine === undefined ? undefined : parseCount(branchLine, /ahead (\d+)/)
|
|
111
|
+
const behind = branchLine === undefined ? undefined : parseCount(branchLine, /behind (\d+)/)
|
|
112
|
+
const dirty = lines.some((l) => !l.startsWith('## '))
|
|
113
|
+
const dirtyFiles = lines.filter((l) => !l.startsWith('## ')).map((l) => l.slice(3).trim()).filter(Boolean)
|
|
114
|
+
const remoteR = await this.exec(['remote', '-v'], { cwd: dir, timeout: QUICK_TIMEOUT_MS })
|
|
115
|
+
const remote = remoteR.ok ? remoteR.stdout.split('\n').map((l) => l.trim()).find(Boolean) ?? '' : ''
|
|
116
|
+
const lastR = await this.exec(['log', '-1', '--format=%h %s'], { cwd: dir, timeout: QUICK_TIMEOUT_MS })
|
|
117
|
+
const lastCommit = lastR.ok && lastR.stdout.trim().length > 0 ? lastR.stdout.trim() : undefined
|
|
118
|
+
return { exists: true, branch, dirty, dirtyFiles, remote, ...(lastCommit !== undefined ? { lastCommit } : {}), ...(ahead !== undefined ? { ahead } : {}), ...(behind !== undefined ? { behind } : {}) }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** `git pull --rebase --autostash`; on conflict: abort + report files. */
|
|
122
|
+
async pull(dir: string): Promise<GitActionResult> {
|
|
123
|
+
const r = await this.exec(['pull', '--rebase', '--autostash'], { cwd: dir, timeout: HEAVY_TIMEOUT_MS })
|
|
124
|
+
if (r.ok) return { ok: true, message: r.stdout.trim() || 'pull ok' }
|
|
125
|
+
const conflictFiles = await this.unmergedFiles(dir)
|
|
126
|
+
await this.exec(['rebase', '--abort'], { cwd: dir, timeout: HEAVY_TIMEOUT_MS })
|
|
127
|
+
const reason = (r.stderr.trim() || r.stdout.trim()).slice(0, 500)
|
|
128
|
+
return { ok: false, message: conflictFiles.length > 0 ? `conflict in ${conflictFiles.join(', ')} (rebase aborted): ${reason}` : `pull failed: ${reason}`, ...(conflictFiles.length > 0 ? { conflictFiles } : {}) }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async push(dir: string): Promise<GitActionResult> {
|
|
132
|
+
const r = await this.exec(['push'], { cwd: dir, timeout: HEAVY_TIMEOUT_MS })
|
|
133
|
+
return r.ok
|
|
134
|
+
? { ok: true, message: r.stdout.trim() || 'push ok' }
|
|
135
|
+
: { ok: false, message: (r.stderr.trim() || r.stdout.trim()).slice(0, 500) }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** First push with upstream tracking (called once after a remote is set). */
|
|
139
|
+
async firstPush(dir: string): Promise<GitActionResult> {
|
|
140
|
+
const branch = (await this.status(dir)).branch || 'main'
|
|
141
|
+
const r = await this.exec(['push', '-u', 'origin', branch], { cwd: dir, timeout: HEAVY_TIMEOUT_MS })
|
|
142
|
+
return r.ok
|
|
143
|
+
? { ok: true, message: `pushed ${branch} to origin` }
|
|
144
|
+
: { ok: false, message: (r.stderr.trim() || r.stdout.trim()).slice(0, 500) }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Ensure `origin` points at `url` (add or set-url). */
|
|
148
|
+
async ensureRemote(dir: string, url: string): Promise<GitActionResult> {
|
|
149
|
+
const cur = await this.exec(['remote', 'get-url', 'origin'], { cwd: dir, timeout: QUICK_TIMEOUT_MS })
|
|
150
|
+
if (cur.ok) {
|
|
151
|
+
if (cur.stdout.trim() === url) return { ok: true, message: 'remote origin already set' }
|
|
152
|
+
const set = await this.exec(['remote', 'set-url', 'origin', url], { cwd: dir, timeout: HEAVY_TIMEOUT_MS })
|
|
153
|
+
return set.ok ? { ok: true, message: `remote origin → ${url}` } : { ok: false, message: set.stderr.trim() || 'remote set-url failed' }
|
|
154
|
+
}
|
|
155
|
+
const add = await this.exec(['remote', 'add', 'origin', url], { cwd: dir, timeout: HEAVY_TIMEOUT_MS })
|
|
156
|
+
return add.ok ? { ok: true, message: `remote origin → ${url}` } : { ok: false, message: add.stderr.trim() || 'remote add failed' }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private async unmergedFiles(dir: string): Promise<string[]> {
|
|
160
|
+
const r = await this.exec(['diff', '--name-only', '--diff-filter=U'], { cwd: dir, timeout: QUICK_TIMEOUT_MS })
|
|
161
|
+
return r.ok ? r.stdout.split('\n').map((l) => l.trim()).filter(Boolean) : []
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Always-on local identity so commits never depend on global git config. */
|
|
166
|
+
function identity(): string[] {
|
|
167
|
+
return ['-c', 'user.name=dsh-tiddlywiki', '-c', 'user.email=dsh-tiddlywiki@local']
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export interface AutoCommitterOptions {
|
|
171
|
+
git: GitFace
|
|
172
|
+
dir: string
|
|
173
|
+
enabled: boolean
|
|
174
|
+
debounceMs: number
|
|
175
|
+
message: () => string
|
|
176
|
+
onError?: (err: unknown) => void
|
|
177
|
+
onCommit?: (info: { committed: boolean; message: string }) => void
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Debounced auto-committer: every wiki write calls `touch()`; the commit
|
|
182
|
+
* fires once writes settle for `debounceMs`. Disable with git.autoCommit.
|
|
183
|
+
*/
|
|
184
|
+
export class AutoCommitter {
|
|
185
|
+
private timer: NodeJS.Timeout | undefined
|
|
186
|
+
private disposed = false
|
|
187
|
+
|
|
188
|
+
constructor(private readonly options: AutoCommitterOptions) {}
|
|
189
|
+
|
|
190
|
+
touch(): void {
|
|
191
|
+
if (!this.options.enabled || this.disposed) return
|
|
192
|
+
if (this.timer !== undefined) clearTimeout(this.timer)
|
|
193
|
+
this.timer = setTimeout(() => { void this.flush() }, this.options.debounceMs)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Run a commit now (also cancels the pending debounce). */
|
|
197
|
+
async flush(): Promise<void> {
|
|
198
|
+
if (this.timer !== undefined) {
|
|
199
|
+
clearTimeout(this.timer)
|
|
200
|
+
this.timer = undefined
|
|
201
|
+
}
|
|
202
|
+
if (!this.options.enabled || this.disposed) return
|
|
203
|
+
try {
|
|
204
|
+
const result = await this.options.git.commit(this.options.dir, this.options.message())
|
|
205
|
+
this.options.onCommit?.(result)
|
|
206
|
+
} catch (err) {
|
|
207
|
+
this.options.onError?.(err)
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
dispose(): void {
|
|
212
|
+
this.disposed = true
|
|
213
|
+
if (this.timer !== undefined) {
|
|
214
|
+
clearTimeout(this.timer)
|
|
215
|
+
this.timer = undefined
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DSH webserver routes for dsh-tiddlywiki (design doc §10).
|
|
3
|
+
*
|
|
4
|
+
* | route | method | purpose |
|
|
5
|
+
* |---------------------------|--------|------------------------------------------|
|
|
6
|
+
* | /dsh-tiddlywiki/status | GET | panel health (service / url / git / tag) |
|
|
7
|
+
* | /dsh-tiddlywiki/note | POST | quick-note → independent tiddler |
|
|
8
|
+
* | /dsh-tiddlywiki/restart | POST | one-click retry/restart of the TW child |
|
|
9
|
+
* | /dsh-tiddlywiki/api/* | any | passthrough to the TW service (JSON) |
|
|
10
|
+
*
|
|
11
|
+
* Matching is exact-over-prefix, so the exact routes win and the `/api`
|
|
12
|
+
* prefix catches the rest. Client calls are same-origin (the DSH web server),
|
|
13
|
+
* so no CORS is involved.
|
|
14
|
+
*
|
|
15
|
+
* @module dsh-tiddlywiki/host/routes
|
|
16
|
+
*/
|
|
17
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
18
|
+
import type { TiddlyWebClient } from './tw-api.ts'
|
|
19
|
+
import type { WikiServer } from './wiki.ts'
|
|
20
|
+
import type { GitFace } from './git.ts'
|
|
21
|
+
import { PATH_PREFIX } from './wiki.ts'
|
|
22
|
+
|
|
23
|
+
export const ROUTE_PREFIX = PATH_PREFIX
|
|
24
|
+
|
|
25
|
+
/** Max JSON body for note/restart. */
|
|
26
|
+
const MAX_BODY_BYTES = 2 * 1024 * 1024
|
|
27
|
+
|
|
28
|
+
/** Max passthrough body (tiddler content can be large). */
|
|
29
|
+
const MAX_PROXY_BODY_BYTES = 16 * 1024 * 1024
|
|
30
|
+
|
|
31
|
+
/** Structural webserver face (a subset of dsh-host-webserver). */
|
|
32
|
+
export interface WebServerFace {
|
|
33
|
+
register(route: { kind: 'exact' | 'prefix'; path: string; handler: (req: IncomingMessage, res: ServerResponse) => void }): () => void
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface RouteDeps {
|
|
37
|
+
server: WikiServer
|
|
38
|
+
getClient: () => TiddlyWebClient | undefined
|
|
39
|
+
git: GitFace
|
|
40
|
+
autoCommit: () => void
|
|
41
|
+
noteDefaults: () => { tag: string }
|
|
42
|
+
getWikiPath: () => string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function readBody(req: IncomingMessage, limit = MAX_BODY_BYTES): Promise<string> {
|
|
46
|
+
return new Promise((resolveP, rejectP) => {
|
|
47
|
+
let size = 0
|
|
48
|
+
const chunks: Buffer[] = []
|
|
49
|
+
req.on('data', (chunk: Buffer) => {
|
|
50
|
+
size += chunk.length
|
|
51
|
+
if (size > limit) {
|
|
52
|
+
rejectP(new Error('body too large'))
|
|
53
|
+
req.destroy()
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
chunks.push(chunk)
|
|
57
|
+
})
|
|
58
|
+
req.on('end', () => resolveP(Buffer.concat(chunks).toString('utf8')))
|
|
59
|
+
req.on('error', rejectP)
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function json(res: ServerResponse, payload: unknown, status = 200): void {
|
|
64
|
+
const body = JSON.stringify(payload)
|
|
65
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
66
|
+
res.end(body)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function pad(n: number): string {
|
|
70
|
+
return n < 10 ? `0${n}` : String(n)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Default note title: `YYYY-MM-DD HH:mm` (design doc D6). */
|
|
74
|
+
function timestampTitle(date = new Date()): string {
|
|
75
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Open a tiddler in TW's NATIVE editor: save the tiddler (when text is
|
|
80
|
+
* non-empty), reuse or create a DRAFT tiddler carrying `draft.of`/`draft.title`
|
|
81
|
+
* (TW's story view renders drafts with the EditTemplate — list.js:
|
|
82
|
+
* `isDraft && editTemplate`), and return the draft title so the client can
|
|
83
|
+
* navigate the panel iframe to `#<draftTitle>`.
|
|
84
|
+
*/
|
|
85
|
+
export async function openInTwEditor(
|
|
86
|
+
client: TiddlyWebClient,
|
|
87
|
+
title: string,
|
|
88
|
+
text: string,
|
|
89
|
+
tag: string,
|
|
90
|
+
): Promise<{ title: string; draftTitle: string }> {
|
|
91
|
+
if (text.trim().length > 0) {
|
|
92
|
+
await client.put({ title, text, tags: [tag] })
|
|
93
|
+
}
|
|
94
|
+
// Draft content: the provided text, else the existing tiddler's content.
|
|
95
|
+
let draftText = text
|
|
96
|
+
if (draftText.trim().length === 0) {
|
|
97
|
+
const existing = await client.get(title)
|
|
98
|
+
draftText = existing?.text ?? ''
|
|
99
|
+
}
|
|
100
|
+
// Reuse an existing draft for this title (mirrors wiki.findDraft).
|
|
101
|
+
let draftTitle: string | undefined
|
|
102
|
+
try {
|
|
103
|
+
const items = await client.list(undefined, true)
|
|
104
|
+
for (const item of items) {
|
|
105
|
+
if (item['draft.of'] === title && typeof item.title === 'string') {
|
|
106
|
+
draftTitle = item.title
|
|
107
|
+
break
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
} catch {
|
|
111
|
+
/* fall back to a fresh draft */
|
|
112
|
+
}
|
|
113
|
+
if (draftTitle === undefined) draftTitle = `Draft of "${title}" ${Date.now()}`
|
|
114
|
+
await client.put({ title: draftTitle, text: draftText, 'draft.of': title, 'draft.title': title, type: 'text/vnd.tiddlywiki' })
|
|
115
|
+
return { title, draftTitle }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDeps): () => void {
|
|
119
|
+
const handleStatus = async (_req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
120
|
+
const view = deps.server.status()
|
|
121
|
+
let gitSummary: GitStatusViewPublic | null = null
|
|
122
|
+
try {
|
|
123
|
+
gitSummary = await deps.git.status(deps.getWikiPath())
|
|
124
|
+
} catch {
|
|
125
|
+
gitSummary = null
|
|
126
|
+
}
|
|
127
|
+
json(res, { ok: true, ...view, git: gitSummary, note: { tag: deps.noteDefaults().tag } })
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const handleNote = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
131
|
+
try {
|
|
132
|
+
const body = JSON.parse(await readBody(req)) as { title?: unknown; tag?: unknown; text?: unknown }
|
|
133
|
+
const text = typeof body.text === 'string' && body.text.trim().length > 0 ? body.text.trim() : null
|
|
134
|
+
if (text === null) {
|
|
135
|
+
json(res, { ok: false, error: 'text is required' }, 400)
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
const client = deps.getClient()
|
|
139
|
+
if (client === undefined) {
|
|
140
|
+
json(res, { ok: false, error: 'wiki service is not running' }, 503)
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
const title = typeof body.title === 'string' && body.title.trim().length > 0 ? body.title.trim() : timestampTitle()
|
|
144
|
+
const tag = typeof body.tag === 'string' && body.tag.trim().length > 0 ? body.tag.trim() : deps.noteDefaults().tag
|
|
145
|
+
await client.put({ title, text, tags: [tag] })
|
|
146
|
+
deps.autoCommit()
|
|
147
|
+
json(res, { ok: true, title, tag, text })
|
|
148
|
+
} catch (err) {
|
|
149
|
+
json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, 500)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const handleEdit = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
154
|
+
try {
|
|
155
|
+
const body = JSON.parse(await readBody(req)) as { title?: unknown; tag?: unknown; text?: unknown }
|
|
156
|
+
const client = deps.getClient()
|
|
157
|
+
if (client === undefined) {
|
|
158
|
+
json(res, { ok: false, error: 'wiki service is not running' }, 503)
|
|
159
|
+
return
|
|
160
|
+
}
|
|
161
|
+
const title = typeof body.title === 'string' && body.title.trim().length > 0 ? body.title.trim() : timestampTitle()
|
|
162
|
+
const tag = typeof body.tag === 'string' && body.tag.trim().length > 0 ? body.tag.trim() : deps.noteDefaults().tag
|
|
163
|
+
const text = typeof body.text === 'string' ? body.text : ''
|
|
164
|
+
const result = await openInTwEditor(client, title, text, tag)
|
|
165
|
+
deps.autoCommit()
|
|
166
|
+
json(res, { ok: true, ...result, twUrl: deps.server.url })
|
|
167
|
+
} catch (err) {
|
|
168
|
+
json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, 500)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const handleRestart = async (_req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
173
|
+
try {
|
|
174
|
+
await deps.server.restart()
|
|
175
|
+
json(res, { ok: true, status: deps.server.status().status })
|
|
176
|
+
} catch (err) {
|
|
177
|
+
json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, 500)
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Passthrough /dsh-tiddlywiki/api/<rest> → TW root /<rest>. */
|
|
182
|
+
const handleApiProxy = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
183
|
+
const client = deps.getClient()
|
|
184
|
+
if (client === undefined) {
|
|
185
|
+
json(res, { ok: false, error: 'wiki service is not running' }, 503)
|
|
186
|
+
return
|
|
187
|
+
}
|
|
188
|
+
const url = new URL(req.url ?? '/', 'http://127.0.0.1')
|
|
189
|
+
const rest = url.pathname.replace(/^\/dsh-tiddlywiki\/api/, '') || '/'
|
|
190
|
+
try {
|
|
191
|
+
const headers: Record<string, string> = {}
|
|
192
|
+
const ct = req.headers['content-type']
|
|
193
|
+
if (typeof ct === 'string') headers['content-type'] = ct
|
|
194
|
+
const method = (req.method ?? 'GET').toUpperCase()
|
|
195
|
+
// TW's CSRF gate requires X-Requested-With on writes; forward it through.
|
|
196
|
+
if (method === 'PUT' || method === 'DELETE' || method === 'POST') headers['x-requested-with'] = 'TiddlyWiki'
|
|
197
|
+
const init: RequestInit = { method, headers, signal: AbortSignal.timeout(15_000) }
|
|
198
|
+
if (method === 'PUT' || method === 'POST') init.body = await readBody(req, MAX_PROXY_BODY_BYTES)
|
|
199
|
+
const upstream = await fetch(`${deps.server.url}${rest}${url.search}`, init)
|
|
200
|
+
const data = await upstream.text()
|
|
201
|
+
res.writeHead(upstream.status, {
|
|
202
|
+
'content-type': upstream.headers.get('content-type') ?? 'application/json; charset=utf-8',
|
|
203
|
+
'cache-control': 'no-store',
|
|
204
|
+
})
|
|
205
|
+
res.end(data)
|
|
206
|
+
} catch (err) {
|
|
207
|
+
json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, 502)
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const disposers = [
|
|
212
|
+
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/status`, handler: (req, res) => { void handleStatus(req, res) } }),
|
|
213
|
+
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/note`, handler: (req, res) => { void handleNote(req, res) } }),
|
|
214
|
+
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/edit`, handler: (req, res) => { void handleEdit(req, res) } }),
|
|
215
|
+
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/restart`, handler: (req, res) => { void handleRestart(req, res) } }),
|
|
216
|
+
ctx.webServer.register({ kind: 'prefix', path: `${ROUTE_PREFIX}/api`, handler: (req, res) => { void handleApiProxy(req, res) } }),
|
|
217
|
+
]
|
|
218
|
+
return () => {
|
|
219
|
+
for (const dispose of disposers) dispose()
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Public shape of the git status summary sent to the panel. */
|
|
224
|
+
export interface GitStatusViewPublic {
|
|
225
|
+
exists: boolean
|
|
226
|
+
branch: string
|
|
227
|
+
dirty: boolean
|
|
228
|
+
dirtyFiles: string[]
|
|
229
|
+
remote: string
|
|
230
|
+
lastCommit?: string
|
|
231
|
+
ahead?: number
|
|
232
|
+
behind?: number
|
|
233
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in doc note for the plugin (design doc §14): a short user-facing
|
|
3
|
+
* "how to use dsh-tiddlywiki" note that is seeded into the wiki on first run.
|
|
4
|
+
*
|
|
5
|
+
* Idempotent seed (create-if-missing): on every plugin start we check whether
|
|
6
|
+
* the note tiddler exists and only write it when it is absent — deleting it
|
|
7
|
+
* and restarting dsh web recreates it, but editing it never gets overwritten.
|
|
8
|
+
*
|
|
9
|
+
* @module dsh-tiddlywiki/host/seed-notes
|
|
10
|
+
*/
|
|
11
|
+
import type { TiddlyWebClient } from './tw-api.ts'
|
|
12
|
+
|
|
13
|
+
/** Note tiddler title (a normal, searchable note — not a system tiddler). */
|
|
14
|
+
export const DOC_NOTE_TITLE = 'dsh-tiddlywiki 插件说明'
|
|
15
|
+
|
|
16
|
+
/** Tag that makes the note easy to find via `tiddlywiki_search tag=docs`. */
|
|
17
|
+
export const DOC_NOTE_TAG = 'docs'
|
|
18
|
+
|
|
19
|
+
/** The note body, TiddlyWiki wiki-text. */
|
|
20
|
+
export const DOC_NOTE_TEXT = `! dsh-tiddlywiki 插件说明
|
|
21
|
+
|
|
22
|
+
本插件把 **TiddlyWiki 5** 作为 DSH 的持久知识库(wiki 文件夹本身就是一个 git 仓库,随内容自动提交/同步)。
|
|
23
|
+
|
|
24
|
+
!! 它能做什么
|
|
25
|
+
|
|
26
|
+
* **5 个 agent 工具**:\`tiddlywiki_search\`(检索)/ \`tiddlywiki_get\`(读)/ \`tiddlywiki_put\`(写)/ \`tiddlywiki_delete\`(删)/ \`tiddlywiki_git_sync\`(git 同步)。
|
|
27
|
+
* **TW 编辑器面板**:侧边栏「TiddlyWiki」按钮 → 在界面中央打开完整版 TW 编辑器。
|
|
28
|
+
* **快速笔记**:右下角悬浮「📝 快速笔记」写随手记,\`Ctrl+Enter\` 保存;点「✏️ 在 TW 中编辑」会弹出独立小窗用 TW 原生编辑器编辑。
|
|
29
|
+
* **git 同步**:写入自动防抖 commit(默认 60 秒);手动 \`tiddlywiki_git_sync action=sync\` 做 pull → commit → push。
|
|
30
|
+
* **设置页**:DSH 设置 → 「TiddlyWiki 知识库」管理插件/主题/语言与运行配置。
|
|
31
|
+
|
|
32
|
+
!! 知识库纪律(三条)
|
|
33
|
+
|
|
34
|
+
1. 开工先 \`tiddlywiki_git_sync action=pull\`(rebase + autostash,真冲突会自动 abort 并报文件)。
|
|
35
|
+
2. 收工 \`tiddlywiki_git_sync action=sync\`。
|
|
36
|
+
3. 插件自动 commit 兜底,手动 sync 用于需要主动推送的场合。
|
|
37
|
+
|
|
38
|
+
!! 主题与语言
|
|
39
|
+
|
|
40
|
+
* **主题**分两层:每行一个「☑ 加载」(多选 = TW 里可用的主题,依赖链自动带上)和「◉ 活动」(单选 = 当前视觉主题)。应用后自动重启 TW。
|
|
41
|
+
* **语言**:设置页勾选 \`zh-Hans\`(简体)并应用,TW 界面即切换为中文。
|
|
42
|
+
|
|
43
|
+
!! 说明
|
|
44
|
+
|
|
45
|
+
* 本笔记由插件在首次启动时自动写入 wiki(幂等:不存在才写)。删除后重启 dsh web 会重建;手动编辑过的内容不会被覆盖。
|
|
46
|
+
* 更多细节见插件仓库 README。`
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Seed the doc note when it is absent. Returns whether a note was written.
|
|
50
|
+
* Never throws (missing wiki or note already present → no-op / false).
|
|
51
|
+
*/
|
|
52
|
+
export async function seedDocNote(client: TiddlyWebClient): Promise<boolean> {
|
|
53
|
+
const existing = await client.get(DOC_NOTE_TITLE).catch(() => undefined)
|
|
54
|
+
if (existing !== undefined) return false
|
|
55
|
+
await client.put({
|
|
56
|
+
title: DOC_NOTE_TITLE,
|
|
57
|
+
text: DOC_NOTE_TEXT,
|
|
58
|
+
type: 'text/vnd.tiddlywiki',
|
|
59
|
+
tags: [DOC_NOTE_TAG],
|
|
60
|
+
})
|
|
61
|
+
return true
|
|
62
|
+
}
|