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.
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Floating "sync" button (bottom-right) — one click pulls + commits + pushes
3
+ * the wiki git repository and reports the result, with a live status dot so
4
+ * the human always sees whether the knowledge base is in sync.
5
+ *
6
+ * - honors `ui.showSyncButton`: the button is mounted async and never created
7
+ * when the option is off (settings page toggle);
8
+ * - polls /dsh-tiddlywiki/status every 30s for the git state;
9
+ * - clicking POSTs /dsh-tiddlywiki/sync (pull → commit → push) and toasts the
10
+ * outcome, then refreshes the dot immediately.
11
+ *
12
+ * Status dot mapping (git summary from /status):
13
+ * offline → gray (no repo / service unreachable)
14
+ * dirty → amber (uncommitted changes)
15
+ * behind → red (remote has commits we don't — pull will rebase)
16
+ * clean → green (worktree clean)
17
+ *
18
+ * @module dsh-tiddlywiki/client/sync-button
19
+ */
20
+ import { toast } from './toast.ts'
21
+
22
+ const STATUS_ENDPOINT = '/dsh-tiddlywiki/status'
23
+ const SYNC_ENDPOINT = '/dsh-tiddlywiki/sync'
24
+ const POLL_MS = 30_000
25
+
26
+ /** Public git summary shape (mirrors GitStatusViewPublic on the host). */
27
+ interface GitView {
28
+ exists?: boolean
29
+ branch?: string
30
+ dirty?: boolean
31
+ dirtyFiles?: string[]
32
+ remote?: string
33
+ lastCommit?: string
34
+ ahead?: number
35
+ behind?: number
36
+ }
37
+
38
+ interface StatusPayload {
39
+ ok?: boolean
40
+ status?: string
41
+ git?: GitView | null
42
+ ui?: { showSyncButton?: boolean }
43
+ }
44
+
45
+ /** Inline sync icon (circular arrows, stroke-based, theme-colored). */
46
+ const SYNC_ICON = '<svg viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M13 8a5 5 0 1 1-1.4-3.5"/><path d="M13 2.5v3h-3"/></svg>'
47
+
48
+ function pad(n: number): string {
49
+ return n < 10 ? `0${n}` : String(n)
50
+ }
51
+
52
+ /** Compact local time for the tooltip (e.g. "10:32"). */
53
+ function clock(date: Date): string {
54
+ return `${pad(date.getHours())}:${pad(date.getMinutes())}`
55
+ }
56
+
57
+ async function fetchStatus(): Promise<StatusPayload | null> {
58
+ try {
59
+ const res = await fetch(STATUS_ENDPOINT, { signal: AbortSignal.timeout(8_000) })
60
+ if (!res.ok) return null
61
+ return (await res.json()) as StatusPayload
62
+ } catch {
63
+ return null
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Mount the floating sync button. Fetches /status first: when
69
+ * `ui.showSyncButton` is off the button is never created. Returns a disposer.
70
+ */
71
+ export function mountSyncButton(): () => void {
72
+ let disposed = false
73
+ let root: HTMLButtonElement | undefined
74
+ let dot: HTMLSpanElement | undefined
75
+ let label: HTMLSpanElement | undefined
76
+ let lastSync: Date | undefined
77
+ let timer: number | undefined
78
+
79
+ const build = (): HTMLButtonElement => {
80
+ const btn = document.createElement('button')
81
+ btn.type = 'button'
82
+ btn.className = 'dsh-tw-sync'
83
+ btn.title = '同步知识库(pull → commit → push)'
84
+
85
+ const icon = document.createElement('span')
86
+ icon.className = 'dsh-tw-sync-icon'
87
+ icon.innerHTML = SYNC_ICON
88
+
89
+ dot = document.createElement('span')
90
+ dot.className = 'dsh-tw-sync-dot'
91
+ dot.dataset.state = 'offline'
92
+
93
+ label = document.createElement('span')
94
+ label.className = 'dsh-tw-sync-label'
95
+ label.textContent = '同步'
96
+
97
+ btn.append(icon, label, dot)
98
+ btn.addEventListener('click', () => { void doSync() })
99
+ document.body.append(btn)
100
+ return btn
101
+ }
102
+
103
+ /** Map a git summary onto the dot + label + tooltip. */
104
+ const applyStatus = (payload: StatusPayload | null): void => {
105
+ if (root === undefined || dot === undefined || label === undefined) return
106
+ const git = payload?.git
107
+ const bits: string[] = ['同步知识库']
108
+ let state = 'offline'
109
+ let word = '离线'
110
+
111
+ if (git === null || git === undefined || git.exists !== true) {
112
+ bits.push('git 仓库不可用')
113
+ } else {
114
+ bits.push(`分支 ${git.branch ?? '?'}`)
115
+ if (typeof git.lastCommit === 'string') bits.push(git.lastCommit)
116
+ const behind = typeof git.behind === 'number' ? git.behind : 0
117
+ const ahead = typeof git.ahead === 'number' ? git.ahead : 0
118
+ if (ahead > 0) bits.push(`领先 ${ahead}`)
119
+ if (behind > 0) bits.push(`落后 ${behind}`)
120
+ if (git.dirty === true) {
121
+ state = 'dirty'
122
+ word = '待提交'
123
+ bits.push(`有 ${git.dirtyFiles?.length ?? 0} 个未提交改动`)
124
+ } else if (behind > 0) {
125
+ state = 'behind'
126
+ word = '可更新'
127
+ } else {
128
+ state = 'clean'
129
+ word = '已同步'
130
+ }
131
+ }
132
+ if (lastSync !== undefined) bits.push(`上次同步 ${clock(lastSync)}`)
133
+ root.title = bits.join(' · ')
134
+ dot.dataset.state = state
135
+ label.textContent = word
136
+ }
137
+
138
+ const doSync = async (): Promise<void> => {
139
+ if (root === undefined) return
140
+ root.disabled = true
141
+ root.classList.add('dsh-tw-sync-spin')
142
+ if (label !== undefined) label.textContent = '同步中…'
143
+ if (dot !== undefined) dot.dataset.state = 'syncing'
144
+ try {
145
+ const res = await fetch(SYNC_ENDPOINT, { method: 'POST', signal: AbortSignal.timeout(120_000) })
146
+ const payload = (await res.json().catch(() => null)) as
147
+ | { ok?: boolean; message?: string; error?: string; status?: GitView; conflictFiles?: string[]; push?: string; changed?: boolean; restarted?: boolean; restartError?: string }
148
+ | null
149
+ lastSync = new Date()
150
+ if (payload === null || payload.ok !== true) {
151
+ const msg = payload?.error ?? payload?.message ?? `HTTP ${res.status}`
152
+ toast(`同步失败:${msg}`)
153
+ } else {
154
+ let detail = ''
155
+ if (payload.push && payload.push !== 'nothing to commit') detail = `(${payload.push})`
156
+ if (payload.changed === true) {
157
+ detail += payload.restarted === true ? ',TW 已重启' : ',TW 未自动重启'
158
+ if (payload.restartError) detail += `(${payload.restartError})`
159
+ }
160
+ toast(`同步完成:${payload.message ?? 'OK'}${detail}`)
161
+ }
162
+ applyStatus(await fetchStatus())
163
+ } catch (err) {
164
+ toast(`同步失败:${err instanceof Error ? err.message : String(err)}`)
165
+ } finally {
166
+ root.disabled = false
167
+ root.classList.remove('dsh-tw-sync-spin')
168
+ }
169
+ }
170
+
171
+ const poll = async (): Promise<void> => {
172
+ applyStatus(await fetchStatus())
173
+ }
174
+
175
+ void (async () => {
176
+ const payload = await fetchStatus()
177
+ if (disposed || payload?.ui?.showSyncButton === false) return
178
+ root = build()
179
+ applyStatus(payload)
180
+ timer = window.setInterval(() => { void poll() }, POLL_MS)
181
+ })()
182
+
183
+ return () => {
184
+ disposed = true
185
+ if (timer !== undefined) window.clearInterval(timer)
186
+ root?.remove()
187
+ }
188
+ }
@@ -1,22 +1,22 @@
1
- /**
2
- * One-shot transient toast, shared by the quick-note widget and the settings
3
- * page. Reuses a single body-level element (`.dsh-tw-toast`) with a show
4
- * class; the element is created lazily and styled by styles.ts.
5
- *
6
- * @module dsh-tiddlywiki/client/toast
7
- */
8
-
9
- export function toast(message: string): void {
10
- let el = document.querySelector<HTMLElement>('.dsh-tw-toast')
11
- if (el === null) {
12
- el = document.createElement('div')
13
- el.className = 'dsh-tw-toast'
14
- document.body.append(el)
15
- }
16
- el.textContent = message
17
- el.classList.add('dsh-tw-toast-show')
18
- clearTimeout((el as HTMLElement & { __twToastTimer?: number }).__twToastTimer)
19
- ;(el as HTMLElement & { __twToastTimer?: number }).__twToastTimer = window.setTimeout(() => {
20
- el?.classList.remove('dsh-tw-toast-show')
21
- }, 2_500)
22
- }
1
+ /**
2
+ * One-shot transient toast, shared by the quick-note widget and the settings
3
+ * page. Reuses a single body-level element (`.dsh-tw-toast`) with a show
4
+ * class; the element is created lazily and styled by styles.ts.
5
+ *
6
+ * @module dsh-tiddlywiki/client/toast
7
+ */
8
+
9
+ export function toast(message: string): void {
10
+ let el = document.querySelector<HTMLElement>('.dsh-tw-toast')
11
+ if (el === null) {
12
+ el = document.createElement('div')
13
+ el.className = 'dsh-tw-toast'
14
+ document.body.append(el)
15
+ }
16
+ el.textContent = message
17
+ el.classList.add('dsh-tw-toast-show')
18
+ clearTimeout((el as HTMLElement & { __twToastTimer?: number }).__twToastTimer)
19
+ ;(el as HTMLElement & { __twToastTimer?: number }).__twToastTimer = window.setTimeout(() => {
20
+ el?.classList.remove('dsh-tw-toast-show')
21
+ }, 2_500)
22
+ }