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