dsh-webdav-sync 1.0.1 → 1.0.3

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/lib/client.js CHANGED
@@ -49,9 +49,40 @@ window.__ModuleLoader__.load({
49
49
  30,
50
50
  60
51
51
  ];
52
+ /** Loopback port the forwarder listens on when nothing else was asked for. */
53
+ const RELAY_DEFAULT_PORT = 3426;
54
+ /**
55
+ * The address a client is given: loopback, the port, and the server's own path.
56
+ *
57
+ * The path is carried over so that the rest of the address means the same thing
58
+ * to the client as the real server's address did — a client that joins its own
59
+ * paths onto the host alone, and one that keeps the base path it was given, both
60
+ * end up asking for the same place. Pure form: the same port and target always
61
+ * give the same address.
62
+ */
63
+ function relayUrl(port, basePath) {
64
+ return `http://127.0.0.1:${port}${basePath}`;
65
+ }
66
+ /**
67
+ * The path part of a WebDAV address, which the forwarded address carries over.
68
+ *
69
+ * `https://host/dav/` gives `/dav`; an address with no path, one that cannot be
70
+ * read at all, or one that is not http(s) gives ''. Both halves work out the
71
+ * forwarded address from this, so the line the card shows is the address the host
72
+ * would hand out for the same draft rather than a second opinion about it.
73
+ */
74
+ function relayBasePath(url) {
75
+ try {
76
+ const parsed = new URL(url);
77
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return "";
78
+ return parsed.pathname.replace(/\/+$/, "");
79
+ } catch {
80
+ return "";
81
+ }
82
+ }
52
83
  //#endregion
53
84
  //#region \0webdav-sync-css:/home/runner/work/dsh-webdav/dsh-webdav/src/client/WebdavSyncCard.module.css?inline
54
- const css = ".summary_318697645 {\n display: flex;\n align-items: center;\n gap: 8px;\n min-width: 0;\n font-size: 13px;\n color: var(--dsw-alias-label-secondary);\n}\n\n.summaryText_318697645 {\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n.page_318697645 {\n display: flex;\n flex-direction: column;\n gap: 20px;\n padding: 4px 0 8px;\n font-size: 13px;\n color: var(--dsw-alias-label-primary);\n}\n\n.legend_318697645 {\n display: flex;\n align-items: baseline;\n gap: 8px;\n margin: 0 0 8px;\n font-size: 12px;\n font-weight: 600;\n letter-spacing: 0.02em;\n color: var(--dsw-alias-label-secondary);\n}\n\n.hint_318697645 {\n margin: 0;\n font-size: 12px;\n line-height: 1.6;\n color: var(--dsw-alias-label-tertiary);\n}\n\n.field_318697645 {\n display: flex;\n flex-direction: column;\n gap: 4px;\n margin-bottom: 10px;\n}\n\n.fieldLabel_318697645 {\n font-size: 12px;\n color: var(--dsw-alias-label-secondary);\n}\n\n/*\n * The line of explanation belonging to one field: it sits between that field's\n * label and its input, so a reader gets the rule before typing, and the gap\n * between two fields (10px) still reads as the bigger break.\n */\n.fieldHint_318697645 {\n margin: 0;\n font-size: 12px;\n line-height: 1.5;\n color: var(--dsw-alias-label-tertiary);\n}\n\n.grid_318697645 {\n display: grid;\n grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));\n gap: 10px 14px;\n}\n\n/*\n * A field's label line when the field also carries an option of its own: the\n * label and the saved-password checkbox share the line, so the option reads as\n * part of the field it governs instead of as a setting of its own further down\n * the card. They sit at the two ends of that line (`space-between`): the label\n * keeps the field column's left edge, the checkbox lines up with its right edge\n * — packed together they read as one long word. The checkbox is brought down to\n * the field label's 12px and its colour for the same reason as the part rows\n * (see `.partRow_318697645 label`): a control inside a label line should not outrank the\n * line it sits in.\n */\n.labelRow_318697645 {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 10px;\n}\n\n.labelRow_318697645 label {\n gap: 6px;\n font-size: 12px;\n line-height: 18px;\n color: var(--dsw-alias-label-secondary);\n}\n\n.labelRow_318697645 label input {\n width: 14px;\n height: 14px;\n}\n\n/*\n * The auto-sync choice: every tab in one row, all the same width, so six\n * options read as one strip instead of a dropdown hiding five of them.\n */\n/* Doubled class: the control's own rule also sets a display, and this wins. */\n.autoControl_318697645.autoControl_318697645 {\n display: grid;\n width: 100%;\n box-sizing: border-box;\n}\n\n/* Six tabs have to share the row, so each keeps its padding modest. */\n.autoControl_318697645 button {\n padding: 0 6px;\n font-size: 12px;\n}\n\n.partRow_318697645 {\n display: flex;\n align-items: flex-start;\n gap: 10px;\n padding: 7px 0;\n border-bottom: 1px solid var(--dsw-alias-border-l2);\n}\n\n/* No trailing border when the rows stand alone (a host with no view yet). */\n.partRow_318697645:last-child {\n border-bottom: none;\n}\n\n/* The checkbox with its one-line description under it. */\n.partText_318697645 {\n display: flex;\n flex: 1;\n flex-direction: column;\n gap: 2px;\n min-width: 0;\n}\n\n/*\n * The checkbox component sets its own label at 14px, which is bigger than this\n * card's section headings (12px) — the rows read as if they outranked the\n * heading above them. Its class name is internal to the component, so the\n * element selector is the way in; the box shrinks with the text to match.\n */\n.partRow_318697645 label {\n gap: 6px;\n font-size: 12px;\n line-height: 18px;\n}\n\n.partRow_318697645 label input {\n width: 14px;\n height: 14px;\n}\n\n.partHint_318697645 {\n font-size: 11px;\n line-height: 1.4;\n color: var(--dsw-alias-label-tertiary);\n}\n\n.partState_318697645 {\n margin-left: auto;\n flex: none;\n padding-top: 2px;\n font-size: 11px;\n color: var(--dsw-alias-label-tertiary);\n}\n\n.partStateBoth_318697645 {\n color: var(--dsw-alias-label-warning, var(--dsw-alias-label-secondary));\n}\n\n.notice_318697645 {\n display: flex;\n flex-direction: column;\n gap: 6px;\n padding: 10px 12px;\n border: 1px solid var(--dsw-alias-border-l2);\n border-radius: 10px;\n background: var(--dsw-alias-bg-layer-2);\n font-size: 12px;\n}\n\n.noticeTitle_318697645 {\n font-weight: 600;\n color: var(--dsw-alias-label-primary);\n}\n\n/*\n * The sync actions. Six short labels have to sit on one row, so the buttons\n * keep their own padding down; wrapping stays on only as a narrow-card escape.\n */\n.footer_318697645 {\n display: flex;\n align-items: center;\n gap: 6px;\n flex-wrap: wrap;\n padding-top: 4px;\n}\n\n.footer_318697645 button {\n padding: 0 8px;\n}\n\n.spacer_318697645 {\n flex: 1;\n}\n\n/* The card's own actions: WebDAV settings, kept apart from the sync ones. */\n.cardFooter_318697645 {\n display: flex;\n align-items: center;\n gap: 8px;\n flex-wrap: wrap;\n margin-top: 4px;\n padding-top: 12px;\n border-top: 1px solid var(--dsw-alias-border-l2);\n}\n\n.failed_318697645 {\n font-size: 12px;\n color: var(--dsw-alias-label-error);\n}\n\n.note_318697645 {\n font-size: 12px;\n color: var(--dsw-alias-label-secondary);\n}\n\n.confirm_318697645 {\n display: flex;\n align-items: center;\n gap: 8px;\n flex-wrap: wrap;\n padding: 10px 12px;\n border: 1px solid var(--dsw-alias-border-l4);\n border-radius: 10px;\n font-size: 12px;\n color: var(--dsw-alias-label-primary);\n}\n\n.previewList_318697645 {\n display: flex;\n flex-direction: column;\n gap: 4px;\n font-size: 12px;\n}\n\n.previewRow_318697645 {\n display: flex;\n gap: 8px;\n}\n\n.previewPart_318697645 {\n min-width: 90px;\n color: var(--dsw-alias-label-secondary);\n}\n\n.output_318697645 {\n max-height: 180px;\n margin: 0;\n overflow: auto;\n padding: 8px 10px;\n border-radius: 8px;\n background: var(--dsw-alias-bg-layer-3);\n font-family: ui-monospace, SFMono-Regular, Menlo, monospace;\n font-size: 11px;\n line-height: 1.5;\n white-space: pre-wrap;\n color: var(--dsw-alias-label-secondary);\n}\n\n.spin_318697645 {\n animation: webdavSyncSpin 0.9s linear infinite;\n}\n\n@keyframes webdavSyncSpin {\n to {\n transform: rotate(360deg);\n }\n}\n";
85
+ const css = ".summary_318697645 {\n display: flex;\n align-items: center;\n gap: 8px;\n min-width: 0;\n font-size: 13px;\n color: var(--dsw-alias-label-secondary);\n}\n\n.summaryText_318697645 {\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n.page_318697645 {\n display: flex;\n flex-direction: column;\n gap: 20px;\n padding: 4px 0 8px;\n font-size: 13px;\n color: var(--dsw-alias-label-primary);\n}\n\n.legend_318697645 {\n display: flex;\n align-items: baseline;\n gap: 8px;\n margin: 0 0 8px;\n font-size: 12px;\n font-weight: 600;\n letter-spacing: 0.02em;\n color: var(--dsw-alias-label-secondary);\n}\n\n.hint_318697645 {\n margin: 0;\n font-size: 12px;\n line-height: 1.6;\n color: var(--dsw-alias-label-tertiary);\n}\n\n/* The two stamps share one line: the check on the left, the sync on the right.\n They wrap rather than overlap when the card is too narrow to hold both. */\n.times_318697645 {\n display: flex;\n align-items: baseline;\n justify-content: space-between;\n gap: 4px 10px;\n flex-wrap: wrap;\n}\n\n.field_318697645 {\n display: flex;\n flex-direction: column;\n gap: 4px;\n margin-bottom: 10px;\n}\n\n.fieldLabel_318697645 {\n font-size: 12px;\n color: var(--dsw-alias-label-secondary);\n}\n\n/*\n * The line of explanation belonging to one field: it sits between that field's\n * label and its input, so a reader gets the rule before typing, and the gap\n * between two fields (10px) still reads as the bigger break.\n */\n.fieldHint_318697645 {\n margin: 0;\n font-size: 12px;\n line-height: 1.5;\n color: var(--dsw-alias-label-tertiary);\n}\n\n.grid_318697645 {\n display: grid;\n grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));\n gap: 10px 14px;\n}\n\n/*\n * The extra-headers field is the card's only multi-line input, and there is no\n * multi-line primitive to reuse. It takes the one-line input's frame instead —\n * same border, radius and background — so it reads as a field of the same\n * family, and a monospace face because what goes in it is header syntax, where\n * the alignment of a name against its value is the thing being checked. It\n * starts three lines tall and may be dragged taller.\n */\n.headersArea_318697645 {\n box-sizing: border-box;\n width: 100%;\n min-height: 66px;\n padding: 6px 8px;\n resize: vertical;\n border: 0.5px solid var(--dsw-alias-border-l4);\n border-radius: var(--dsw-radius-md);\n background: var(--dsw-alias-bg-layer-1);\n font-family: ui-monospace, SFMono-Regular, Menlo, monospace;\n font-size: 12px;\n line-height: 18px;\n color: var(--dsw-alias-label-primary);\n}\n\n.headersArea_318697645::placeholder {\n color: var(--dsw-alias-label-dimmed);\n}\n\n.headersArea_318697645:focus-visible {\n outline: none;\n border-color: var(--dsw-alias-state-business-primary);\n}\n\n/*\n * A field's label line when the field also carries an option of its own: the\n * label and the saved-password checkbox share the line, so the option reads as\n * part of the field it governs instead of as a setting of its own further down\n * the card. They sit at the two ends of that line (`space-between`): the label\n * keeps the field column's left edge, the checkbox lines up with its right edge\n * — packed together they read as one long word. The checkbox is brought down to\n * the field label's 12px and its colour for the same reason as the part rows\n * (see `.partRow_318697645 label`): a control inside a label line should not outrank the\n * line it sits in.\n */\n.labelRow_318697645 {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 10px;\n}\n\n.labelRow_318697645 label {\n gap: 6px;\n font-size: 12px;\n line-height: 18px;\n color: var(--dsw-alias-label-secondary);\n}\n\n.labelRow_318697645 label input {\n width: 14px;\n height: 14px;\n}\n\n/*\n * The auto-sync choice: every tab in one row, all the same width, so six\n * options read as one strip instead of a dropdown hiding five of them.\n */\n/* Doubled class: the control's own rule also sets a display, and this wins. */\n.autoControl_318697645.autoControl_318697645 {\n display: grid;\n width: 100%;\n box-sizing: border-box;\n}\n\n/* Six tabs have to share the row, so each keeps its padding modest. */\n.autoControl_318697645 button {\n padding: 0 6px;\n font-size: 12px;\n}\n\n.partRow_318697645 {\n display: flex;\n align-items: flex-start;\n gap: 10px;\n padding: 7px 0;\n border-bottom: 1px solid var(--dsw-alias-border-l2);\n}\n\n/* No trailing border when the rows stand alone (a host with no view yet). */\n.partRow_318697645:last-child {\n border-bottom: none;\n}\n\n/* The checkbox with its one-line description under it. */\n.partText_318697645 {\n display: flex;\n flex: 1;\n flex-direction: column;\n gap: 2px;\n min-width: 0;\n}\n\n/*\n * The checkbox component sets its own label at 14px, which is bigger than this\n * card's section headings (12px) — the rows read as if they outranked the\n * heading above them. Its class name is internal to the component, so the\n * element selector is the way in; the box shrinks with the text to match.\n */\n.partRow_318697645 label {\n gap: 6px;\n font-size: 12px;\n line-height: 18px;\n}\n\n.partRow_318697645 label input {\n width: 14px;\n height: 14px;\n}\n\n.partHint_318697645 {\n font-size: 11px;\n line-height: 1.4;\n color: var(--dsw-alias-label-tertiary);\n}\n\n.partState_318697645 {\n margin-left: auto;\n flex: none;\n padding-top: 2px;\n font-size: 11px;\n color: var(--dsw-alias-label-tertiary);\n}\n\n.partStateBoth_318697645 {\n color: var(--dsw-alias-label-warning, var(--dsw-alias-label-secondary));\n}\n\n/*\n * The extra directories, laid out as one more part row — checkbox, name,\n * one-line description, state — exactly like the four above, with the picker on\n * the title line right after the name and the chosen paths listed under the row.\n * Each path is a small card of its own: a bordered row that reads as something\n * carried, not as another line of prose.\n */\n\n/*\n * The picker shares the title line with the switch. It is a text button, so it\n * has to stop borrowing a control's height and padding to sit level with a 12px\n * label — and it reads quieter than the label until it is pointed at.\n */\n.titleRow_318697645 {\n display: flex;\n align-items: center;\n gap: 4px;\n}\n\n.titleRow_318697645 button {\n height: auto;\n min-height: 0;\n padding: 0 2px;\n font-size: 12px;\n line-height: 18px;\n color: var(--dsw-alias-label-secondary);\n}\n\n.titleRow_318697645 button:hover:not(:disabled) {\n color: var(--dsw-alias-label-primary);\n}\n\n.dirList_318697645 {\n padding: 0 0 4px;\n border-bottom: 1px solid var(--dsw-alias-border-l2);\n}\n\n/* The group owns the separator; its heading row sits above the paths. */\n.dirList_318697645 .partRow_318697645 {\n border-bottom: none;\n align-items: center;\n}\n\n.dirRow_318697645 {\n display: flex;\n align-items: center;\n gap: 6px;\n margin: 0 0 4px 20px;\n padding: 4px 6px 4px 8px;\n border: 1px solid var(--dsw-alias-border-l2);\n border-radius: 8px;\n background: var(--dsw-alias-bg-layer-3);\n}\n\n/* Switched off, the paths stay on screen but read as dormant. */\n.dirOff_318697645 {\n opacity: 0.6;\n}\n\n/* Mono, because a path is read character by character; a narrow box drops the\n * tail, and the whole path stays in the row's title attribute. */\n.dirPath_318697645 {\n flex: 1;\n min-width: 0;\n overflow: hidden;\n font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n font-size: 11px;\n line-height: 16px;\n color: var(--dsw-alias-label-primary);\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n/* The row a path is typed into, when no dialog could be reached. */\n.dirRow_318697645 input {\n flex: 1;\n min-width: 0;\n}\n\n/* A configured directory this computer could not read, one line per directory. */\n.problem_318697645 {\n margin: 4px 0 0;\n font-size: 11px;\n line-height: 1.5;\n color: var(--dsw-alias-label-error);\n}\n\n.notice_318697645 {\n display: flex;\n flex-direction: column;\n gap: 6px;\n padding: 10px 12px;\n border: 1px solid var(--dsw-alias-border-l2);\n border-radius: 10px;\n background: var(--dsw-alias-bg-layer-2);\n font-size: 12px;\n}\n\n.noticeTitle_318697645 {\n font-weight: 600;\n color: var(--dsw-alias-label-primary);\n}\n\n/*\n * The sync actions. Six short labels have to sit on one row, so the buttons\n * keep their own padding down; wrapping stays on only as a narrow-card escape.\n * With no frame to separate them, the spacing has to: each label carries 6px of\n * its own and the row adds 2px, so neighbouring labels sit 14px apart — roomy\n * enough to read as separate actions, tight enough not to break the row.\n */\n.footer_318697645 {\n display: flex;\n align-items: center;\n gap: 2px;\n flex-wrap: wrap;\n padding-top: 4px;\n}\n\n/*\n * Every sync action is a text button: no frame, no fill, only a label. The\n * reason is the row itself — six framed buttons next to the settings above read\n * as a second form — and the risk a text button always carries: 「看不出是个\n * 按钮」. That is answered by colour and by the pointer, not by a box:\n *\n * - the label sits in the ordinary primary colour, so it reads as text that\n * happens to be clickable;\n * - the hover fill comes from `ghost` and appears under the pointer, which is\n * exactly when the question 「能点吗」 gets asked;\n * - the focus ring is drawn here because `ghost` has none of its own, and a\n * keyboard user has no pointer to discover the button with.\n *\n * `height: auto` drops the control's 28px box so six labels fit one line; the\n * 24px `min-height` keeps the hover fill from collapsing onto the glyphs. The\n * 500 weight is what makes the whole row read as one band of controls rather\n * than as a sentence that happens to end the section: at 12px, ordinary body\n * weight next to ordinary body text is the one thing a text button cannot\n * recover from with colour alone.\n */\n.footer_318697645 button.textAction_318697645 {\n height: auto;\n min-height: 24px;\n padding: 0 6px;\n color: var(--dsw-alias-label-primary);\n font-weight: 500;\n}\n\n.footer_318697645 button.textAction_318697645:hover:not(:disabled) {\n background: var(--dsw-alias-interactive-bg-hover);\n color: var(--dsw-alias-label-primary);\n}\n\n.footer_318697645 button.textAction_318697645:focus-visible {\n outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));\n outline-offset: 1px;\n}\n\n/*\n * The one action this part of the card is for. It is the row's only accented\n * label, which is what makes 「立即同步」 findable without a filled box; the\n * colour carries it in both themes, where a 12px label is small enough that\n * hue alone would need a second signal — here that signal is being the only\n * coloured one in an otherwise grey row, so the weight stays the row's own.\n */\n.footer_318697645 button.textActionPrimary_318697645 {\n color: var(--dsw-alias-state-business-primary);\n}\n\n.footer_318697645 button.textActionPrimary_318697645:hover:not(:disabled) {\n color: var(--dsw-alias-state-business-primary);\n}\n\n/*\n * The two actions that replace one whole side with the other: 「服务器覆盖本机」\n * and 「本机覆盖服务器」. They are the only pair in the row that can throw away\n * work on the side it writes, so they carry the warning colour while staying\n * text buttons. Not the error colour: both are reversible (a restore has\n * `undo`, an upload keeps the old file in `sync/`), and red would say otherwise.\n *\n * `warn-label`, not `warn-primary`: the design system splits these two — the\n * primary amber (#f59e0b) paints dots and tag fills, while warning *text* uses\n * the darker `label` step (#dd8629), which is what `Toast` and\n * `ConnectionIndicator` put on 12px labels exactly like this one. At 12px the\n * primary amber on a light card is barely 2:1 against the surface; the label\n * step is the token that was picked for reading, so it is the one used here.\n */\n.footer_318697645 button.textActionReplace_318697645 {\n color: var(--dsw-alias-state-warn-label);\n}\n\n.footer_318697645 button.textActionReplace_318697645:hover:not(:disabled) {\n background: color-mix(in srgb, var(--dsw-alias-state-warn-label) 10%, transparent);\n color: var(--dsw-alias-state-warn-label);\n}\n\n.spacer_318697645 {\n flex: 1;\n}\n\n/* The card's own actions: WebDAV settings, kept apart from the sync ones. */\n.cardFooter_318697645 {\n display: flex;\n align-items: center;\n gap: 8px;\n flex-wrap: wrap;\n margin-top: 4px;\n padding-top: 12px;\n border-top: 1px solid var(--dsw-alias-border-l2);\n}\n\n.failed_318697645 {\n font-size: 12px;\n color: var(--dsw-alias-label-error);\n}\n\n.note_318697645 {\n font-size: 12px;\n color: var(--dsw-alias-label-secondary);\n}\n\n.confirm_318697645 {\n display: flex;\n align-items: center;\n gap: 8px;\n flex-wrap: wrap;\n padding: 10px 12px;\n border: 1px solid var(--dsw-alias-border-l4);\n border-radius: 10px;\n font-size: 12px;\n color: var(--dsw-alias-label-primary);\n}\n\n.previewList_318697645 {\n display: flex;\n flex-direction: column;\n gap: 4px;\n font-size: 12px;\n}\n\n.previewRow_318697645 {\n display: flex;\n gap: 8px;\n}\n\n.previewPart_318697645 {\n min-width: 90px;\n color: var(--dsw-alias-label-secondary);\n}\n\n.output_318697645 {\n max-height: 180px;\n margin: 0;\n overflow: auto;\n padding: 8px 10px;\n border-radius: 8px;\n background: var(--dsw-alias-bg-layer-3);\n font-family: ui-monospace, SFMono-Regular, Menlo, monospace;\n font-size: 11px;\n line-height: 1.5;\n white-space: pre-wrap;\n color: var(--dsw-alias-label-secondary);\n}\n\n.spin_318697645 {\n animation: webdavSyncSpin 0.9s linear infinite;\n}\n\n@keyframes webdavSyncSpin {\n to {\n transform: rotate(360deg);\n }\n}\n\n/*\n * The forwarder's switch and the two values it hands out.\n *\n * The switch is a checkbox like the ones that choose the parts, and it reads at\n * the size of the section titles rather than at the primitive's own 14px: one\n * line of body-sized text under a title is a control shouting over its heading.\n *\n * The address is what a reader copies, so it is plain text — monospace, because\n * a path is read character by character — with the copy icon right after it and\n * no frame: a field around a value nobody types only reads as something to fill\n * in. The account sits under it the same way: name, value, copy icon. All three\n * are named, because three copy buttons in a row are otherwise three buttons\n * that all say 「copy」.\n */\n.relaySwitch_318697645 {\n margin-bottom: 6px;\n}\n\n.relaySwitch_318697645 label {\n font-size: 12px;\n line-height: 18px;\n}\n\n.relayAddressRow_318697645,\n.relayAccountRow_318697645 {\n display: flex;\n align-items: center;\n gap: 6px;\n flex-wrap: wrap;\n margin-bottom: 6px;\n}\n\n/* The two account values are two groups rather than four loose items: the wider\n * gap separates them, the narrow one binds each label to its value and icon. */\n.relayAccountRow_318697645 {\n gap: 6px 14px;\n}\n\n/* One value with its copy icon. It stays on one line, so a wrapped card can\n * never leave an icon under a value it does not copy. */\n.relayPair_318697645 {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n}\n\n/* Plain text rather than a field frame, but still the reader's to select: the\n * failure hint's fallback — select it and copy by hand — has to keep working.\n * It takes only the width it needs, so the copy icon sits at the end of the\n * address instead of being pushed out to the card's right edge: the icon\n * belongs to that string, and a gap would read as a separate action. */\n.relayAddress_318697645 {\n flex: 0 1 auto;\n min-width: 0;\n font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n font-size: 12px;\n line-height: 18px;\n color: var(--dsw-alias-label-primary);\n overflow-wrap: anywhere;\n}\n\n/* An icon button: the primitive's frame drops away, and what is left is the\n * app's own pressable shape — the hover wash around a 16px glyph, at the 24px\n * the app's other icon actions take. */\nbutton.relayCopy_318697645 {\n flex: none;\n width: 24px;\n height: 24px;\n padding: 0;\n color: var(--dsw-alias-label-secondary);\n}\n\nbutton.relayCopy_318697645:hover:not(:disabled) {\n color: var(--dsw-alias-label-primary);\n}\n\n.relayLabel_318697645 {\n font-size: 12px;\n line-height: 18px;\n color: var(--dsw-alias-label-tertiary);\n}\n\n/* A value with nothing around it: the three things a reader takes away — the\n * address and the two account strings — are all set the same way, because none\n * of them is a field. A frame here would say 「fill this in」 about a string that\n * is generated and read-only. */\n.relayValue_318697645 {\n font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n font-size: 12px;\n line-height: 18px;\n color: var(--dsw-alias-label-primary);\n}\n";
55
86
  const tagId = "dsh-webdav-sync/WebdavSyncCard.module.css";
56
87
  if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=\"" + tagId + "\"]") === null) {
57
88
  const tag = document.createElement("style");
@@ -66,10 +97,12 @@ window.__ModuleLoader__.load({
66
97
  "page": "page_318697645",
67
98
  "legend": "legend_318697645",
68
99
  "hint": "hint_318697645",
100
+ "times": "times_318697645",
69
101
  "field": "field_318697645",
70
102
  "fieldLabel": "fieldLabel_318697645",
71
103
  "fieldHint": "fieldHint_318697645",
72
104
  "grid": "grid_318697645",
105
+ "headersArea": "headersArea_318697645",
73
106
  "partRow": "partRow_318697645",
74
107
  "labelRow": "labelRow_318697645",
75
108
  "autoControl": "autoControl_318697645",
@@ -77,9 +110,18 @@ window.__ModuleLoader__.load({
77
110
  "partHint": "partHint_318697645",
78
111
  "partState": "partState_318697645",
79
112
  "partStateBoth": "partStateBoth_318697645",
113
+ "titleRow": "titleRow_318697645",
114
+ "dirList": "dirList_318697645",
115
+ "dirRow": "dirRow_318697645",
116
+ "dirOff": "dirOff_318697645",
117
+ "dirPath": "dirPath_318697645",
118
+ "problem": "problem_318697645",
80
119
  "notice": "notice_318697645",
81
120
  "noticeTitle": "noticeTitle_318697645",
82
121
  "footer": "footer_318697645",
122
+ "textAction": "textAction_318697645",
123
+ "textActionPrimary": "textActionPrimary_318697645",
124
+ "textActionReplace": "textActionReplace_318697645",
83
125
  "spacer": "spacer_318697645",
84
126
  "cardFooter": "cardFooter_318697645",
85
127
  "failed": "failed_318697645",
@@ -89,9 +131,188 @@ window.__ModuleLoader__.load({
89
131
  "previewRow": "previewRow_318697645",
90
132
  "previewPart": "previewPart_318697645",
91
133
  "output": "output_318697645",
92
- "spin": "spin_318697645"
134
+ "spin": "spin_318697645",
135
+ "relaySwitch": "relaySwitch_318697645",
136
+ "relayAddressRow": "relayAddressRow_318697645",
137
+ "relayAccountRow": "relayAccountRow_318697645",
138
+ "relayPair": "relayPair_318697645",
139
+ "relayAddress": "relayAddress_318697645",
140
+ "relayCopy": "relayCopy_318697645",
141
+ "relayLabel": "relayLabel_318697645",
142
+ "relayValue": "relayValue_318697645"
93
143
  };
94
144
  //#endregion
145
+ //#region src/client/RelayPanel.tsx
146
+ /**
147
+ * The forwarder's section of the card.
148
+ *
149
+ * It is a section of the sync's own form, not a panel of its own: one switch, and
150
+ * — once it is on — the one address another program is pointed at, as plain text
151
+ * with a copy icon right after it, and the account that address asks for
152
+ * underneath it, each of its two values carrying the same copy icon. Nothing here
153
+ * is filled in and nothing here is saved separately: everything the forwarder uses
154
+ * is the configuration above it, and the account is generated.
155
+ *
156
+ * The address is worked out from the draft rather than from what the host last
157
+ * reported, so the line shows the address this configuration *will* hand out,
158
+ * following the address above as it is edited. What the host is actually running
159
+ * comes from the view: whether it is listening, why it is not, and the account it
160
+ * minted, which does not exist until the first save. The port is part of the
161
+ * address rather than a field of its own — a reader copies one string, and the
162
+ * one case where the port needs changing (some other program already holds it)
163
+ * is exactly the case where the host reports it.
164
+ *
165
+ * @module webdav-sync/client/RelayPanel
166
+ */
167
+ /** How long a copy icon says it worked, matching the app's own copy buttons. */
168
+ const COPIED_MS = 1e3;
169
+ /**
170
+ * One copy icon: a ghost button whose glyph changes to a check for a moment.
171
+ *
172
+ * @param props - the label, whether it worked, and the write to run.
173
+ * @returns the button, with its tooltip.
174
+ */
175
+ function CopyControl({ label, done, onCopy }) {
176
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Tooltip, {
177
+ label,
178
+ side: "top",
179
+ portal: true,
180
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
181
+ variant: "ghost",
182
+ size: "sm",
183
+ className: WebdavSyncCard_module_default.relayCopy,
184
+ icon: done ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconCheckOutlineRegular, {}) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconCopyOutlineRegular, {}),
185
+ "aria-label": label,
186
+ onClick: onCopy
187
+ })
188
+ });
189
+ }
190
+ /**
191
+ * The forwarder section.
192
+ *
193
+ * @param props - the draft's switch and port, the host's view, and the dictionary.
194
+ * @returns the section.
195
+ */
196
+ function RelayPanel(props) {
197
+ const { t, target, enabled, port, view, busy } = props;
198
+ const [copied, setCopied] = (0, react.useState)(void 0);
199
+ const revert = (0, react.useRef)(void 0);
200
+ const address = relayUrl(port, relayBasePath(target));
201
+ const account = view?.user ?? "";
202
+ const password = view?.password ?? "";
203
+ const listening = view?.listening === true && view.port === port && view.url === address;
204
+ const problem = view?.error ?? "";
205
+ (0, react.useEffect)(() => () => window.clearTimeout(revert.current), []);
206
+ const copy = (which, text) => {
207
+ (0, _deepseek_ai_dsh_client_ui_primitives.writeClipboard)(text).then((ok) => {
208
+ setCopied({
209
+ target: which,
210
+ ok
211
+ });
212
+ window.clearTimeout(revert.current);
213
+ if (ok) revert.current = window.setTimeout(() => setCopied(void 0), COPIED_MS);
214
+ });
215
+ };
216
+ /** One value's copy icon, saying 「已复制」 for it and for nothing else. */
217
+ const control = (which, text, base) => {
218
+ const done = copied?.target === which && copied.ok;
219
+ const failed = copied?.target === which && !copied.ok;
220
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(CopyControl, {
221
+ label: done ? t("relayCopied") : t(base),
222
+ done,
223
+ onCopy: () => copy(which, text)
224
+ }), failed && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
225
+ className: WebdavSyncCard_module_default.failed,
226
+ role: "alert",
227
+ children: t("relayCopyFailed")
228
+ })] });
229
+ };
230
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", { children: [
231
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("h4", {
232
+ className: WebdavSyncCard_module_default.legend,
233
+ children: [t("relayLegend"), enabled && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Tag, {
234
+ tone: listening ? "success" : "quiet",
235
+ children: listening ? t("relayListening") : t("relayIdle")
236
+ })]
237
+ }),
238
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
239
+ className: WebdavSyncCard_module_default.relaySwitch,
240
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Checkbox, {
241
+ checked: enabled,
242
+ label: t("relayEnable"),
243
+ disabled: busy,
244
+ onChange: (next) => props.onToggle(next)
245
+ })
246
+ }),
247
+ enabled && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
248
+ target.trim() === "" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
249
+ className: WebdavSyncCard_module_default.hint,
250
+ children: t("relayNoTarget")
251
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
252
+ className: WebdavSyncCard_module_default.relayAddressRow,
253
+ children: [
254
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
255
+ className: WebdavSyncCard_module_default.relayLabel,
256
+ children: t("relayAddressLabel")
257
+ }),
258
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("code", {
259
+ className: WebdavSyncCard_module_default.relayAddress,
260
+ children: address
261
+ }),
262
+ control("address", address, "relayCopyAddress")
263
+ ]
264
+ }),
265
+ problem !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
266
+ className: WebdavSyncCard_module_default.relayAddressRow,
267
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
268
+ className: WebdavSyncCard_module_default.failed,
269
+ role: "alert",
270
+ children: problem
271
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
272
+ variant: "ghost",
273
+ size: "sm",
274
+ disabled: busy,
275
+ onClick: () => props.onNextPort(),
276
+ children: t("relayNextPort")
277
+ })]
278
+ }),
279
+ account === "" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
280
+ className: WebdavSyncCard_module_default.hint,
281
+ children: t("relayAccountLater")
282
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
283
+ className: WebdavSyncCard_module_default.relayAccountRow,
284
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
285
+ className: WebdavSyncCard_module_default.relayPair,
286
+ children: [
287
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
288
+ className: WebdavSyncCard_module_default.relayLabel,
289
+ children: t("relayUserLabel")
290
+ }),
291
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
292
+ className: WebdavSyncCard_module_default.relayValue,
293
+ children: account
294
+ }),
295
+ control("user", account, "relayCopyUser")
296
+ ]
297
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
298
+ className: WebdavSyncCard_module_default.relayPair,
299
+ children: [
300
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
301
+ className: WebdavSyncCard_module_default.relayLabel,
302
+ children: t("relayPasswordLabel")
303
+ }),
304
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
305
+ className: WebdavSyncCard_module_default.relayValue,
306
+ children: password
307
+ }),
308
+ control("password", password, "relayCopyPassword")
309
+ ]
310
+ })]
311
+ })
312
+ ] })
313
+ ] });
314
+ }
315
+ //#endregion
95
316
  //#region src/client/WebdavSyncCard.tsx
96
317
  /**
97
318
  * The WebDAV sync card, rendered in two seats from one body:
@@ -113,17 +334,40 @@ window.__ModuleLoader__.load({
113
334
  models: "partModels",
114
335
  defaults: "partDefaults",
115
336
  plugins: "partPlugins",
116
- patch: "partPatch"
337
+ patch: "partPatch",
338
+ dirs: "partDirs"
117
339
  };
118
340
  /** Which dictionary key says, in a line, what a part carries. */
119
341
  const PART_HINT = {
120
342
  models: "partModelsHint",
121
343
  defaults: "partDefaultsHint",
122
344
  plugins: "partPluginsHint",
123
- patch: "partPatchHint"
345
+ patch: "partPatchHint",
346
+ dirs: "partDirsHint"
124
347
  };
348
+ /**
349
+ * The parts whose switch is a checkbox on their own row.
350
+ *
351
+ * The extra directories are a `part` too — they hash, merge and travel like the
352
+ * four profile parts — but there is no single boolean behind them: the list
353
+ * itself is the part, so it is turned off by removing every row, and its rows
354
+ * are edited one by one instead of behind one label. Filtering it out keeps the
355
+ * checkbox loop honest, and the predicate is what tells the compiler so.
356
+ */
357
+ function isToggledPart(view) {
358
+ return view.part !== "dirs";
359
+ }
125
360
  /** The value of the auto-sync control's first tab, which is not an interval. */
126
361
  const AUTO_OFF = "off";
362
+ /**
363
+ * How often the card re-reads the status while it is open, in milliseconds.
364
+ *
365
+ * The host's own timer works in minutes, so anything in that range would do;
366
+ * this is small enough that a page left open shows a sync as soon as it happens
367
+ * and large enough that the host is not being asked constantly. Nothing here
368
+ * reaches the server: `status` reads the profile and the state file.
369
+ */
370
+ const STATUS_EVERY_MS = 15e3;
127
371
  /** Which dictionary key names the action in flight. */
128
372
  const BUSY_TEXT = {
129
373
  check: "check",
@@ -178,6 +422,11 @@ window.__ModuleLoader__.load({
178
422
  (0, react.useEffect)(() => {
179
423
  if (onPage) props.ensure();
180
424
  }, [onPage]);
425
+ (0, react.useEffect)(() => {
426
+ if (!onPage) return void 0;
427
+ const timer = setInterval(() => props.poll(), STATUS_EVERY_MS);
428
+ return () => clearInterval(timer);
429
+ }, [onPage]);
181
430
  if (state.phase === "loading") return onPage ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
182
431
  className: WebdavSyncCard_module_default.note,
183
432
  role: "status",
@@ -203,8 +452,9 @@ window.__ModuleLoader__.load({
203
452
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
204
453
  className: WebdavSyncCard_module_default.footer,
205
454
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
206
- variant: "outline",
455
+ variant: "ghost",
207
456
  size: "sm",
457
+ className: `${WebdavSyncCard_module_default.textAction} ${WebdavSyncCard_module_default.textActionPrimary}`,
208
458
  onClick: () => props.refresh(),
209
459
  children: t("check")
210
460
  })
@@ -212,6 +462,7 @@ window.__ModuleLoader__.load({
212
462
  });
213
463
  const busy = state.busy !== "";
214
464
  const busyKey = BUSY_TEXT[state.busy];
465
+ const dirsPart = (state.view?.parts ?? []).find((part) => part.part === "dirs");
215
466
  const confirmText = state.confirm === "restore" ? t("confirmRestore") : state.confirm === "upload" ? t("confirmUpload") : state.confirm === "off" ? t("confirmOff") : "";
216
467
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
217
468
  className: WebdavSyncCard_module_default.page,
@@ -299,6 +550,23 @@ window.__ModuleLoader__.load({
299
550
  onChange: (event) => props.edit("passphrase", event.target.value)
300
551
  })
301
552
  ]
553
+ }),
554
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
555
+ className: WebdavSyncCard_module_default.field,
556
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("label", {
557
+ className: WebdavSyncCard_module_default.fieldLabel,
558
+ htmlFor: "webdav-sync-headers",
559
+ children: t("headersLabel")
560
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("textarea", {
561
+ id: "webdav-sync-headers",
562
+ className: WebdavSyncCard_module_default.headersArea,
563
+ rows: 3,
564
+ spellCheck: false,
565
+ autoComplete: "off",
566
+ placeholder: t("headersPlaceholder"),
567
+ value: state.headers,
568
+ onChange: (event) => props.edit("headers", event.target.value)
569
+ })]
302
570
  })
303
571
  ] }),
304
572
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h4", {
@@ -321,7 +589,7 @@ window.__ModuleLoader__.load({
321
589
  className: WebdavSyncCard_module_default.legend,
322
590
  children: t("partsLegend")
323
591
  }),
324
- (state.view?.parts ?? []).map((part) => {
592
+ (state.view?.parts ?? []).filter(isToggledPart).map((part) => {
325
593
  const stateKey = part.synced ? partStateKey(part) : "partOff";
326
594
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
327
595
  className: WebdavSyncCard_module_default.partRow,
@@ -342,51 +610,122 @@ window.__ModuleLoader__.load({
342
610
  })]
343
611
  }, part.part);
344
612
  }),
345
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
346
- className: WebdavSyncCard_module_default.hint,
347
- children: state.view?.last === void 0 ? t("neverSynced") : t("lastSync", { at: whenText(state.view.last) })
613
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
614
+ className: WebdavSyncCard_module_default.dirList,
615
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
616
+ className: WebdavSyncCard_module_default.partRow,
617
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
618
+ className: WebdavSyncCard_module_default.partText,
619
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
620
+ className: WebdavSyncCard_module_default.titleRow,
621
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Checkbox, {
622
+ checked: state.dirsEnabled,
623
+ label: t("partDirs"),
624
+ title: t("partOffHint"),
625
+ onChange: (next) => props.toggle("dirsEnabled", next)
626
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
627
+ variant: "ghost",
628
+ size: "sm",
629
+ disabled: busy || state.dirs.length >= 5,
630
+ onClick: () => props.pickDir(),
631
+ children: t("chooseDir")
632
+ })]
633
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
634
+ className: WebdavSyncCard_module_default.partHint,
635
+ children: t("partDirsHint")
636
+ })]
637
+ }), dirsPart !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
638
+ className: WebdavSyncCard_module_default.partState,
639
+ children: t(dirsPart.synced ? partStateKey(dirsPart) : "partOff")
640
+ })]
641
+ }), state.dirs.map((path, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
642
+ className: state.dirsEnabled ? WebdavSyncCard_module_default.dirRow : `${WebdavSyncCard_module_default.dirRow} ${WebdavSyncCard_module_default.dirOff}`,
643
+ children: [path === "" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Input, {
644
+ id: `webdav-sync-dir-${index}`,
645
+ value: path,
646
+ spellCheck: false,
647
+ autoComplete: "off",
648
+ placeholder: t("dirPlaceholder"),
649
+ disabled: busy,
650
+ onChange: (event) => props.editDir(index, event.target.value)
651
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
652
+ className: WebdavSyncCard_module_default.dirPath,
653
+ title: path,
654
+ children: path
655
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
656
+ variant: "outline",
657
+ size: "sm",
658
+ disabled: busy,
659
+ onClick: () => props.removeDir(index),
660
+ children: t("dirRemove")
661
+ })]
662
+ }, index))]
663
+ }),
664
+ (state.view?.dirProblems ?? []).map((problem) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
665
+ className: WebdavSyncCard_module_default.problem,
666
+ role: "status",
667
+ children: t("dirProblem", {
668
+ path: problem.path,
669
+ message: problem.message
670
+ })
671
+ }, problem.path)),
672
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
673
+ className: WebdavSyncCard_module_default.times,
674
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
675
+ className: WebdavSyncCard_module_default.hint,
676
+ children: state.view?.last === void 0 ? t("neverChecked") : t("lastCheck", { at: whenText(state.view.last) })
677
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
678
+ className: WebdavSyncCard_module_default.hint,
679
+ children: state.view?.moved === void 0 ? t("neverSynced") : t("lastSync", { at: whenText(state.view.moved) })
680
+ })]
348
681
  }),
349
682
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
350
683
  className: WebdavSyncCard_module_default.footer,
351
684
  children: [
352
685
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
353
- variant: "outline",
686
+ variant: "ghost",
354
687
  size: "sm",
688
+ className: WebdavSyncCard_module_default.textAction,
355
689
  disabled: busy,
356
690
  onClick: () => props.check(),
357
691
  children: state.busy === "check" ? t("working") : t("check")
358
692
  }),
359
693
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
360
- variant: "outline",
694
+ variant: "ghost",
361
695
  size: "sm",
696
+ className: WebdavSyncCard_module_default.textAction,
362
697
  disabled: busy,
363
698
  onClick: () => props.preview(),
364
699
  children: state.busy === "preview" ? t("working") : t("preview")
365
700
  }),
366
701
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
367
- variant: "primary",
702
+ variant: "ghost",
368
703
  size: "sm",
704
+ className: `${WebdavSyncCard_module_default.textAction} ${WebdavSyncCard_module_default.textActionPrimary}`,
369
705
  disabled: busy,
370
706
  onClick: () => props.sync(),
371
707
  children: state.busy === "sync" ? t("working") : t("syncNow")
372
708
  }),
373
709
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
374
- variant: "outline",
710
+ variant: "ghost",
375
711
  size: "sm",
712
+ className: `${WebdavSyncCard_module_default.textAction} ${WebdavSyncCard_module_default.textActionReplace}`,
376
713
  disabled: busy,
377
714
  onClick: () => props.askRestore(),
378
715
  children: t("restore")
379
716
  }),
380
717
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
381
- variant: "outline",
718
+ variant: "ghost",
382
719
  size: "sm",
720
+ className: WebdavSyncCard_module_default.textAction,
383
721
  disabled: busy || !(state.view?.undoable ?? false),
384
722
  onClick: () => props.undo(),
385
723
  children: t("undo")
386
724
  }),
387
725
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
388
- variant: "outline",
726
+ variant: "ghost",
389
727
  size: "sm",
728
+ className: `${WebdavSyncCard_module_default.textAction} ${WebdavSyncCard_module_default.textActionReplace}`,
390
729
  disabled: busy,
391
730
  onClick: () => props.askUpload(),
392
731
  children: t("upload")
@@ -492,6 +831,16 @@ window.__ModuleLoader__.load({
492
831
  t("working")
493
832
  ]
494
833
  }),
834
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(RelayPanel, {
835
+ t,
836
+ target: state.url,
837
+ enabled: state.relayEnabled,
838
+ port: state.relayPort,
839
+ view: state.view?.relay,
840
+ busy,
841
+ onToggle: (next) => props.toggle("relayEnabled", next),
842
+ onNextPort: () => props.nextPort()
843
+ }),
495
844
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
496
845
  className: WebdavSyncCard_module_default.cardFooter,
497
846
  children: [
@@ -636,12 +985,17 @@ window.__ModuleLoader__.load({
636
985
  user: "",
637
986
  password: "",
638
987
  passphrase: "",
988
+ headers: "",
989
+ dirs: [],
990
+ dirsEnabled: true,
639
991
  rememberPassword: true,
640
992
  autoEvery: -1,
641
993
  models: true,
642
994
  defaults: true,
643
995
  plugins: true,
644
996
  patch: true,
997
+ relayEnabled: false,
998
+ relayPort: RELAY_DEFAULT_PORT,
645
999
  dirty: false,
646
1000
  busy: "",
647
1001
  message: "",
@@ -656,6 +1010,16 @@ window.__ModuleLoader__.load({
656
1010
  state = EMPTY;
657
1011
  listeners = /* @__PURE__ */ new Set();
658
1012
  started = false;
1013
+ /** The directory dialog of this deployment, when it has one. */
1014
+ choose;
1015
+ /**
1016
+ * @param choose - how this deployment picks a directory. Left out, the card
1017
+ * still works: a pick then leaves a blank row the path is typed into, which
1018
+ * is also what a deployment with no reachable dialog gets.
1019
+ */
1020
+ constructor(choose) {
1021
+ this.choose = choose;
1022
+ }
659
1023
  getSnapshot() {
660
1024
  return this.state;
661
1025
  }
@@ -689,12 +1053,17 @@ window.__ModuleLoader__.load({
689
1053
  user: view.config.user,
690
1054
  password: "",
691
1055
  passphrase: "",
1056
+ headers: view.config.headers,
1057
+ dirs: [...view.config.dirs ?? []],
1058
+ dirsEnabled: view.config.dirsEnabled ?? true,
692
1059
  rememberPassword: view.config.rememberPassword,
693
1060
  autoEvery: view.config.autoEvery,
694
1061
  models: view.config.models,
695
1062
  defaults: view.config.defaults,
696
1063
  plugins: view.config.plugins,
697
1064
  patch: view.config.patch,
1065
+ relayEnabled: view.config.relayEnabled ?? false,
1066
+ relayPort: view.config.relayPort ?? 3426,
698
1067
  dirty: false
699
1068
  });
700
1069
  }
@@ -743,6 +1112,31 @@ window.__ModuleLoader__.load({
743
1112
  refresh() {
744
1113
  this.load();
745
1114
  }
1115
+ /**
1116
+ * A read the person did not ask for, for a timer. A first read already in
1117
+ * flight is allowed to land rather than asked for twice, and nothing here ever
1118
+ * reports a failure: the buttons are the paths that say what went wrong, and a
1119
+ * read that quietly could not be answered leaves the card showing the last
1120
+ * thing the host did say.
1121
+ */
1122
+ poll() {
1123
+ if (this.state.phase === "loading") return;
1124
+ this.reloadStatus();
1125
+ }
1126
+ /** Re-read the status and fold it in without disturbing what is being typed. */
1127
+ async reloadStatus() {
1128
+ const answer = await requestStatus();
1129
+ if (!answer.ok) return;
1130
+ if (this.state.dirty) {
1131
+ this.set({
1132
+ view: answer.data,
1133
+ error: ""
1134
+ });
1135
+ return;
1136
+ }
1137
+ this.set({ error: "" });
1138
+ this.adopt(answer.data);
1139
+ }
746
1140
  edit(field, value) {
747
1141
  this.set({
748
1142
  [field]: value,
@@ -755,6 +1149,50 @@ window.__ModuleLoader__.load({
755
1149
  dirty: true
756
1150
  });
757
1151
  }
1152
+ /**
1153
+ * Ask for a directory and stage it.
1154
+ *
1155
+ * A dialog that opened and was cancelled stages nothing: a cancel must not
1156
+ * leave a row behind. A dialog that cannot be reached at all leaves one blank
1157
+ * row instead, which is the one way left to name a path by hand.
1158
+ */
1159
+ pickDir() {
1160
+ if (this.state.dirs.length >= 5) return;
1161
+ const choose = this.choose;
1162
+ if (choose === void 0) {
1163
+ this.appendDir("");
1164
+ return;
1165
+ }
1166
+ choose().then((chosen) => {
1167
+ const path = (chosen ?? "").trim();
1168
+ if (path !== "") this.appendDir(path);
1169
+ }, () => this.appendDir(""));
1170
+ }
1171
+ /** Stage a row at the end of the list, unless that exact path is already on it. */
1172
+ appendDir(path) {
1173
+ if (this.state.dirs.length >= 5) return;
1174
+ if (path !== "" && this.state.dirs.includes(path)) return;
1175
+ this.set({
1176
+ dirs: [...this.state.dirs, path],
1177
+ dirty: true
1178
+ });
1179
+ }
1180
+ editDir(index, path) {
1181
+ if (index < 0 || index >= this.state.dirs.length) return;
1182
+ const dirs = [...this.state.dirs];
1183
+ dirs[index] = path;
1184
+ this.set({
1185
+ dirs,
1186
+ dirty: true
1187
+ });
1188
+ }
1189
+ removeDir(index) {
1190
+ if (index < 0 || index >= this.state.dirs.length) return;
1191
+ this.set({
1192
+ dirs: this.state.dirs.filter((_, at) => at !== index),
1193
+ dirty: true
1194
+ });
1195
+ }
758
1196
  setAuto(minutes) {
759
1197
  if (!AUTO_CHOICES.includes(minutes)) return;
760
1198
  this.set({
@@ -762,6 +1200,13 @@ window.__ModuleLoader__.load({
762
1200
  dirty: true
763
1201
  });
764
1202
  }
1203
+ nextPort() {
1204
+ if (this.state.relayPort >= 65535) return;
1205
+ this.set({
1206
+ relayPort: this.state.relayPort + 1,
1207
+ dirty: true
1208
+ });
1209
+ }
765
1210
  reset() {
766
1211
  const view = this.state.view;
767
1212
  if (view === void 0) return;
@@ -782,12 +1227,17 @@ window.__ModuleLoader__.load({
782
1227
  user: this.state.user,
783
1228
  password: this.state.password,
784
1229
  passphrase: this.state.passphrase,
1230
+ headers: this.state.headers,
785
1231
  autoEvery: this.state.autoEvery,
786
1232
  models: this.state.models,
787
1233
  defaults: this.state.defaults,
788
1234
  plugins: this.state.plugins,
789
1235
  patch: this.state.patch,
1236
+ dirs: this.state.dirs,
1237
+ dirsEnabled: this.state.dirsEnabled,
790
1238
  rememberPassword: this.state.rememberPassword,
1239
+ relayEnabled: this.state.relayEnabled,
1240
+ relayPort: this.state.relayPort,
791
1241
  ...extra
792
1242
  };
793
1243
  }
@@ -866,9 +1316,14 @@ window.__ModuleLoader__.load({
866
1316
  return {
867
1317
  ensure: () => this.ensure(),
868
1318
  refresh: () => this.refresh(),
1319
+ poll: () => this.poll(),
869
1320
  edit: (field, value) => this.edit(field, value),
870
1321
  toggle: (field, value) => this.toggle(field, value),
1322
+ pickDir: () => this.pickDir(),
1323
+ editDir: (index, path) => this.editDir(index, path),
1324
+ removeDir: (index) => this.removeDir(index),
871
1325
  setAuto: (minutes) => this.setAuto(minutes),
1326
+ nextPort: () => this.nextPort(),
872
1327
  reset: () => this.reset(),
873
1328
  save: () => this.save(),
874
1329
  check: () => this.check(),
@@ -885,6 +1340,24 @@ window.__ModuleLoader__.load({
885
1340
  }
886
1341
  };
887
1342
  //#endregion
1343
+ //#region src/client/picker.ts
1344
+ /**
1345
+ * Build the chooser for whatever this deployment has.
1346
+ *
1347
+ * @param ctx - the client context, used only to look the workspace service up.
1348
+ * @returns the chooser; it rejects when no dialog can be reached.
1349
+ */
1350
+ function directoryChooser(ctx) {
1351
+ const lookup = ctx.get.bind(ctx);
1352
+ return async () => {
1353
+ const bridge = globalThis.__DSH_DIRECTORY_PICKER__;
1354
+ if (typeof bridge?.pick === "function") return await bridge.pick();
1355
+ const workspace = lookup("uiWorkspace");
1356
+ if (typeof workspace?.pickDirectory === "function") return await workspace.pickDirectory();
1357
+ throw new Error("no directory chooser is available here");
1358
+ };
1359
+ }
1360
+ //#endregion
888
1361
  //#region src/client/locales.ts
889
1362
  const NS = "webdav-sync";
890
1363
  /** Chinese copy — the source dictionary. */
@@ -899,6 +1372,8 @@ window.__ModuleLoader__.load({
899
1372
  passwordLabel: "密码",
900
1373
  passphraseLabel: "口令",
901
1374
  passphraseHint: "口令在文件离开本机前用来加密,服务器看不到内容;不要与 WebDAV 密码用同一个。",
1375
+ headersLabel: "额外请求头",
1376
+ headersPlaceholder: "User-Agent: Zotero/7.0\nAuthorization: Bearer xxxxx",
902
1377
  rememberPassword: "保存密码",
903
1378
  autoLegend: "自动同步",
904
1379
  autoManual: "手动同步",
@@ -914,10 +1389,14 @@ window.__ModuleLoader__.load({
914
1389
  partPluginsHint: "装了哪些插件、按什么顺序加载。插件包本身不传,每台机器自己安装。",
915
1390
  partPatch: "其余 profile 配置",
916
1391
  partPatchHint: "MCP 服务器、权限预设、界面设置、insert 块等其余配置。",
1392
+ partDirs: "附加目录",
1393
+ partDirsHint: "profile 之外的目录,连里面的文件一起同步;路径跟着这份配置走,恢复时写回同一个目录。",
917
1394
  partSynced: "已一致",
918
1395
  partOnlyHere: "仅本机有改动",
919
1396
  partOnlyThere: "仅服务器有改动",
920
1397
  partBoth: "两边都有改动",
1398
+ lastCheck: "上次检查:{at}",
1399
+ neverChecked: "尚未检查过",
921
1400
  lastSync: "上次同步:{at}",
922
1401
  neverSynced: "尚未同步过",
923
1402
  noticeTitle: "上次同步替换了内容",
@@ -956,7 +1435,26 @@ window.__ModuleLoader__.load({
956
1435
  takeNewerRemote: "两边都改过,保留服务器较新的一份",
957
1436
  unavailable: "暂时不可用",
958
1437
  partOff: "不同步",
959
- partOffHint: "关掉之后,本机不会再动这一份,也不会把它推给别的机器。"
1438
+ partOffHint: "关掉之后,本机不会再动这一份,也不会把它推给别的机器。",
1439
+ chooseDir: "选择目录",
1440
+ dirPlaceholder: "这个目录的绝对路径",
1441
+ dirRemove: "移除",
1442
+ dirProblem: "「{path}」这个目录没读成:{message}",
1443
+ relayLegend: "本机转发",
1444
+ relayEnable: "启用本机转发",
1445
+ relayListening: "正在转发",
1446
+ relayIdle: "没有在转发",
1447
+ relayNoTarget: "先在上面把 WebDAV 地址填好",
1448
+ relayNextPort: "换一个端口",
1449
+ relayAccountLater: "保存一次后生成给客户端用的账号密码。",
1450
+ relayAddressLabel: "地址",
1451
+ relayUserLabel: "用户名",
1452
+ relayPasswordLabel: "密码",
1453
+ relayCopyAddress: "复制地址",
1454
+ relayCopyUser: "复制用户名",
1455
+ relayCopyPassword: "复制密码",
1456
+ relayCopied: "已复制",
1457
+ relayCopyFailed: "复制没成功,请手动选中复制"
960
1458
  };
961
1459
  /** English copy, complete by construction. */
962
1460
  const en = {
@@ -970,6 +1468,8 @@ window.__ModuleLoader__.load({
970
1468
  passwordLabel: "Password",
971
1469
  passphraseLabel: "Passphrase",
972
1470
  passphraseHint: "It seals the file before it leaves this computer, so the server cannot read it. Do not reuse the password above.",
1471
+ headersLabel: "Extra request headers",
1472
+ headersPlaceholder: "User-Agent: Zotero/7.0\nAuthorization: Bearer xxxxx",
973
1473
  rememberPassword: "Remember the password",
974
1474
  autoLegend: "Sync by itself",
975
1475
  autoManual: "By hand",
@@ -985,10 +1485,14 @@ window.__ModuleLoader__.load({
985
1485
  partPluginsHint: "Which plugins are installed and in what order they load. The packages themselves stay behind: each computer installs them.",
986
1486
  partPatch: "Other profile configuration",
987
1487
  partPatchHint: "MCP servers, permission presets, interface settings, insert blocks, and the rest.",
1488
+ partDirs: "Extra directories",
1489
+ partDirsHint: "Directories outside the profile, carried with the files inside them; the paths travel with this configuration, and a restore writes back into the same directory.",
988
1490
  partSynced: "In step",
989
1491
  partOnlyHere: "Changed here only",
990
1492
  partOnlyThere: "Changed on the server only",
991
1493
  partBoth: "Changed on both",
1494
+ lastCheck: "Last check: {at}",
1495
+ neverChecked: "Never checked yet",
992
1496
  lastSync: "Last sync: {at}",
993
1497
  neverSynced: "Never synced yet",
994
1498
  noticeTitle: "The last sync replaced something",
@@ -1027,7 +1531,26 @@ window.__ModuleLoader__.load({
1027
1531
  takeNewerRemote: "Both changed: keep the server’s as the newer one",
1028
1532
  unavailable: "Not available",
1029
1533
  partOff: "Not synced",
1030
- partOffHint: "Off: this computer neither changes this part nor hands it to the others."
1534
+ partOffHint: "Off: this computer neither changes this part nor hands it to the others.",
1535
+ chooseDir: "Choose directory",
1536
+ dirPlaceholder: "Absolute path of the directory",
1537
+ dirRemove: "Remove",
1538
+ dirProblem: "Could not read the directory {path}: {message}",
1539
+ relayLegend: "Forwarding on this computer",
1540
+ relayEnable: "Forward for other programs on this computer",
1541
+ relayListening: "Forwarding",
1542
+ relayIdle: "Not forwarding",
1543
+ relayNoTarget: "Fill in the WebDAV address above first",
1544
+ relayNextPort: "Another port",
1545
+ relayAccountLater: "The account for the client is made on the first save.",
1546
+ relayAddressLabel: "Address",
1547
+ relayUserLabel: "User name",
1548
+ relayPasswordLabel: "Password",
1549
+ relayCopyAddress: "Copy address",
1550
+ relayCopyUser: "Copy user name",
1551
+ relayCopyPassword: "Copy password",
1552
+ relayCopied: "Copied",
1553
+ relayCopyFailed: "Could not copy — select it and copy by hand"
1031
1554
  };
1032
1555
  //#endregion
1033
1556
  //#region src/client/index.ts
@@ -1063,12 +1586,14 @@ window.__ModuleLoader__.load({
1063
1586
  zh,
1064
1587
  en
1065
1588
  }), "webdav-sync: dictionaries");
1066
- const model = new WebdavSyncCard();
1589
+ const model = new WebdavSyncCard(directoryChooser(ctx));
1067
1590
  const bound = model.actions();
1068
1591
  const t = ctx.locale.bind(NS);
1069
1592
  /**
1070
1593
  * The registration-side face both seats inject. The store rides the reserved
1071
1594
  * `hooks` compartment, so the renderer binds it as the `useWebdavSync` prop.
1595
+ * The forwarder is a switch inside this same store rather than a second one:
1596
+ * its state is part of the card's draft and goes out with the same Save.
1072
1597
  */
1073
1598
  const face = () => ({
1074
1599
  ...bound,