dsh-tiddlywiki 0.2.1 → 0.3.1
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 +542 -27
- package/lib/client.js +542 -27
- package/lib/index.js +232 -21
- package/lib/index.js.map +1 -1
- package/package.json +79 -79
- 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 +107 -11
- 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 +167 -9
- 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 +12 -6
- package/src/sdk.ts +198 -198
package/lib/client.bundle.js
CHANGED
|
@@ -82,9 +82,9 @@ html[data-dsh-tw-active] .dshDesktopConversationSurface > :not([data-dsh-tw-view
|
|
|
82
82
|
setting controls whether they mount at all. */
|
|
83
83
|
.dsh-tw-panel-status {
|
|
84
84
|
/* 状态/重载悬浮按钮:右对齐到与快速笔记按钮同一条竖线(right:24),
|
|
85
|
-
|
|
85
|
+
垂直排在「同步」按钮(bottom:140)上方;
|
|
86
86
|
两个按钮竖直依次排列(状态在上、重载在下),与其他插件按钮列对齐、不堆叠。 */
|
|
87
|
-
position: fixed; right: 24px; bottom:
|
|
87
|
+
position: fixed; right: 24px; bottom: 192px; z-index: 950;
|
|
88
88
|
display: flex; flex-direction: column; align-items: flex-end; gap: 8px;
|
|
89
89
|
}
|
|
90
90
|
.dsh-tw-panel-status-btn {
|
|
@@ -119,9 +119,12 @@ html[data-dsh-tw-active] .dshDesktopConversationSurface > :not([data-dsh-tw-view
|
|
|
119
119
|
|
|
120
120
|
/* ── floating quick-note widget ────────────────────────────────
|
|
121
121
|
Positioned ABOVE the shutdown launcher FAB (fixed right:24 bottom:24,
|
|
122
|
-
z-index 900, 46px) and BELOW its confirm overlay (z-index 1000).
|
|
122
|
+
z-index 900, 46px) and BELOW its confirm overlay (z-index 1000).
|
|
123
|
+
Interactive overlays (the open card, the native-editor popup) sit ABOVE the
|
|
124
|
+
passive FAB column (sync/status, z-index 950) so those FABs never cover the
|
|
125
|
+
card's own bottom-right buttons. */
|
|
123
126
|
.dsh-tw-note {
|
|
124
|
-
position: fixed; right: 24px; bottom: 88px; z-index:
|
|
127
|
+
position: fixed; right: 24px; bottom: 88px; z-index: 980;
|
|
125
128
|
display: flex; flex-direction: column; align-items: flex-end; gap: 10px;
|
|
126
129
|
font-family: inherit;
|
|
127
130
|
}
|
|
@@ -132,11 +135,18 @@ html[data-dsh-tw-active] .dshDesktopConversationSurface > :not([data-dsh-tw-view
|
|
|
132
135
|
border-radius: 14px; box-shadow: var(--dsw-shadow-lv3, 0 8px 30px rgba(0,0,0,.22));
|
|
133
136
|
padding: 12px; display: flex; flex-direction: column; gap: 10px;
|
|
134
137
|
font-size: 13px;
|
|
138
|
+
backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px);
|
|
139
|
+
animation: dsh-tw-note-in 180ms ease;
|
|
135
140
|
}
|
|
136
141
|
.dsh-tw-note-card[hidden] { display: none; }
|
|
142
|
+
@keyframes dsh-tw-note-in {
|
|
143
|
+
from { opacity: 0; transform: translateY(8px) scale(.98); }
|
|
144
|
+
to { opacity: 1; transform: translateY(0) scale(1); }
|
|
145
|
+
}
|
|
137
146
|
.dsh-tw-note-head {
|
|
138
147
|
display: flex; align-items: center; gap: 8px;
|
|
139
|
-
font-size: 12px; font-weight: 600;
|
|
148
|
+
font-size: 12px; font-weight: 600; letter-spacing: .2px;
|
|
149
|
+
color: var(--dsw-alias-label-primary, #222);
|
|
140
150
|
}
|
|
141
151
|
.dsh-tw-note-head .dsh-tw-note-label { margin-right: auto; display: flex; align-items: center; gap: 6px; }
|
|
142
152
|
.dsh-tw-note-close {
|
|
@@ -155,28 +165,91 @@ html[data-dsh-tw-active] .dshDesktopConversationSurface > :not([data-dsh-tw-view
|
|
|
155
165
|
outline: none; border-color: var(--dsw-alias-brand-primary, #3e63dd);
|
|
156
166
|
box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-alias-brand-primary, #3e63dd) 25%, transparent);
|
|
157
167
|
}
|
|
158
|
-
.dsh-tw-note-text {
|
|
168
|
+
.dsh-tw-note-text::placeholder { color: var(--dsw-alias-label-dimmed, #999); }
|
|
169
|
+
/* ── Markdown editor (highlighted-textarea overlay) ────────────────
|
|
170
|
+
The <pre> highlight layer and the transparent <textarea> share identical
|
|
171
|
+
metrics (border/padding/font/line-height/white-space) so lines align 1:1. */
|
|
172
|
+
.dsh-tw-note-editor { position: relative; min-width: 0; }
|
|
173
|
+
.dsh-tw-note-editor pre,
|
|
174
|
+
.dsh-tw-note-editor textarea {
|
|
175
|
+
margin: 0; box-sizing: border-box; width: 100%;
|
|
159
176
|
border: 1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.18)); border-radius: 8px;
|
|
160
|
-
padding: 8px 9px; font: inherit; font-size: 13px;
|
|
161
|
-
|
|
177
|
+
padding: 8px 9px; font: inherit; font-size: 13px; line-height: 1.5; min-height: 120px;
|
|
178
|
+
white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-word; tab-size: 4;
|
|
162
179
|
transition: border-color 120ms ease, box-shadow 120ms ease;
|
|
163
180
|
}
|
|
181
|
+
.dsh-tw-note-editor .dsh-tw-note-hl {
|
|
182
|
+
position: absolute; top: 0; left: 0; right: 0; bottom: 0; z-index: 0;
|
|
183
|
+
background: var(--dsw-alias-bg-layer-1, #fff); color: var(--dsw-alias-label-primary, #222);
|
|
184
|
+
pointer-events: none; overflow: hidden;
|
|
185
|
+
}
|
|
186
|
+
.dsh-tw-note-editor .dsh-tw-note-text {
|
|
187
|
+
position: relative; z-index: 1; resize: vertical;
|
|
188
|
+
background: transparent; color: transparent; caret-color: var(--dsw-alias-label-primary, #222);
|
|
189
|
+
overflow: auto;
|
|
190
|
+
}
|
|
191
|
+
.dsh-tw-note-editor .dsh-tw-note-text:focus { outline: none; }
|
|
192
|
+
.dsh-tw-note-drop .dsh-tw-note-text,
|
|
193
|
+
.dsh-tw-note-drop .dsh-tw-note-hl {
|
|
194
|
+
border-color: var(--dsw-alias-brand-primary, #3e63dd);
|
|
195
|
+
box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-alias-brand-primary, #3e63dd) 25%, transparent);
|
|
196
|
+
}
|
|
197
|
+
/* ── markdown highlight tokens (colors only — never size/height, so the
|
|
198
|
+
overlay keeps line-for-line alignment with the invisible textarea) ── */
|
|
199
|
+
.dsh-tw-note-hl .md-heading { font-weight: 700; color: var(--dsw-alias-brand-primary, #3e63dd); }
|
|
200
|
+
.dsh-tw-note-hl .md-hash { font-weight: 700; color: color-mix(in srgb, var(--dsw-alias-brand-primary, #3e63dd) 55%, transparent); }
|
|
201
|
+
.dsh-tw-note-hl .md-code,
|
|
202
|
+
.dsh-tw-note-hl .md-code-block,
|
|
203
|
+
.dsh-tw-note-hl .md-fence {
|
|
204
|
+
font-family: ui-monospace, "Cascadia Mono", Consolas, "SF Mono", Menlo, monospace;
|
|
205
|
+
}
|
|
206
|
+
.dsh-tw-note-hl .md-code {
|
|
207
|
+
background: color-mix(in srgb, var(--dsw-alias-label-secondary, #888) 14%, transparent);
|
|
208
|
+
border-radius: 4px; padding: 0 3px;
|
|
209
|
+
}
|
|
210
|
+
.dsh-tw-note-hl .md-code-block,
|
|
211
|
+
.dsh-tw-note-hl .md-fence {
|
|
212
|
+
display: block; background: color-mix(in srgb, var(--dsw-alias-label-secondary, #888) 8%, transparent);
|
|
213
|
+
}
|
|
214
|
+
.dsh-tw-note-hl .md-bold { font-weight: 700; }
|
|
215
|
+
.dsh-tw-note-hl .md-italic { font-style: italic; }
|
|
216
|
+
.dsh-tw-note-hl .md-strike { text-decoration: line-through; opacity: .75; }
|
|
217
|
+
.dsh-tw-note-hl .md-link { color: var(--dsw-alias-brand-primary, #3e63dd); text-decoration: underline; }
|
|
218
|
+
.dsh-tw-note-hl .md-url { color: color-mix(in srgb, var(--dsw-alias-brand-primary, #3e63dd) 70%, #999); text-decoration: underline dotted; }
|
|
219
|
+
.dsh-tw-note-hl .md-image { color: var(--dsw-alias-state-success-primary, #3eaa5f); font-weight: 600; }
|
|
220
|
+
.dsh-tw-note-hl .md-bullet { font-weight: 700; color: var(--dsw-alias-brand-primary, #3e63dd); }
|
|
221
|
+
.dsh-tw-note-hl .md-number { font-weight: 700; color: var(--dsw-alias-state-warning-primary, #d9822b); }
|
|
222
|
+
.dsh-tw-note-hl .md-quote { font-style: italic; color: var(--dsw-alias-label-secondary, #888); }
|
|
223
|
+
.dsh-tw-note-hl .md-hr { color: var(--dsw-alias-label-dimmed, #999); text-decoration: line-through; }
|
|
224
|
+
.dsh-tw-note-hl .md-task { color: var(--dsw-alias-label-secondary, #888); }
|
|
225
|
+
.dsh-tw-note-hl .md-task-checked { color: var(--dsw-alias-state-success-primary, #3eaa5f); font-weight: 600; }
|
|
164
226
|
.dsh-tw-note-foot { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
|
227
|
+
.dsh-tw-note-foot-left, .dsh-tw-note-foot-right { display: flex; align-items: center; gap: 8px; }
|
|
165
228
|
.dsh-tw-note-hint { font-size: 11px; color: var(--dsw-alias-label-dimmed, #999); }
|
|
229
|
+
.dsh-tw-note-upload {
|
|
230
|
+
border: 1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.18));
|
|
231
|
+
background: var(--dsw-alias-bg-layer-2, #fff); color: var(--dsw-alias-label-primary, #222);
|
|
232
|
+
font: inherit; font-size: 12px; padding: 6px 10px; border-radius: 8px; cursor: pointer;
|
|
233
|
+
transition: filter 120ms ease, transform 80ms ease;
|
|
234
|
+
}
|
|
235
|
+
.dsh-tw-note-upload:hover { filter: brightness(.96); }
|
|
236
|
+
.dsh-tw-note-upload:active { transform: scale(.96); }
|
|
166
237
|
.dsh-tw-note-save {
|
|
167
238
|
border: 1px solid transparent; background: var(--dsw-alias-brand-primary, #3e63dd); color: #fff;
|
|
168
239
|
font: inherit; font-size: 12px; padding: 6px 16px; border-radius: 8px; cursor: pointer;
|
|
169
|
-
transition: filter 120ms ease;
|
|
240
|
+
transition: filter 120ms ease, transform 80ms ease;
|
|
170
241
|
}
|
|
171
242
|
.dsh-tw-note-save:hover:not(:disabled) { filter: brightness(1.08); }
|
|
243
|
+
.dsh-tw-note-save:active:not(:disabled) { transform: scale(.96); }
|
|
172
244
|
.dsh-tw-note-save:disabled { opacity: .55; cursor: default; }
|
|
173
245
|
.dsh-tw-note-edit {
|
|
174
246
|
border: 1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.18));
|
|
175
247
|
background: var(--dsw-alias-bg-layer-2, #fff); color: var(--dsw-alias-label-primary, #222);
|
|
176
248
|
font: inherit; font-size: 12px; padding: 6px 10px; border-radius: 8px; cursor: pointer;
|
|
177
|
-
transition: filter 120ms ease;
|
|
249
|
+
transition: filter 120ms ease, transform 80ms ease;
|
|
178
250
|
}
|
|
179
251
|
.dsh-tw-note-edit:hover:not(:disabled) { filter: brightness(.96); }
|
|
252
|
+
.dsh-tw-note-edit:active:not(:disabled) { transform: scale(.96); }
|
|
180
253
|
.dsh-tw-note-edit:disabled { opacity: .55; cursor: default; }
|
|
181
254
|
/* ── Multi-tag chip editor (quick-note tags) ─────────────────────────────── */
|
|
182
255
|
.dsh-tw-note-tags { position: relative; flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 4px; }
|
|
@@ -214,7 +287,7 @@ html[data-dsh-tw-active] .dshDesktopConversationSurface > :not([data-dsh-tw-view
|
|
|
214
287
|
background: var(--dsw-alias-bg-layer-2, #fff); color: var(--dsw-alias-label-primary, #222);
|
|
215
288
|
border: 1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.2));
|
|
216
289
|
border-radius: 12px; box-shadow: 0 14px 44px rgba(0,0,0,.3);
|
|
217
|
-
z-index:
|
|
290
|
+
z-index: 990; font-family: inherit; font-size: 13px;
|
|
218
291
|
}
|
|
219
292
|
.dsh-tw-editor-bar {
|
|
220
293
|
display: flex; align-items: center; gap: 8px; flex: 0 0 auto;
|
|
@@ -240,6 +313,29 @@ html[data-dsh-tw-active] .dshDesktopConversationSurface > :not([data-dsh-tw-view
|
|
|
240
313
|
}
|
|
241
314
|
.dsh-tw-note-toggle:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.1)); }
|
|
242
315
|
|
|
316
|
+
/* ── floating sync button (bottom-right) ───────────────────────
|
|
317
|
+
Pill FAB between the quick-note toggle (bottom:88) and the TW panel
|
|
318
|
+
status/reload floaters (bottom:192); dot reflects git state. */
|
|
319
|
+
.dsh-tw-sync {
|
|
320
|
+
position: fixed; right: 24px; bottom: 140px; z-index: 950;
|
|
321
|
+
display: inline-flex; align-items: center; gap: 7px;
|
|
322
|
+
padding: 9px 14px; border-radius: 999px;
|
|
323
|
+
border: 1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.18));
|
|
324
|
+
background: var(--dsw-alias-bg-layer-2, #fff); color: var(--dsw-alias-label-primary, #222);
|
|
325
|
+
font: inherit; font-size: 13px; box-shadow: var(--dsw-shadow-lv3, 0 4px 16px rgba(0,0,0,.16));
|
|
326
|
+
cursor: pointer; transition: background-color 120ms ease;
|
|
327
|
+
}
|
|
328
|
+
.dsh-tw-sync:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.1)); }
|
|
329
|
+
.dsh-tw-sync:disabled { opacity: .65; cursor: default; }
|
|
330
|
+
.dsh-tw-sync-icon { display: inline-flex; flex: none; }
|
|
331
|
+
.dsh-tw-sync-spin .dsh-tw-sync-icon svg { animation: dsh-tw-spin 1s linear infinite; transform-origin: center; }
|
|
332
|
+
@keyframes dsh-tw-spin { to { transform: rotate(360deg); } }
|
|
333
|
+
.dsh-tw-sync-dot { width: 8px; height: 8px; border-radius: 50%; background: #999; flex: none; }
|
|
334
|
+
.dsh-tw-sync-dot[data-state="clean"] { background: var(--dsw-alias-state-success-primary, #3eaa5f); }
|
|
335
|
+
.dsh-tw-sync-dot[data-state="dirty"] { background: var(--dsw-alias-state-warning-primary, #d9822b); }
|
|
336
|
+
.dsh-tw-sync-dot[data-state="behind"] { background: var(--dsw-alias-state-error-primary, #d13b3b); }
|
|
337
|
+
.dsh-tw-sync-dot[data-state="syncing"] { background: var(--dsw-alias-brand-primary, #3e63dd); }
|
|
338
|
+
|
|
243
339
|
/* ── toast ──────────────────────────────────────────────────── */
|
|
244
340
|
.dsh-tw-toast {
|
|
245
341
|
position: fixed; left: 50%; bottom: 96px; transform: translateX(-50%);
|
|
@@ -491,7 +587,7 @@ const PANEL_HOST_SELECTOR = "[data-dsh-panel-host]";
|
|
|
491
587
|
const APP_OVERLAY_Z_INDEX = 20;
|
|
492
588
|
/** Safety re-measure cadence for shell layout changes CSS can't see. */
|
|
493
589
|
const SYNC_INTERVAL_MS = 2e3;
|
|
494
|
-
const STATUS_ENDPOINT$
|
|
590
|
+
const STATUS_ENDPOINT$2 = "/dsh-tiddlywiki/status";
|
|
495
591
|
const RESTART_ENDPOINT$1 = "/dsh-tiddlywiki/restart";
|
|
496
592
|
/**
|
|
497
593
|
* The panel's z-index: below any dsh-better-sidebar host layer so its
|
|
@@ -514,9 +610,9 @@ function conversationColumn() {
|
|
|
514
610
|
if (el !== null) return el;
|
|
515
611
|
}
|
|
516
612
|
}
|
|
517
|
-
async function fetchStatus() {
|
|
613
|
+
async function fetchStatus$1() {
|
|
518
614
|
try {
|
|
519
|
-
const res = await fetch(STATUS_ENDPOINT$
|
|
615
|
+
const res = await fetch(STATUS_ENDPOINT$2, { signal: AbortSignal.timeout(8e3) });
|
|
520
616
|
if (!res.ok) return null;
|
|
521
617
|
return await res.json();
|
|
522
618
|
} catch {
|
|
@@ -681,7 +777,7 @@ function mountPanel(state) {
|
|
|
681
777
|
window.clearTimeout(refreshTimer);
|
|
682
778
|
refreshTimer = void 0;
|
|
683
779
|
}
|
|
684
|
-
const payload = await fetchStatus();
|
|
780
|
+
const payload = await fetchStatus$1();
|
|
685
781
|
if (payload === null) {
|
|
686
782
|
setChip("failed", "状态不可达");
|
|
687
783
|
showError("无法访问 /dsh-tiddlywiki/status");
|
|
@@ -754,7 +850,7 @@ function mountPanel(state) {
|
|
|
754
850
|
ensure();
|
|
755
851
|
applyActive();
|
|
756
852
|
(async () => {
|
|
757
|
-
if ((await fetchStatus())?.ui?.showPanelStatus === false) return;
|
|
853
|
+
if ((await fetchStatus$1())?.ui?.showPanelStatus === false) return;
|
|
758
854
|
statusFloater = buildStatusFloater();
|
|
759
855
|
})();
|
|
760
856
|
return () => {
|
|
@@ -903,6 +999,106 @@ function ensurePopup() {
|
|
|
903
999
|
});
|
|
904
1000
|
}
|
|
905
1001
|
//#endregion
|
|
1002
|
+
//#region src/client/markdown.ts
|
|
1003
|
+
/**
|
|
1004
|
+
* Lightweight Markdown syntax highlighter for the quick-note editor.
|
|
1005
|
+
*
|
|
1006
|
+
* The editor is the classic "highlighted textarea": a `<pre>` rendered BEHIND
|
|
1007
|
+
* a transparent-text `<textarea>` with identical metrics. To keep the two
|
|
1008
|
+
* layers perfectly aligned we NEVER change the user's text — every token is
|
|
1009
|
+
* only wrapped in a colored `<span class="md-*">`, so each line keeps its
|
|
1010
|
+
* exact characters and column widths. Leading whitespace (indentation) is
|
|
1011
|
+
* preserved as-is so list/heading/quote alignment survives.
|
|
1012
|
+
*
|
|
1013
|
+
* Coverage is deliberately pragmatic (a scratch-note editor, not CommonMark):
|
|
1014
|
+
* ATX headings, fenced code blocks, blockquotes, bullet + ordered + task
|
|
1015
|
+
* lists, horizontal rules, inline code, bold, italic, strikethrough, links
|
|
1016
|
+
* and images. HTML is escaped first, so the output is always safe to set as
|
|
1017
|
+
* `innerHTML` (no raw HTML from the user is ever executed).
|
|
1018
|
+
*
|
|
1019
|
+
* @module dsh-tiddlywiki/client/markdown
|
|
1020
|
+
*/
|
|
1021
|
+
/** Escape a raw source fragment for safe insertion into the overlay HTML. */
|
|
1022
|
+
function escapeHtml(value) {
|
|
1023
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
1024
|
+
}
|
|
1025
|
+
/**
|
|
1026
|
+
* Inline token highlighting on ALREADY-ESCAPED text. Inline code spans are
|
|
1027
|
+
* first pulled out into placeholders so later passes (links/bold/italic) can
|
|
1028
|
+
* never reach inside them, then restored.
|
|
1029
|
+
*/
|
|
1030
|
+
function highlightInline(escaped) {
|
|
1031
|
+
const codeTokens = [];
|
|
1032
|
+
let s = escaped.replace(/(`+)([^`]+?)\1/g, (_all, t, code) => {
|
|
1033
|
+
codeTokens.push(`<span class="md-code">${t}${code}${t}</span>`);
|
|
1034
|
+
return `\u0000${codeTokens.length - 1}\u0000`;
|
|
1035
|
+
});
|
|
1036
|
+
s = s.replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, (_a, alt, url) => `<span class="md-image"></span>`);
|
|
1037
|
+
s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_a, text, url) => `<span class="md-link">[${text}](</span><span class="md-url">${url}</span><span class="md-link">)</span>`);
|
|
1038
|
+
const boldTokens = [];
|
|
1039
|
+
s = s.replace(/(\*\*|__)([^*_]+?)\1/g, (_a, d, text) => {
|
|
1040
|
+
boldTokens.push(`<span class="md-bold">${d}${text}${d}</span>`);
|
|
1041
|
+
return `\u0001${boldTokens.length - 1}\u0001`;
|
|
1042
|
+
});
|
|
1043
|
+
s = s.replace(/(\*|_)([^*_]+?)\1/g, "<span class=\"md-italic\">$1$2$1</span>");
|
|
1044
|
+
s = s.replace(/~~([^~]+?)~~/g, "<span class=\"md-strike\">~~$1~~</span>");
|
|
1045
|
+
return s.replace(/\u0001(\d+)\u0001/g, (_a, i) => boldTokens[Number(i)] ?? "").replace(/\u0000(\d+)\u0000/g, (_a, i) => codeTokens[Number(i)] ?? "");
|
|
1046
|
+
}
|
|
1047
|
+
/**
|
|
1048
|
+
* Match and return groups as a plain string array ('' for absent groups), or
|
|
1049
|
+
* null when the pattern does not match. Avoids noUncheckedIndexedAccess
|
|
1050
|
+
* friction on RegExpMatchArray indexing.
|
|
1051
|
+
*/
|
|
1052
|
+
function groups(re, value) {
|
|
1053
|
+
const m = value.match(re);
|
|
1054
|
+
if (m === null) return null;
|
|
1055
|
+
return Array.from(m, (g) => g ?? "");
|
|
1056
|
+
}
|
|
1057
|
+
/** Highlight one non-fence line (leading whitespace preserved verbatim). */
|
|
1058
|
+
function highlightLine(raw) {
|
|
1059
|
+
const leadMatch = groups(/^(\s*)(.*)$/, raw);
|
|
1060
|
+
const lead = leadMatch === null ? "" : leadMatch[1];
|
|
1061
|
+
const body = leadMatch === null ? raw : leadMatch[2];
|
|
1062
|
+
if (body.length === 0) return raw;
|
|
1063
|
+
const heading = groups(/^(#{1,6})(\s+)(.*)$/, body);
|
|
1064
|
+
if (heading !== null) return `${lead}<span class="md-heading">${escapeHtml(heading[1])}${escapeHtml(heading[2])}</span>${highlightInline(escapeHtml(heading[3]))}`;
|
|
1065
|
+
if (/^(-{3,}|\*{3,}|_{3,})\s*$/.test(body)) return `${lead}<span class="md-hr">${escapeHtml(body)}</span>`;
|
|
1066
|
+
const quote = groups(/^(>)( ?)(.*)$/, body);
|
|
1067
|
+
if (quote !== null) return `${lead}<span class="md-quote">${escapeHtml(quote[1])}</span>${escapeHtml(quote[2])}${highlightInline(escapeHtml(quote[3]))}`;
|
|
1068
|
+
const task = groups(/^([-*+])(\s+)(\[[ xX]\])(\s+)(.*)$/, body);
|
|
1069
|
+
if (task !== null) {
|
|
1070
|
+
const checked = /\[[xX]\]/.test(task[3]);
|
|
1071
|
+
return `${lead}<span class="md-bullet">${escapeHtml(task[1])}</span>${escapeHtml(task[2])}<span class="md-task${checked ? " md-task-checked" : ""}">${escapeHtml(task[3])}</span>${escapeHtml(task[4])}${highlightInline(escapeHtml(task[5]))}`;
|
|
1072
|
+
}
|
|
1073
|
+
const bullet = groups(/^([-*+])(\s+)(.*)$/, body);
|
|
1074
|
+
if (bullet !== null) return `${lead}<span class="md-bullet">${escapeHtml(bullet[1])}</span>${escapeHtml(bullet[2])}${highlightInline(escapeHtml(bullet[3]))}`;
|
|
1075
|
+
const ordered = groups(/^(\d+)([.)])(\s+)(.*)$/, body);
|
|
1076
|
+
if (ordered !== null) return `${lead}<span class="md-number">${escapeHtml(ordered[1])}${escapeHtml(ordered[2])}</span>${escapeHtml(ordered[3])}${highlightInline(escapeHtml(ordered[4]))}`;
|
|
1077
|
+
return `${lead}${highlightInline(escapeHtml(body))}`;
|
|
1078
|
+
}
|
|
1079
|
+
/**
|
|
1080
|
+
* Highlight full Markdown source into safe `<span>`-wrapped HTML. Line count
|
|
1081
|
+
* and per-line text (including whitespace) are preserved exactly.
|
|
1082
|
+
*/
|
|
1083
|
+
function highlightMarkdown(src) {
|
|
1084
|
+
const lines = src.split("\n");
|
|
1085
|
+
const out = [];
|
|
1086
|
+
let inFence = false;
|
|
1087
|
+
for (const rawLine of lines) {
|
|
1088
|
+
if (rawLine.match(/^\s*(```|~~~)\s*([\w-]*)\s*$/) !== null) {
|
|
1089
|
+
inFence = !inFence;
|
|
1090
|
+
out.push(`<span class="md-fence">${escapeHtml(rawLine)}</span>`);
|
|
1091
|
+
continue;
|
|
1092
|
+
}
|
|
1093
|
+
if (inFence) {
|
|
1094
|
+
out.push(`<span class="md-code-block">${escapeHtml(rawLine)}</span>`);
|
|
1095
|
+
continue;
|
|
1096
|
+
}
|
|
1097
|
+
out.push(highlightLine(rawLine));
|
|
1098
|
+
}
|
|
1099
|
+
return out.join("\n");
|
|
1100
|
+
}
|
|
1101
|
+
//#endregion
|
|
906
1102
|
//#region src/client/note-widget.ts
|
|
907
1103
|
/**
|
|
908
1104
|
* Floating quick-note widget (design doc §12, D6/D7) — bottom-right, fixed,
|
|
@@ -920,18 +1116,29 @@ function ensurePopup() {
|
|
|
920
1116
|
* (GET /dsh-tiddlywiki/tags). Enter/`,` commits the typed value; Backspace on
|
|
921
1117
|
* an empty field removes the last chip; the dropdown filters on typing.
|
|
922
1118
|
*
|
|
1119
|
+
* Markdown editing: a "highlighted textarea" overlay (a `<pre>` rendered
|
|
1120
|
+
* behind a transparent-text `<textarea>`, see markdown.ts) so Markdown is
|
|
1121
|
+
* syntax-highlighted as you type — no dependencies, perfect alignment.
|
|
1122
|
+
*
|
|
1123
|
+
* File upload: the body accepts files via the 📎 button or drag-and-drop;
|
|
1124
|
+
* each file is POSTed raw to /dsh-tiddlywiki/upload (saved under the wiki's
|
|
1125
|
+
* `files/` folder, git-tracked and served at `/files/<name>`) and a Markdown
|
|
1126
|
+
* image/link line is inserted at the caret.
|
|
1127
|
+
*
|
|
923
1128
|
* @module dsh-tiddlywiki/client/note-widget
|
|
924
1129
|
*/
|
|
925
1130
|
const NOTE_ENDPOINT = "/dsh-tiddlywiki/note";
|
|
926
1131
|
const EDIT_ENDPOINT = "/dsh-tiddlywiki/edit";
|
|
927
|
-
const STATUS_ENDPOINT = "/dsh-tiddlywiki/status";
|
|
1132
|
+
const STATUS_ENDPOINT$1 = "/dsh-tiddlywiki/status";
|
|
928
1133
|
const TAGS_ENDPOINT = "/dsh-tiddlywiki/tags";
|
|
929
|
-
|
|
1134
|
+
const UPLOAD_ENDPOINT = "/dsh-tiddlywiki/upload";
|
|
1135
|
+
const MAX_UPLOAD_BYTES = 64 * 1024 * 1024;
|
|
1136
|
+
function pad$1(n) {
|
|
930
1137
|
return n < 10 ? `0${n}` : String(n);
|
|
931
1138
|
}
|
|
932
1139
|
/** Default note title: `YYYY-MM-DD HH:mm`. */
|
|
933
1140
|
function timestampTitle(date = /* @__PURE__ */ new Date()) {
|
|
934
|
-
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
|
1141
|
+
return `${date.getFullYear()}-${pad$1(date.getMonth() + 1)}-${pad$1(date.getDate())} ${pad$1(date.getHours())}:${pad$1(date.getMinutes())}`;
|
|
935
1142
|
}
|
|
936
1143
|
async function fetchUiOptions() {
|
|
937
1144
|
const fallback = {
|
|
@@ -939,7 +1146,7 @@ async function fetchUiOptions() {
|
|
|
939
1146
|
defaultTag: "inbox"
|
|
940
1147
|
};
|
|
941
1148
|
try {
|
|
942
|
-
const res = await fetch(STATUS_ENDPOINT, { signal: AbortSignal.timeout(5e3) });
|
|
1149
|
+
const res = await fetch(STATUS_ENDPOINT$1, { signal: AbortSignal.timeout(5e3) });
|
|
943
1150
|
if (!res.ok) return fallback;
|
|
944
1151
|
const payload = await res.json();
|
|
945
1152
|
return {
|
|
@@ -1062,6 +1269,81 @@ function buildTagEditor() {
|
|
|
1062
1269
|
}
|
|
1063
1270
|
};
|
|
1064
1271
|
}
|
|
1272
|
+
/**
|
|
1273
|
+
* Markdown editor: a `<pre>` overlay (highlighted by markdown.ts) rendered
|
|
1274
|
+
* behind a transparent-text `<textarea>` with identical metrics. Scroll is
|
|
1275
|
+
* mirrored; on every input the overlay is re-rendered. The text layer stays
|
|
1276
|
+
* exactly aligned with the highlight layer because the highlighter never
|
|
1277
|
+
* changes the user's characters.
|
|
1278
|
+
*/
|
|
1279
|
+
function buildEditor() {
|
|
1280
|
+
const wrap = document.createElement("div");
|
|
1281
|
+
wrap.className = "dsh-tw-note-editor";
|
|
1282
|
+
const hl = document.createElement("pre");
|
|
1283
|
+
hl.className = "dsh-tw-note-hl";
|
|
1284
|
+
hl.setAttribute("aria-hidden", "true");
|
|
1285
|
+
const textarea = document.createElement("textarea");
|
|
1286
|
+
textarea.className = "dsh-tw-note-text";
|
|
1287
|
+
textarea.placeholder = "写点东西… Markdown 高亮,可 📎/拖入文件\nCtrl+Enter 保存";
|
|
1288
|
+
wrap.append(hl, textarea);
|
|
1289
|
+
const render = () => {
|
|
1290
|
+
hl.innerHTML = `${highlightMarkdown(textarea.value)}\n`;
|
|
1291
|
+
hl.scrollTop = textarea.scrollTop;
|
|
1292
|
+
hl.scrollLeft = textarea.scrollLeft;
|
|
1293
|
+
};
|
|
1294
|
+
const syncScroll = () => {
|
|
1295
|
+
hl.scrollTop = textarea.scrollTop;
|
|
1296
|
+
hl.scrollLeft = textarea.scrollLeft;
|
|
1297
|
+
};
|
|
1298
|
+
textarea.addEventListener("input", render);
|
|
1299
|
+
textarea.addEventListener("scroll", syncScroll);
|
|
1300
|
+
render();
|
|
1301
|
+
return {
|
|
1302
|
+
el: wrap,
|
|
1303
|
+
textarea,
|
|
1304
|
+
render,
|
|
1305
|
+
focus: () => textarea.focus()
|
|
1306
|
+
};
|
|
1307
|
+
}
|
|
1308
|
+
/**
|
|
1309
|
+
* Upload one file to the wiki (raw body, name in ?name=), then insert a
|
|
1310
|
+
* Markdown image/link line at the caret of the given editor.
|
|
1311
|
+
*/
|
|
1312
|
+
async function uploadInto(file, editor) {
|
|
1313
|
+
if (file.size > MAX_UPLOAD_BYTES) {
|
|
1314
|
+
toast(`文件过大(≤ ${Math.round(MAX_UPLOAD_BYTES / 1024 / 1024)}MB)`);
|
|
1315
|
+
return;
|
|
1316
|
+
}
|
|
1317
|
+
try {
|
|
1318
|
+
const res = await fetch(`${UPLOAD_ENDPOINT}?name=${encodeURIComponent(file.name)}`, {
|
|
1319
|
+
method: "POST",
|
|
1320
|
+
headers: { "content-type": file.type || "application/octet-stream" },
|
|
1321
|
+
body: file,
|
|
1322
|
+
signal: AbortSignal.timeout(12e4)
|
|
1323
|
+
});
|
|
1324
|
+
const payload = await res.json().catch(() => null);
|
|
1325
|
+
if (!res.ok || payload?.ok !== true) {
|
|
1326
|
+
toast(`上传失败:${payload?.error ?? `HTTP ${res.status}`}`);
|
|
1327
|
+
return;
|
|
1328
|
+
}
|
|
1329
|
+
const name = payload.name ?? file.name;
|
|
1330
|
+
const markdown = file.type.startsWith("image/") ? `` : `[${name}](${payload.url})`;
|
|
1331
|
+
const ta = editor.textarea;
|
|
1332
|
+
const start = ta.selectionStart ?? ta.value.length;
|
|
1333
|
+
const end = ta.selectionEnd ?? start;
|
|
1334
|
+
const before = ta.value.slice(0, start);
|
|
1335
|
+
const after = ta.value.slice(end);
|
|
1336
|
+
const insert = `${start > 0 && !before.endsWith("\n") ? "\n" : ""}${markdown}\n`;
|
|
1337
|
+
ta.value = `${before}${insert}${after}`;
|
|
1338
|
+
const caret = (before + insert).length;
|
|
1339
|
+
ta.setSelectionRange(caret, caret);
|
|
1340
|
+
ta.focus();
|
|
1341
|
+
editor.render();
|
|
1342
|
+
toast(`已上传「${name}」并插入链接`);
|
|
1343
|
+
} catch (err) {
|
|
1344
|
+
toast(`上传失败:${err instanceof Error ? err.message : String(err)}`);
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1065
1347
|
/** Build the whole widget DOM and wire it up (returns the appended root). */
|
|
1066
1348
|
function buildWidget() {
|
|
1067
1349
|
const root = document.createElement("div");
|
|
@@ -1087,14 +1369,56 @@ function buildWidget() {
|
|
|
1087
1369
|
titleInput.placeholder = "标题(默认时间戳)";
|
|
1088
1370
|
const tagEditor = buildTagEditor();
|
|
1089
1371
|
fields.append(titleInput, tagEditor.el);
|
|
1090
|
-
const
|
|
1091
|
-
textarea
|
|
1092
|
-
|
|
1372
|
+
const editor = buildEditor();
|
|
1373
|
+
const textarea = editor.textarea;
|
|
1374
|
+
const uploadBtn = document.createElement("button");
|
|
1375
|
+
uploadBtn.type = "button";
|
|
1376
|
+
uploadBtn.className = "dsh-tw-note-upload";
|
|
1377
|
+
uploadBtn.title = "上传文件到 wiki 并插入 Markdown 链接(也可直接拖入编辑器)";
|
|
1378
|
+
uploadBtn.textContent = "📎 上传";
|
|
1379
|
+
const fileInput = document.createElement("input");
|
|
1380
|
+
fileInput.type = "file";
|
|
1381
|
+
fileInput.multiple = true;
|
|
1382
|
+
fileInput.hidden = true;
|
|
1383
|
+
uploadBtn.addEventListener("click", () => {
|
|
1384
|
+
fileInput.click();
|
|
1385
|
+
});
|
|
1386
|
+
fileInput.addEventListener("change", () => {
|
|
1387
|
+
for (const file of Array.from(fileInput.files ?? [])) uploadInto(file, editor);
|
|
1388
|
+
fileInput.value = "";
|
|
1389
|
+
});
|
|
1390
|
+
let dragDepth = 0;
|
|
1391
|
+
editor.el.addEventListener("dragenter", (event) => {
|
|
1392
|
+
event.preventDefault();
|
|
1393
|
+
dragDepth++;
|
|
1394
|
+
editor.el.classList.add("dsh-tw-note-drop");
|
|
1395
|
+
});
|
|
1396
|
+
editor.el.addEventListener("dragover", (event) => {
|
|
1397
|
+
event.preventDefault();
|
|
1398
|
+
});
|
|
1399
|
+
editor.el.addEventListener("dragleave", (event) => {
|
|
1400
|
+
event.preventDefault();
|
|
1401
|
+
dragDepth = Math.max(0, dragDepth - 1);
|
|
1402
|
+
if (dragDepth === 0) editor.el.classList.remove("dsh-tw-note-drop");
|
|
1403
|
+
});
|
|
1404
|
+
editor.el.addEventListener("drop", (event) => {
|
|
1405
|
+
event.preventDefault();
|
|
1406
|
+
dragDepth = 0;
|
|
1407
|
+
editor.el.classList.remove("dsh-tw-note-drop");
|
|
1408
|
+
const files = event.dataTransfer?.files;
|
|
1409
|
+
if (files === void 0 || files.length === 0) return;
|
|
1410
|
+
for (const file of Array.from(files)) uploadInto(file, editor);
|
|
1411
|
+
});
|
|
1093
1412
|
const foot = document.createElement("div");
|
|
1094
1413
|
foot.className = "dsh-tw-note-foot";
|
|
1414
|
+
const footLeft = document.createElement("div");
|
|
1415
|
+
footLeft.className = "dsh-tw-note-foot-left";
|
|
1095
1416
|
const hint = document.createElement("span");
|
|
1096
1417
|
hint.className = "dsh-tw-note-hint";
|
|
1097
1418
|
hint.textContent = "Ctrl+Enter";
|
|
1419
|
+
footLeft.append(uploadBtn, hint);
|
|
1420
|
+
const footRight = document.createElement("div");
|
|
1421
|
+
footRight.className = "dsh-tw-note-foot-right";
|
|
1098
1422
|
const edit = document.createElement("button");
|
|
1099
1423
|
edit.type = "button";
|
|
1100
1424
|
edit.className = "dsh-tw-note-edit";
|
|
@@ -1104,8 +1428,9 @@ function buildWidget() {
|
|
|
1104
1428
|
save.type = "button";
|
|
1105
1429
|
save.className = "dsh-tw-note-save";
|
|
1106
1430
|
save.textContent = "保存";
|
|
1107
|
-
|
|
1108
|
-
|
|
1431
|
+
footRight.append(edit, save);
|
|
1432
|
+
foot.append(footLeft, footRight);
|
|
1433
|
+
card.append(head, fields, editor.el, foot);
|
|
1109
1434
|
const toggle = document.createElement("button");
|
|
1110
1435
|
toggle.type = "button";
|
|
1111
1436
|
toggle.className = "dsh-tw-note-toggle";
|
|
@@ -1121,7 +1446,7 @@ function buildWidget() {
|
|
|
1121
1446
|
card.hidden = false;
|
|
1122
1447
|
opened = true;
|
|
1123
1448
|
resetTitle();
|
|
1124
|
-
|
|
1449
|
+
editor.focus();
|
|
1125
1450
|
defaultTag = (await fetchUiOptions()).defaultTag;
|
|
1126
1451
|
tagEditor.setDefault(defaultTag);
|
|
1127
1452
|
};
|
|
@@ -1158,6 +1483,7 @@ function buildWidget() {
|
|
|
1158
1483
|
return;
|
|
1159
1484
|
}
|
|
1160
1485
|
textarea.value = "";
|
|
1486
|
+
editor.render();
|
|
1161
1487
|
resetTitle();
|
|
1162
1488
|
toast(`已保存「${payload.title ?? titleInput.value}」`);
|
|
1163
1489
|
close();
|
|
@@ -1239,6 +1565,161 @@ function mountNoteWidget() {
|
|
|
1239
1565
|
return disposer;
|
|
1240
1566
|
}
|
|
1241
1567
|
//#endregion
|
|
1568
|
+
//#region src/client/sync-button.ts
|
|
1569
|
+
/**
|
|
1570
|
+
* Floating "sync" button (bottom-right) — one click pulls + commits + pushes
|
|
1571
|
+
* the wiki git repository and reports the result, with a live status dot so
|
|
1572
|
+
* the human always sees whether the knowledge base is in sync.
|
|
1573
|
+
*
|
|
1574
|
+
* - honors `ui.showSyncButton`: the button is mounted async and never created
|
|
1575
|
+
* when the option is off (settings page toggle);
|
|
1576
|
+
* - polls /dsh-tiddlywiki/status every 30s for the git state;
|
|
1577
|
+
* - clicking POSTs /dsh-tiddlywiki/sync (pull → commit → push) and toasts the
|
|
1578
|
+
* outcome, then refreshes the dot immediately.
|
|
1579
|
+
*
|
|
1580
|
+
* Status dot mapping (git summary from /status):
|
|
1581
|
+
* offline → gray (no repo / service unreachable)
|
|
1582
|
+
* dirty → amber (uncommitted changes)
|
|
1583
|
+
* behind → red (remote has commits we don't — pull will rebase)
|
|
1584
|
+
* clean → green (worktree clean)
|
|
1585
|
+
*
|
|
1586
|
+
* @module dsh-tiddlywiki/client/sync-button
|
|
1587
|
+
*/
|
|
1588
|
+
const STATUS_ENDPOINT = "/dsh-tiddlywiki/status";
|
|
1589
|
+
const SYNC_ENDPOINT$1 = "/dsh-tiddlywiki/sync";
|
|
1590
|
+
const POLL_MS = 3e4;
|
|
1591
|
+
/** Inline sync icon (circular arrows, stroke-based, theme-colored). */
|
|
1592
|
+
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>";
|
|
1593
|
+
function pad(n) {
|
|
1594
|
+
return n < 10 ? `0${n}` : String(n);
|
|
1595
|
+
}
|
|
1596
|
+
/** Compact local time for the tooltip (e.g. "10:32"). */
|
|
1597
|
+
function clock(date) {
|
|
1598
|
+
return `${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
|
1599
|
+
}
|
|
1600
|
+
async function fetchStatus() {
|
|
1601
|
+
try {
|
|
1602
|
+
const res = await fetch(STATUS_ENDPOINT, { signal: AbortSignal.timeout(8e3) });
|
|
1603
|
+
if (!res.ok) return null;
|
|
1604
|
+
return await res.json();
|
|
1605
|
+
} catch {
|
|
1606
|
+
return null;
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1609
|
+
/**
|
|
1610
|
+
* Mount the floating sync button. Fetches /status first: when
|
|
1611
|
+
* `ui.showSyncButton` is off the button is never created. Returns a disposer.
|
|
1612
|
+
*/
|
|
1613
|
+
function mountSyncButton() {
|
|
1614
|
+
let disposed = false;
|
|
1615
|
+
let root;
|
|
1616
|
+
let dot;
|
|
1617
|
+
let label;
|
|
1618
|
+
let lastSync;
|
|
1619
|
+
let timer;
|
|
1620
|
+
const build = () => {
|
|
1621
|
+
const btn = document.createElement("button");
|
|
1622
|
+
btn.type = "button";
|
|
1623
|
+
btn.className = "dsh-tw-sync";
|
|
1624
|
+
btn.title = "同步知识库(pull → commit → push)";
|
|
1625
|
+
const icon = document.createElement("span");
|
|
1626
|
+
icon.className = "dsh-tw-sync-icon";
|
|
1627
|
+
icon.innerHTML = SYNC_ICON;
|
|
1628
|
+
dot = document.createElement("span");
|
|
1629
|
+
dot.className = "dsh-tw-sync-dot";
|
|
1630
|
+
dot.dataset.state = "offline";
|
|
1631
|
+
label = document.createElement("span");
|
|
1632
|
+
label.className = "dsh-tw-sync-label";
|
|
1633
|
+
label.textContent = "同步";
|
|
1634
|
+
btn.append(icon, label, dot);
|
|
1635
|
+
btn.addEventListener("click", () => {
|
|
1636
|
+
doSync();
|
|
1637
|
+
});
|
|
1638
|
+
document.body.append(btn);
|
|
1639
|
+
return btn;
|
|
1640
|
+
};
|
|
1641
|
+
/** Map a git summary onto the dot + label + tooltip. */
|
|
1642
|
+
const applyStatus = (payload) => {
|
|
1643
|
+
if (root === void 0 || dot === void 0 || label === void 0) return;
|
|
1644
|
+
const git = payload?.git;
|
|
1645
|
+
const bits = ["同步知识库"];
|
|
1646
|
+
let state = "offline";
|
|
1647
|
+
let word = "离线";
|
|
1648
|
+
if (git === null || git === void 0 || git.exists !== true) bits.push("git 仓库不可用");
|
|
1649
|
+
else {
|
|
1650
|
+
bits.push(`分支 ${git.branch ?? "?"}`);
|
|
1651
|
+
if (typeof git.lastCommit === "string") bits.push(git.lastCommit);
|
|
1652
|
+
const behind = typeof git.behind === "number" ? git.behind : 0;
|
|
1653
|
+
const ahead = typeof git.ahead === "number" ? git.ahead : 0;
|
|
1654
|
+
if (ahead > 0) bits.push(`领先 ${ahead}`);
|
|
1655
|
+
if (behind > 0) bits.push(`落后 ${behind}`);
|
|
1656
|
+
if (git.dirty === true) {
|
|
1657
|
+
state = "dirty";
|
|
1658
|
+
word = "待提交";
|
|
1659
|
+
bits.push(`有 ${git.dirtyFiles?.length ?? 0} 个未提交改动`);
|
|
1660
|
+
} else if (behind > 0) {
|
|
1661
|
+
state = "behind";
|
|
1662
|
+
word = "可更新";
|
|
1663
|
+
} else {
|
|
1664
|
+
state = "clean";
|
|
1665
|
+
word = "已同步";
|
|
1666
|
+
}
|
|
1667
|
+
}
|
|
1668
|
+
if (lastSync !== void 0) bits.push(`上次同步 ${clock(lastSync)}`);
|
|
1669
|
+
root.title = bits.join(" · ");
|
|
1670
|
+
dot.dataset.state = state;
|
|
1671
|
+
label.textContent = word;
|
|
1672
|
+
};
|
|
1673
|
+
const doSync = async () => {
|
|
1674
|
+
if (root === void 0) return;
|
|
1675
|
+
root.disabled = true;
|
|
1676
|
+
root.classList.add("dsh-tw-sync-spin");
|
|
1677
|
+
if (label !== void 0) label.textContent = "同步中…";
|
|
1678
|
+
if (dot !== void 0) dot.dataset.state = "syncing";
|
|
1679
|
+
try {
|
|
1680
|
+
const res = await fetch(SYNC_ENDPOINT$1, {
|
|
1681
|
+
method: "POST",
|
|
1682
|
+
signal: AbortSignal.timeout(12e4)
|
|
1683
|
+
});
|
|
1684
|
+
const payload = await res.json().catch(() => null);
|
|
1685
|
+
lastSync = /* @__PURE__ */ new Date();
|
|
1686
|
+
if (payload === null || payload.ok !== true) toast(`同步失败:${payload?.error ?? payload?.message ?? `HTTP ${res.status}`}`);
|
|
1687
|
+
else {
|
|
1688
|
+
let detail = "";
|
|
1689
|
+
if (payload.push && payload.push !== "nothing to commit") detail = `(${payload.push})`;
|
|
1690
|
+
if (payload.changed === true) {
|
|
1691
|
+
detail += payload.restarted === true ? ",TW 已重启" : ",TW 未自动重启";
|
|
1692
|
+
if (payload.restartError) detail += `(${payload.restartError})`;
|
|
1693
|
+
}
|
|
1694
|
+
toast(`同步完成:${payload.message ?? "OK"}${detail}`);
|
|
1695
|
+
}
|
|
1696
|
+
applyStatus(await fetchStatus());
|
|
1697
|
+
} catch (err) {
|
|
1698
|
+
toast(`同步失败:${err instanceof Error ? err.message : String(err)}`);
|
|
1699
|
+
} finally {
|
|
1700
|
+
root.disabled = false;
|
|
1701
|
+
root.classList.remove("dsh-tw-sync-spin");
|
|
1702
|
+
}
|
|
1703
|
+
};
|
|
1704
|
+
const poll = async () => {
|
|
1705
|
+
applyStatus(await fetchStatus());
|
|
1706
|
+
};
|
|
1707
|
+
(async () => {
|
|
1708
|
+
const payload = await fetchStatus();
|
|
1709
|
+
if (disposed || payload?.ui?.showSyncButton === false) return;
|
|
1710
|
+
root = build();
|
|
1711
|
+
applyStatus(payload);
|
|
1712
|
+
timer = window.setInterval(() => {
|
|
1713
|
+
poll();
|
|
1714
|
+
}, POLL_MS);
|
|
1715
|
+
})();
|
|
1716
|
+
return () => {
|
|
1717
|
+
disposed = true;
|
|
1718
|
+
if (timer !== void 0) window.clearInterval(timer);
|
|
1719
|
+
root?.remove();
|
|
1720
|
+
};
|
|
1721
|
+
}
|
|
1722
|
+
//#endregion
|
|
1242
1723
|
//#region src/client/settings-page.ts
|
|
1243
1724
|
/**
|
|
1244
1725
|
* Settings-page half (design doc §13, config panel): a pure-DOM page mounted
|
|
@@ -1262,6 +1743,7 @@ const STATE_ENDPOINT = "/dsh-tiddlywiki/admin/state";
|
|
|
1262
1743
|
const INFO_ENDPOINT = "/dsh-tiddlywiki/admin/info";
|
|
1263
1744
|
const CONFIG_ENDPOINT = "/dsh-tiddlywiki/admin/config";
|
|
1264
1745
|
const RESTART_ENDPOINT = "/dsh-tiddlywiki/admin/restart";
|
|
1746
|
+
const SYNC_ENDPOINT = "/dsh-tiddlywiki/sync";
|
|
1265
1747
|
function make(tag, className, text) {
|
|
1266
1748
|
const node = document.createElement(tag);
|
|
1267
1749
|
if (className !== void 0) node.className = className;
|
|
@@ -1328,6 +1810,37 @@ function renderStatus(row, state, refresh) {
|
|
|
1328
1810
|
state.git?.lastCommit !== void 0 ? state.git.lastCommit : "",
|
|
1329
1811
|
state.git?.dirty === true ? "有未提交改动" : ""
|
|
1330
1812
|
].filter(Boolean).join(" · "));
|
|
1813
|
+
const sync = make("button", "dsh-tw-settings-btn", "同步");
|
|
1814
|
+
sync.type = "button";
|
|
1815
|
+
sync.title = "git 同步(pull → commit → push)";
|
|
1816
|
+
sync.addEventListener("click", () => {
|
|
1817
|
+
sync.disabled = true;
|
|
1818
|
+
sync.textContent = "同步中…";
|
|
1819
|
+
(async () => {
|
|
1820
|
+
try {
|
|
1821
|
+
const res = await fetch(SYNC_ENDPOINT, {
|
|
1822
|
+
method: "POST",
|
|
1823
|
+
signal: AbortSignal.timeout(12e4)
|
|
1824
|
+
});
|
|
1825
|
+
const payload = await res.json().catch(() => null);
|
|
1826
|
+
if (!res.ok || payload?.ok !== true) toast(`同步失败:${payload?.error ?? payload?.message ?? `HTTP ${res.status}`}`);
|
|
1827
|
+
else {
|
|
1828
|
+
let detail = "";
|
|
1829
|
+
if (payload.changed === true) {
|
|
1830
|
+
detail += payload.restarted === true ? ",TW 已重启" : ",TW 未自动重启";
|
|
1831
|
+
if (payload.restartError) detail += `(${payload.restartError})`;
|
|
1832
|
+
}
|
|
1833
|
+
toast(`同步完成:${payload.message ?? "OK"}${detail}`);
|
|
1834
|
+
}
|
|
1835
|
+
} catch (err) {
|
|
1836
|
+
toast(`同步失败:${err instanceof Error ? err.message : String(err)}`);
|
|
1837
|
+
} finally {
|
|
1838
|
+
sync.disabled = false;
|
|
1839
|
+
sync.textContent = "同步";
|
|
1840
|
+
refresh();
|
|
1841
|
+
}
|
|
1842
|
+
})();
|
|
1843
|
+
});
|
|
1331
1844
|
const restart = make("button", "dsh-tw-settings-btn", "重启 TW");
|
|
1332
1845
|
restart.type = "button";
|
|
1333
1846
|
restart.addEventListener("click", () => {
|
|
@@ -1346,7 +1859,7 @@ function renderStatus(row, state, refresh) {
|
|
|
1346
1859
|
}
|
|
1347
1860
|
})();
|
|
1348
1861
|
});
|
|
1349
|
-
row.append(chip, label, restart);
|
|
1862
|
+
row.append(chip, label, sync, restart);
|
|
1350
1863
|
}
|
|
1351
1864
|
/** Config section: fields bound to effective config, changed-only save. */
|
|
1352
1865
|
function renderConfigSection(body, config, refresh) {
|
|
@@ -1407,6 +1920,7 @@ function renderConfigSection(body, config, refresh) {
|
|
|
1407
1920
|
textField("git.branch", "git 分支", typeof git.branch === "string" ? git.branch : "main");
|
|
1408
1921
|
checkField("ui.showQuickNote", "显示「快速笔记」悬浮按钮", ui.showQuickNote !== false);
|
|
1409
1922
|
checkField("ui.showPanelStatus", "显示 TW 面板右下角「状态/重载」悬浮按钮", ui.showPanelStatus !== false);
|
|
1923
|
+
checkField("ui.showSyncButton", "显示右下角「同步」悬浮按钮", ui.showSyncButton !== false);
|
|
1410
1924
|
const save = make("button", "dsh-tw-settings-btn dsh-tw-settings-primary", "保存配置");
|
|
1411
1925
|
save.type = "button";
|
|
1412
1926
|
save.addEventListener("click", () => {
|
|
@@ -1643,6 +2157,7 @@ function apply(ctx) {
|
|
|
1643
2157
|
disposers.push(mountSidebarEntry(state));
|
|
1644
2158
|
disposers.push(mountPanel(state));
|
|
1645
2159
|
disposers.push(mountNoteWidget());
|
|
2160
|
+
disposers.push(mountSyncButton());
|
|
1646
2161
|
disposers.push(disposeEditorPopup);
|
|
1647
2162
|
} catch (error) {
|
|
1648
2163
|
console.error("[dsh-tiddlywiki] mount failed:", error);
|