dsh-webdav-sync 1.0.5 → 1.0.6
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/README.md +8 -3
- package/lib/client.js +618 -56
- package/lib/engine.d.ts +28 -1
- package/lib/engine.js +177 -3
- package/lib/index.js +26 -6
- package/lib/wire.d.ts +65 -0
- package/lib/wire.js +4 -0
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -41,6 +41,10 @@ window.__ModuleLoader__.load({
|
|
|
41
41
|
const ROUTE_PREVIEW = `${ROUTE_PREFIX}/preview`;
|
|
42
42
|
/** POST: put back the copies a restore replaced. */
|
|
43
43
|
const ROUTE_UNDO = `${ROUTE_PREFIX}/undo`;
|
|
44
|
+
/** POST: write one passphrase-sealed backup file into a folder the person picked. */
|
|
45
|
+
const ROUTE_EXPORT = `${ROUTE_PREFIX}/export`;
|
|
46
|
+
/** POST: open a picked backup file and put what it holds on this computer. */
|
|
47
|
+
const ROUTE_IMPORT = `${ROUTE_PREFIX}/import`;
|
|
44
48
|
/** The intervals the card offers, in minutes. */
|
|
45
49
|
const AUTO_CHOICES = [
|
|
46
50
|
-1,
|
|
@@ -82,7 +86,7 @@ window.__ModuleLoader__.load({
|
|
|
82
86
|
}
|
|
83
87
|
//#endregion
|
|
84
88
|
//#region \0webdav-sync-css:/home/runner/work/dsh-webdav/dsh-webdav/src/client/WebdavSyncCard.module.css?inline
|
|
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/*\n * The install report: how it went, the log, and the way out.\n *\n * The button sits on the sentence's row rather than under the log, so closing it\n * is where the eye already is — and one button takes the whole report away, since\n * the log is there to be read once and then is only something to scroll past.\n */\n.outputBlock_318697645 {\n display: flex;\n flex-direction: column;\n gap: 6px;\n}\n\n.outputHead_318697645 {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 8px;\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";
|
|
89
|
+
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/*\n * A heading that carries a button. `.legend_318697645` aligns on the baseline because its\n * other inline companion is a `Tag`; a 24px hit target cannot share a baseline\n * with 12px text — measured in Chromium, the glyph inside the button sat 3px\n * below the words' centre, low enough to look like it was falling off the line.\n * Centring the line fixes that and costs only the heading's own height (24px\n * instead of the 16.5px line box). Declared after `.legend_318697645` so it wins.\n */\n.legendAction_318697645 {\n align-items: center;\n}\n\n/*\n * The one action belonging to the 「同步内容」 heading: reading the status again.\n *\n * It is an icon button in the heading's own colour, not a worded one: the\n * heading is 12px/600, and a 12px label next to it would read as a second\n * heading rather than as something to press. The hover wash and the focus ring\n * come from `ghost`; what is set here is only the 24px square the card's other\n * icon actions take (`button.relayCopy_318697645`) — the primitive's 28px `sm` box would\n * make the heading taller than the rows under it. Written as `button.…` so the\n * control's own size rule does not win on specificity, with `align-self` left\n * in for any future heading that still aligns on the baseline.\n */\n.legend_318697645 button.legendIcon_318697645 {\n flex: none;\n align-self: center;\n width: 24px;\n height: 24px;\n padding: 0;\n color: var(--dsw-alias-label-secondary);\n}\n\n.legend_318697645 button.legendIcon_318697645:hover:not(:disabled) {\n color: var(--dsw-alias-label-primary);\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/*\n * The install report: how it went, the log, and the way out.\n *\n * The button sits on the sentence's row rather than under the log, so closing it\n * is where the eye already is — and one button takes the whole report away, since\n * the log is there to be read once and then is only something to scroll past.\n */\n.outputBlock_318697645 {\n display: flex;\n flex-direction: column;\n gap: 6px;\n}\n\n.outputHead_318697645 {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 8px;\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\n/* ---- manual backup ----\n *\n * Two rows, each one file, each opening into its own small form. The row is the\n * shape the screenshot asked for: what it does on the left over one line saying\n * what that means, and the action on the right, on the same baseline as the\n * title. The action is a text button rather than a framed one because it is the\n * same weight of decision as the six above it, and because a framed button on\n * both rows would read as the section's primary action twice over.\n */\n.backupRow_318697645 {\n display: flex;\n align-items: flex-start;\n gap: 12px;\n padding: 9px 0;\n border-bottom: 1px solid var(--dsw-alias-border-l2);\n}\n\n.backupRow_318697645:last-child {\n border-bottom: none;\n}\n\n.backupText_318697645 {\n display: flex;\n flex: 1;\n flex-direction: column;\n gap: 3px;\n min-width: 0;\n}\n\n.backupTitle_318697645 {\n font-size: 13px;\n line-height: 20px;\n color: var(--dsw-alias-label-primary);\n}\n\n.backupHint_318697645 {\n font-size: 12px;\n line-height: 1.5;\n color: var(--dsw-alias-label-tertiary);\n}\n\n/* Sits on the title's line, not the row's top: the row grows downwards with the\n * hint under it, and the action belongs to the title it acts on. */\n.backupAction_318697645 {\n flex: none;\n height: 20px;\n min-height: 0;\n padding: 0 4px;\n font-size: 12px;\n line-height: 20px;\n color: var(--dsw-alias-label-secondary);\n}\n\n.backupAction_318697645:hover:not(:disabled) {\n color: var(--dsw-alias-label-primary);\n}\n\n/* The open form hangs under its row and is set in from it, so the two questions\n * read as belonging to the row above rather than as the section's settings. */\n.backupForm_318697645 {\n display: flex;\n flex-direction: column;\n gap: 2px;\n margin: 0 0 4px;\n padding: 10px 12px 12px;\n border: 1px solid var(--dsw-alias-border-l2);\n border-radius: 10px;\n background: var(--dsw-alias-bg-layer-2);\n}\n\n.backupForm_318697645 .field_318697645 {\n margin-bottom: 8px;\n}\n\n.backupForm_318697645 .field_318697645:last-of-type {\n margin-bottom: 0;\n}\n\n.backupPathRow_318697645 {\n display: flex;\n align-items: center;\n gap: 8px;\n min-width: 0;\n}\n\n.backupPathRow_318697645 > :first-child {\n flex: 1;\n min-width: 0;\n}\n\n.backupPathRow_318697645 > button {\n flex: none;\n}\n\n/* The name of the file that was read, standing where a path field would: this\n * half cannot be given a path, only a file, so the row says which file it has. */\n.backupFileName_318697645 {\n font-size: 12px;\n line-height: 18px;\n color: var(--dsw-alias-label-secondary);\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n/* The browser's picker is opened from the button beside it; a second visible\n * input would be the same choice offered twice. */\n.backupFileInput_318697645 {\n display: none;\n}\n\n.backupFormFooter_318697645 {\n display: flex;\n align-items: center;\n gap: 8px;\n padding-top: 10px;\n}\n";
|
|
86
90
|
const tagId = "dsh-webdav-sync/WebdavSyncCard.module.css";
|
|
87
91
|
if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=\"" + tagId + "\"]") === null) {
|
|
88
92
|
const tag = document.createElement("style");
|
|
@@ -96,6 +100,9 @@ window.__ModuleLoader__.load({
|
|
|
96
100
|
"summaryText": "summaryText_318697645",
|
|
97
101
|
"page": "page_318697645",
|
|
98
102
|
"legend": "legend_318697645",
|
|
103
|
+
"legendAction": "legendAction_318697645",
|
|
104
|
+
"relayCopy": "relayCopy_318697645",
|
|
105
|
+
"legendIcon": "legendIcon_318697645",
|
|
99
106
|
"hint": "hint_318697645",
|
|
100
107
|
"times": "times_318697645",
|
|
101
108
|
"field": "field_318697645",
|
|
@@ -139,11 +146,296 @@ window.__ModuleLoader__.load({
|
|
|
139
146
|
"relayAccountRow": "relayAccountRow_318697645",
|
|
140
147
|
"relayPair": "relayPair_318697645",
|
|
141
148
|
"relayAddress": "relayAddress_318697645",
|
|
142
|
-
"relayCopy": "relayCopy_318697645",
|
|
143
149
|
"relayLabel": "relayLabel_318697645",
|
|
144
|
-
"relayValue": "relayValue_318697645"
|
|
150
|
+
"relayValue": "relayValue_318697645",
|
|
151
|
+
"backupRow": "backupRow_318697645",
|
|
152
|
+
"backupText": "backupText_318697645",
|
|
153
|
+
"backupTitle": "backupTitle_318697645",
|
|
154
|
+
"backupHint": "backupHint_318697645",
|
|
155
|
+
"backupAction": "backupAction_318697645",
|
|
156
|
+
"backupForm": "backupForm_318697645",
|
|
157
|
+
"backupPathRow": "backupPathRow_318697645",
|
|
158
|
+
"backupFileName": "backupFileName_318697645",
|
|
159
|
+
"backupFileInput": "backupFileInput_318697645",
|
|
160
|
+
"backupFormFooter": "backupFormFooter_318697645"
|
|
145
161
|
};
|
|
146
162
|
//#endregion
|
|
163
|
+
//#region src/client/ManualBackupPanel.tsx
|
|
164
|
+
/**
|
|
165
|
+
* The manual backup section of the card: export and import, one row each.
|
|
166
|
+
*
|
|
167
|
+
* It sits under 同步内容 and answers a question the sync itself cannot: how to
|
|
168
|
+
* move this setup to a computer that has no account, no address and no network.
|
|
169
|
+
* Both rows are one file each way, so both stay shut until they are asked for —
|
|
170
|
+
* a passphrase field on screen at all times would read as a setting to keep,
|
|
171
|
+
* when it is one file's one question.
|
|
172
|
+
*
|
|
173
|
+
* The file itself is the same sealed envelope the server holds, so a manual
|
|
174
|
+
* export can be imported by any other copy of this plugin and nothing new has to
|
|
175
|
+
* be trusted with the plaintext.
|
|
176
|
+
*
|
|
177
|
+
* The export is written by the host half: this half cannot save a file (the
|
|
178
|
+
* desktop shell cancels browser downloads), so it names a folder and the host
|
|
179
|
+
* writes there. The import travels the other way — this half reads the picked
|
|
180
|
+
* file, because only it can see one, and the host decrypts the text it is given.
|
|
181
|
+
*
|
|
182
|
+
* @module webdav-sync/client/ManualBackupPanel
|
|
183
|
+
*/
|
|
184
|
+
/**
|
|
185
|
+
* A byte count as something a reader can compare at a glance.
|
|
186
|
+
*
|
|
187
|
+
* @param bytes - the file's size.
|
|
188
|
+
* @returns e.g. 「812 B」 or 「1.4 KiB」.
|
|
189
|
+
*/
|
|
190
|
+
function sizeText(bytes) {
|
|
191
|
+
if (!Number.isFinite(bytes) || bytes < 0) return "—";
|
|
192
|
+
if (bytes < 1024) return `${Math.round(bytes)} B`;
|
|
193
|
+
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KiB`;
|
|
194
|
+
return `${(bytes / 1048576).toFixed(1)} MiB`;
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* The manual backup section.
|
|
198
|
+
*
|
|
199
|
+
* @param props - the card's snapshot, its actions and the dictionary.
|
|
200
|
+
* @returns the section.
|
|
201
|
+
*/
|
|
202
|
+
function ManualBackupPanel(props) {
|
|
203
|
+
const { t, state, busy, partName } = props;
|
|
204
|
+
const file = (0, react.useRef)(null);
|
|
205
|
+
const names = (parts) => parts.map((part) => partName(part)).join("、");
|
|
206
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", { children: [
|
|
207
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h4", {
|
|
208
|
+
className: WebdavSyncCard_module_default.legend,
|
|
209
|
+
children: t("backupLegend")
|
|
210
|
+
}),
|
|
211
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
212
|
+
className: WebdavSyncCard_module_default.backupRow,
|
|
213
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
214
|
+
className: WebdavSyncCard_module_default.backupText,
|
|
215
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
216
|
+
className: WebdavSyncCard_module_default.backupTitle,
|
|
217
|
+
children: t("exportTitle")
|
|
218
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
219
|
+
className: WebdavSyncCard_module_default.backupHint,
|
|
220
|
+
children: t("exportHint")
|
|
221
|
+
})]
|
|
222
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
|
|
223
|
+
variant: "ghost",
|
|
224
|
+
size: "sm",
|
|
225
|
+
className: WebdavSyncCard_module_default.backupAction,
|
|
226
|
+
disabled: busy,
|
|
227
|
+
"aria-expanded": state.exportOpen,
|
|
228
|
+
onClick: () => state.exportOpen ? props.closeExport() : props.openExport(),
|
|
229
|
+
children: t("exportAction")
|
|
230
|
+
})]
|
|
231
|
+
}),
|
|
232
|
+
state.exportOpen && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
233
|
+
className: WebdavSyncCard_module_default.backupForm,
|
|
234
|
+
children: [
|
|
235
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
236
|
+
className: WebdavSyncCard_module_default.field,
|
|
237
|
+
children: [
|
|
238
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("label", {
|
|
239
|
+
className: WebdavSyncCard_module_default.fieldLabel,
|
|
240
|
+
htmlFor: "webdav-sync-export-passphrase",
|
|
241
|
+
children: t("exportPassphraseLabel")
|
|
242
|
+
}),
|
|
243
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
244
|
+
className: WebdavSyncCard_module_default.fieldHint,
|
|
245
|
+
children: t("exportPassphraseHint")
|
|
246
|
+
}),
|
|
247
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Input, {
|
|
248
|
+
id: "webdav-sync-export-passphrase",
|
|
249
|
+
type: "password",
|
|
250
|
+
value: state.exportPassphrase,
|
|
251
|
+
autoComplete: "new-password",
|
|
252
|
+
onChange: (event) => props.editExport("passphrase", event.target.value)
|
|
253
|
+
})
|
|
254
|
+
]
|
|
255
|
+
}),
|
|
256
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
257
|
+
className: WebdavSyncCard_module_default.field,
|
|
258
|
+
children: [
|
|
259
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("label", {
|
|
260
|
+
className: WebdavSyncCard_module_default.fieldLabel,
|
|
261
|
+
htmlFor: "webdav-sync-export-dir",
|
|
262
|
+
children: t("exportDirLabel")
|
|
263
|
+
}),
|
|
264
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
265
|
+
className: WebdavSyncCard_module_default.fieldHint,
|
|
266
|
+
children: t("exportDirHint")
|
|
267
|
+
}),
|
|
268
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
269
|
+
className: WebdavSyncCard_module_default.backupPathRow,
|
|
270
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Input, {
|
|
271
|
+
id: "webdav-sync-export-dir",
|
|
272
|
+
value: state.exportDir,
|
|
273
|
+
placeholder: t("exportDirPlaceholder"),
|
|
274
|
+
onChange: (event) => props.editExport("dir", event.target.value)
|
|
275
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
|
|
276
|
+
variant: "outline",
|
|
277
|
+
size: "sm",
|
|
278
|
+
disabled: busy,
|
|
279
|
+
onClick: () => props.pickExportDir(),
|
|
280
|
+
children: t("exportDirChoose")
|
|
281
|
+
})]
|
|
282
|
+
})
|
|
283
|
+
]
|
|
284
|
+
}),
|
|
285
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
286
|
+
className: WebdavSyncCard_module_default.backupFormFooter,
|
|
287
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
|
|
288
|
+
variant: "primary",
|
|
289
|
+
size: "sm",
|
|
290
|
+
disabled: busy,
|
|
291
|
+
onClick: () => props.runExport(),
|
|
292
|
+
children: state.busy === "export" ? t("working") : t("exportConfirm")
|
|
293
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
|
|
294
|
+
variant: "outline",
|
|
295
|
+
size: "sm",
|
|
296
|
+
disabled: busy,
|
|
297
|
+
onClick: () => props.closeExport(),
|
|
298
|
+
children: t("exportCancel")
|
|
299
|
+
})]
|
|
300
|
+
})
|
|
301
|
+
]
|
|
302
|
+
}),
|
|
303
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
304
|
+
className: WebdavSyncCard_module_default.backupRow,
|
|
305
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
306
|
+
className: WebdavSyncCard_module_default.backupText,
|
|
307
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
308
|
+
className: WebdavSyncCard_module_default.backupTitle,
|
|
309
|
+
children: t("importTitle")
|
|
310
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
311
|
+
className: WebdavSyncCard_module_default.backupHint,
|
|
312
|
+
children: t("importHint")
|
|
313
|
+
})]
|
|
314
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
|
|
315
|
+
variant: "ghost",
|
|
316
|
+
size: "sm",
|
|
317
|
+
className: WebdavSyncCard_module_default.backupAction,
|
|
318
|
+
disabled: busy,
|
|
319
|
+
"aria-expanded": state.importOpen,
|
|
320
|
+
onClick: () => state.importOpen ? props.closeImport() : props.openImport(),
|
|
321
|
+
children: t("importAction")
|
|
322
|
+
})]
|
|
323
|
+
}),
|
|
324
|
+
state.importOpen && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
325
|
+
className: WebdavSyncCard_module_default.backupForm,
|
|
326
|
+
children: [
|
|
327
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
328
|
+
className: WebdavSyncCard_module_default.field,
|
|
329
|
+
children: [
|
|
330
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("label", {
|
|
331
|
+
className: WebdavSyncCard_module_default.fieldLabel,
|
|
332
|
+
htmlFor: "webdav-sync-import-passphrase",
|
|
333
|
+
children: t("importPassphraseLabel")
|
|
334
|
+
}),
|
|
335
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
336
|
+
className: WebdavSyncCard_module_default.fieldHint,
|
|
337
|
+
children: t("importPassphraseHint")
|
|
338
|
+
}),
|
|
339
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Input, {
|
|
340
|
+
id: "webdav-sync-import-passphrase",
|
|
341
|
+
type: "password",
|
|
342
|
+
value: state.importPassphrase,
|
|
343
|
+
autoComplete: "new-password",
|
|
344
|
+
onChange: (event) => props.editImport("passphrase", event.target.value)
|
|
345
|
+
})
|
|
346
|
+
]
|
|
347
|
+
}),
|
|
348
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
349
|
+
className: WebdavSyncCard_module_default.field,
|
|
350
|
+
children: [
|
|
351
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
352
|
+
className: WebdavSyncCard_module_default.fieldLabel,
|
|
353
|
+
children: t("importFileLabel")
|
|
354
|
+
}),
|
|
355
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
356
|
+
className: WebdavSyncCard_module_default.fieldHint,
|
|
357
|
+
children: t("importFileHint")
|
|
358
|
+
}),
|
|
359
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
360
|
+
className: WebdavSyncCard_module_default.backupPathRow,
|
|
361
|
+
children: [
|
|
362
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
363
|
+
className: WebdavSyncCard_module_default.backupFileName,
|
|
364
|
+
children: state.importName === "" ? t("importNoFile") : state.importName
|
|
365
|
+
}),
|
|
366
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
|
|
367
|
+
variant: "outline",
|
|
368
|
+
size: "sm",
|
|
369
|
+
disabled: busy,
|
|
370
|
+
onClick: () => file.current?.click(),
|
|
371
|
+
children: t("importFileChoose")
|
|
372
|
+
}),
|
|
373
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
374
|
+
ref: file,
|
|
375
|
+
type: "file",
|
|
376
|
+
accept: ".prop,application/json",
|
|
377
|
+
className: WebdavSyncCard_module_default.backupFileInput,
|
|
378
|
+
onChange: (event) => {
|
|
379
|
+
const picked = event.target.files?.[0];
|
|
380
|
+
event.target.value = "";
|
|
381
|
+
if (picked !== void 0) props.pickFile(picked);
|
|
382
|
+
}
|
|
383
|
+
})
|
|
384
|
+
]
|
|
385
|
+
})
|
|
386
|
+
]
|
|
387
|
+
}),
|
|
388
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
389
|
+
className: WebdavSyncCard_module_default.backupFormFooter,
|
|
390
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
|
|
391
|
+
variant: "primary",
|
|
392
|
+
size: "sm",
|
|
393
|
+
disabled: busy,
|
|
394
|
+
onClick: () => props.runImport(),
|
|
395
|
+
children: state.busy === "import" ? t("working") : t("importConfirm")
|
|
396
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
|
|
397
|
+
variant: "outline",
|
|
398
|
+
size: "sm",
|
|
399
|
+
disabled: busy,
|
|
400
|
+
onClick: () => props.closeImport(),
|
|
401
|
+
children: t("importCancel")
|
|
402
|
+
})]
|
|
403
|
+
})
|
|
404
|
+
]
|
|
405
|
+
}),
|
|
406
|
+
state.exported !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
407
|
+
className: WebdavSyncCard_module_default.note,
|
|
408
|
+
role: "status",
|
|
409
|
+
children: t("exportDone", {
|
|
410
|
+
file: state.exported.file,
|
|
411
|
+
size: sizeText(state.exported.size),
|
|
412
|
+
parts: names(state.exported.parts)
|
|
413
|
+
})
|
|
414
|
+
}),
|
|
415
|
+
state.imported !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
416
|
+
className: WebdavSyncCard_module_default.note,
|
|
417
|
+
role: "status",
|
|
418
|
+
children: [
|
|
419
|
+
state.imported.here.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
420
|
+
className: WebdavSyncCard_module_default.note,
|
|
421
|
+
children: t("importDone", { parts: names(state.imported.here) })
|
|
422
|
+
}) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
423
|
+
className: WebdavSyncCard_module_default.note,
|
|
424
|
+
children: t("importSame", { parts: names(state.imported.same) })
|
|
425
|
+
}),
|
|
426
|
+
state.imported.pendingInstall && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
427
|
+
className: WebdavSyncCard_module_default.note,
|
|
428
|
+
children: t("importPending")
|
|
429
|
+
}),
|
|
430
|
+
state.imported.saved !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
431
|
+
className: WebdavSyncCard_module_default.note,
|
|
432
|
+
children: t("noticeSaved", { dir: state.imported.saved })
|
|
433
|
+
})
|
|
434
|
+
]
|
|
435
|
+
})
|
|
436
|
+
] });
|
|
437
|
+
}
|
|
438
|
+
//#endregion
|
|
147
439
|
//#region src/client/RelayPanel.tsx
|
|
148
440
|
/**
|
|
149
441
|
* The forwarder's section of the card.
|
|
@@ -315,6 +607,25 @@ window.__ModuleLoader__.load({
|
|
|
315
607
|
] });
|
|
316
608
|
}
|
|
317
609
|
//#endregion
|
|
610
|
+
//#region src/client/status-polling.ts
|
|
611
|
+
/** Re-read local status while the full card is mounted, never run a sync. */
|
|
612
|
+
function watchStatus(poll) {
|
|
613
|
+
const refresh = () => {
|
|
614
|
+
if (document.visibilityState !== "hidden") poll();
|
|
615
|
+
};
|
|
616
|
+
refresh();
|
|
617
|
+
const timer = setInterval(refresh, 15e3);
|
|
618
|
+
window.addEventListener("focus", refresh);
|
|
619
|
+
window.addEventListener("online", refresh);
|
|
620
|
+
document.addEventListener("visibilitychange", refresh);
|
|
621
|
+
return () => {
|
|
622
|
+
clearInterval(timer);
|
|
623
|
+
window.removeEventListener("focus", refresh);
|
|
624
|
+
window.removeEventListener("online", refresh);
|
|
625
|
+
document.removeEventListener("visibilitychange", refresh);
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
//#endregion
|
|
318
629
|
//#region src/client/WebdavSyncCard.tsx
|
|
319
630
|
/**
|
|
320
631
|
* The WebDAV sync card, rendered in two seats from one body:
|
|
@@ -361,15 +672,6 @@ window.__ModuleLoader__.load({
|
|
|
361
672
|
}
|
|
362
673
|
/** The value of the auto-sync control's first tab, which is not an interval. */
|
|
363
674
|
const AUTO_OFF = "off";
|
|
364
|
-
/**
|
|
365
|
-
* How often the card re-reads the status while it is open, in milliseconds.
|
|
366
|
-
*
|
|
367
|
-
* The host's own timer works in minutes, so anything in that range would do;
|
|
368
|
-
* this is small enough that a page left open shows a sync as soon as it happens
|
|
369
|
-
* and large enough that the host is not being asked constantly. Nothing here
|
|
370
|
-
* reaches the server: `status` reads the profile and the state file.
|
|
371
|
-
*/
|
|
372
|
-
const STATUS_EVERY_MS = 15e3;
|
|
373
675
|
/** Which dictionary key names the action in flight. */
|
|
374
676
|
const BUSY_TEXT = {
|
|
375
677
|
check: "check",
|
|
@@ -437,12 +739,11 @@ window.__ModuleLoader__.load({
|
|
|
437
739
|
const state = props.useWebdavSync((snapshot) => snapshot);
|
|
438
740
|
(0, react.useEffect)(() => {
|
|
439
741
|
if (onPage) props.ensure();
|
|
440
|
-
}, [onPage]);
|
|
742
|
+
}, [onPage, props.ensure]);
|
|
441
743
|
(0, react.useEffect)(() => {
|
|
442
744
|
if (!onPage) return void 0;
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
}, [onPage]);
|
|
745
|
+
return watchStatus(props.poll);
|
|
746
|
+
}, [onPage, props.poll]);
|
|
446
747
|
if (state.phase === "loading") return onPage ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
447
748
|
className: WebdavSyncCard_module_default.note,
|
|
448
749
|
role: "status",
|
|
@@ -472,7 +773,7 @@ window.__ModuleLoader__.load({
|
|
|
472
773
|
size: "sm",
|
|
473
774
|
className: `${WebdavSyncCard_module_default.textAction} ${WebdavSyncCard_module_default.textActionPrimary}`,
|
|
474
775
|
onClick: () => props.refresh(),
|
|
475
|
-
children: t("
|
|
776
|
+
children: t("refreshStatus")
|
|
476
777
|
})
|
|
477
778
|
})]
|
|
478
779
|
});
|
|
@@ -483,10 +784,14 @@ window.__ModuleLoader__.load({
|
|
|
483
784
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
484
785
|
className: WebdavSyncCard_module_default.page,
|
|
485
786
|
children: [
|
|
486
|
-
state.error !== "" && /* @__PURE__ */ (0, react_jsx_runtime.
|
|
787
|
+
state.error !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
|
|
487
788
|
className: WebdavSyncCard_module_default.failed,
|
|
488
789
|
role: "alert",
|
|
489
|
-
children:
|
|
790
|
+
children: [
|
|
791
|
+
t("statusStale"),
|
|
792
|
+
" ",
|
|
793
|
+
state.error
|
|
794
|
+
]
|
|
490
795
|
}),
|
|
491
796
|
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", { children: [
|
|
492
797
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h4", {
|
|
@@ -601,9 +906,21 @@ window.__ModuleLoader__.load({
|
|
|
601
906
|
}
|
|
602
907
|
})] }),
|
|
603
908
|
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", { children: [
|
|
604
|
-
/* @__PURE__ */ (0, react_jsx_runtime.
|
|
605
|
-
className: WebdavSyncCard_module_default.legend
|
|
606
|
-
children: t("partsLegend")
|
|
909
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("h4", {
|
|
910
|
+
className: `${WebdavSyncCard_module_default.legend} ${WebdavSyncCard_module_default.legendAction}`,
|
|
911
|
+
children: [t("partsLegend"), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Tooltip, {
|
|
912
|
+
label: t("refreshStatus"),
|
|
913
|
+
side: "top",
|
|
914
|
+
portal: true,
|
|
915
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
|
|
916
|
+
variant: "ghost",
|
|
917
|
+
size: "sm",
|
|
918
|
+
className: WebdavSyncCard_module_default.legendIcon,
|
|
919
|
+
icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconRefreshOutlineRegular, {}),
|
|
920
|
+
"aria-label": t("refreshStatus"),
|
|
921
|
+
onClick: () => props.refresh()
|
|
922
|
+
})
|
|
923
|
+
})]
|
|
607
924
|
}),
|
|
608
925
|
(state.view?.parts ?? []).filter(isToggledPart).map((part) => {
|
|
609
926
|
const on = state[part.part];
|
|
@@ -766,6 +1083,13 @@ window.__ModuleLoader__.load({
|
|
|
766
1083
|
]
|
|
767
1084
|
})
|
|
768
1085
|
] }),
|
|
1086
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(ManualBackupPanel, {
|
|
1087
|
+
...props,
|
|
1088
|
+
t,
|
|
1089
|
+
state,
|
|
1090
|
+
busy,
|
|
1091
|
+
partName: (part) => t(PART_NAME[part])
|
|
1092
|
+
}),
|
|
769
1093
|
state.view?.notice !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
770
1094
|
className: WebdavSyncCard_module_default.notice,
|
|
771
1095
|
children: [
|
|
@@ -939,12 +1263,13 @@ window.__ModuleLoader__.load({
|
|
|
939
1263
|
//#endregion
|
|
940
1264
|
//#region src/client/api.ts
|
|
941
1265
|
/** Send one JSON request and fold every failure into a message. */
|
|
942
|
-
async function send(method, url, body) {
|
|
1266
|
+
async function send(method, url, body, signal) {
|
|
943
1267
|
let response;
|
|
944
1268
|
try {
|
|
945
1269
|
response = await fetch(url, {
|
|
946
1270
|
method,
|
|
947
1271
|
cache: "no-store",
|
|
1272
|
+
...signal === void 0 ? {} : { signal },
|
|
948
1273
|
headers: { "content-type": "application/json" },
|
|
949
1274
|
...body === void 0 ? {} : { body: JSON.stringify(body) }
|
|
950
1275
|
});
|
|
@@ -975,9 +1300,24 @@ window.__ModuleLoader__.load({
|
|
|
975
1300
|
data: payload
|
|
976
1301
|
};
|
|
977
1302
|
}
|
|
978
|
-
/**
|
|
979
|
-
function requestStatus() {
|
|
980
|
-
|
|
1303
|
+
/** Bound status reads (including the body), without timing out long sync writes. */
|
|
1304
|
+
async function requestStatus() {
|
|
1305
|
+
const controller = new AbortController();
|
|
1306
|
+
let timer;
|
|
1307
|
+
const timeout = new Promise((resolve) => {
|
|
1308
|
+
timer = setTimeout(() => {
|
|
1309
|
+
resolve({
|
|
1310
|
+
ok: false,
|
|
1311
|
+
error: "读取同步状态超时(10 秒)"
|
|
1312
|
+
});
|
|
1313
|
+
controller.abort();
|
|
1314
|
+
}, 1e4);
|
|
1315
|
+
});
|
|
1316
|
+
try {
|
|
1317
|
+
return await Promise.race([send("GET", ROUTE_STATUS, void 0, controller.signal), timeout]);
|
|
1318
|
+
} finally {
|
|
1319
|
+
clearTimeout(timer);
|
|
1320
|
+
}
|
|
981
1321
|
}
|
|
982
1322
|
/** Store the address, account, passphrase and part switches. */
|
|
983
1323
|
function requestConfigure(body) {
|
|
@@ -1019,6 +1359,14 @@ window.__ModuleLoader__.load({
|
|
|
1019
1359
|
function requestOff() {
|
|
1020
1360
|
return send("POST", ROUTE_OFF, {});
|
|
1021
1361
|
}
|
|
1362
|
+
/** Write one sealed backup file into a folder this computer can reach. */
|
|
1363
|
+
function requestExport(body) {
|
|
1364
|
+
return send("POST", ROUTE_EXPORT, body);
|
|
1365
|
+
}
|
|
1366
|
+
/** Open a picked backup file's text and put what it holds on this computer. */
|
|
1367
|
+
function requestImport(body) {
|
|
1368
|
+
return send("POST", ROUTE_IMPORT, body);
|
|
1369
|
+
}
|
|
1022
1370
|
//#endregion
|
|
1023
1371
|
//#region src/client/card-store.ts
|
|
1024
1372
|
/**
|
|
@@ -1060,13 +1408,24 @@ window.__ModuleLoader__.load({
|
|
|
1060
1408
|
installFailed: false,
|
|
1061
1409
|
keysProblems: [],
|
|
1062
1410
|
reloadNeeded: false,
|
|
1063
|
-
confirm: ""
|
|
1411
|
+
confirm: "",
|
|
1412
|
+
exportOpen: false,
|
|
1413
|
+
exportPassphrase: "",
|
|
1414
|
+
exportDir: "",
|
|
1415
|
+
importOpen: false,
|
|
1416
|
+
importPassphrase: "",
|
|
1417
|
+
importName: "",
|
|
1418
|
+
importText: "",
|
|
1419
|
+
exported: void 0,
|
|
1420
|
+
imported: void 0
|
|
1064
1421
|
};
|
|
1065
1422
|
/** The card: an observable snapshot, the staged draft, and every write. */
|
|
1066
1423
|
var WebdavSyncCard = class {
|
|
1067
1424
|
state = EMPTY;
|
|
1068
1425
|
listeners = /* @__PURE__ */ new Set();
|
|
1069
1426
|
started = false;
|
|
1427
|
+
statusSequence = 0;
|
|
1428
|
+
statusReads = 0;
|
|
1070
1429
|
/** The directory dialog of this deployment, when it has one. */
|
|
1071
1430
|
choose;
|
|
1072
1431
|
/**
|
|
@@ -1151,48 +1510,63 @@ window.__ModuleLoader__.load({
|
|
|
1151
1510
|
this.load();
|
|
1152
1511
|
}
|
|
1153
1512
|
async load() {
|
|
1513
|
+
this.statusReads += 1;
|
|
1514
|
+
const sequence = ++this.statusSequence;
|
|
1154
1515
|
this.set({
|
|
1155
1516
|
phase: this.state.view === void 0 ? "loading" : this.state.phase,
|
|
1156
1517
|
error: ""
|
|
1157
1518
|
});
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
this.
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1519
|
+
try {
|
|
1520
|
+
const answer = await requestStatus();
|
|
1521
|
+
if (sequence !== this.statusSequence) return;
|
|
1522
|
+
if (!answer.ok) {
|
|
1523
|
+
this.set({
|
|
1524
|
+
phase: "ready",
|
|
1525
|
+
error: answer.error
|
|
1526
|
+
});
|
|
1527
|
+
return;
|
|
1528
|
+
}
|
|
1529
|
+
this.adopt(answer.data);
|
|
1530
|
+
} finally {
|
|
1531
|
+
this.statusReads -= 1;
|
|
1166
1532
|
}
|
|
1167
|
-
this.adopt(answer.data);
|
|
1168
1533
|
}
|
|
1534
|
+
/** A manual status refresh, not a sync and not a reset of the draft. */
|
|
1169
1535
|
refresh() {
|
|
1170
|
-
this.
|
|
1536
|
+
this.poll();
|
|
1171
1537
|
}
|
|
1172
|
-
/**
|
|
1173
|
-
* A read the person did not ask for, for a timer. A first read already in
|
|
1174
|
-
* flight is allowed to land rather than asked for twice, and nothing here ever
|
|
1175
|
-
* reports a failure: the buttons are the paths that say what went wrong, and a
|
|
1176
|
-
* read that quietly could not be answered leaves the card showing the last
|
|
1177
|
-
* thing the host did say.
|
|
1178
|
-
*/
|
|
1538
|
+
/** Periodic and foreground reads share one bounded request per card. */
|
|
1179
1539
|
poll() {
|
|
1180
|
-
if (this.state.phase === "loading") return;
|
|
1540
|
+
if (this.state.phase === "loading" || this.statusReads > 0) return;
|
|
1181
1541
|
this.reloadStatus();
|
|
1182
1542
|
}
|
|
1183
1543
|
/** Re-read the status and fold it in without disturbing what is being typed. */
|
|
1184
1544
|
async reloadStatus() {
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1545
|
+
this.statusReads += 1;
|
|
1546
|
+
const sequence = ++this.statusSequence;
|
|
1547
|
+
try {
|
|
1548
|
+
const answer = await requestStatus();
|
|
1549
|
+
if (sequence !== this.statusSequence) return;
|
|
1550
|
+
if (!answer.ok) {
|
|
1551
|
+
this.set({
|
|
1552
|
+
phase: "ready",
|
|
1553
|
+
error: answer.error
|
|
1554
|
+
});
|
|
1555
|
+
return;
|
|
1556
|
+
}
|
|
1557
|
+
if (this.state.dirty) {
|
|
1558
|
+
this.set({
|
|
1559
|
+
phase: "ready",
|
|
1560
|
+
view: answer.data,
|
|
1561
|
+
error: ""
|
|
1562
|
+
});
|
|
1563
|
+
return;
|
|
1564
|
+
}
|
|
1565
|
+
this.set({ error: "" });
|
|
1566
|
+
this.adopt(answer.data);
|
|
1567
|
+
} finally {
|
|
1568
|
+
this.statusReads -= 1;
|
|
1193
1569
|
}
|
|
1194
|
-
this.set({ error: "" });
|
|
1195
|
-
this.adopt(answer.data);
|
|
1196
1570
|
}
|
|
1197
1571
|
edit(field, value) {
|
|
1198
1572
|
this.set({
|
|
@@ -1357,6 +1731,126 @@ window.__ModuleLoader__.load({
|
|
|
1357
1731
|
installFailed: false
|
|
1358
1732
|
});
|
|
1359
1733
|
}
|
|
1734
|
+
openExport() {
|
|
1735
|
+
this.set({
|
|
1736
|
+
exportOpen: true,
|
|
1737
|
+
importOpen: false,
|
|
1738
|
+
message: "",
|
|
1739
|
+
messageFailed: false
|
|
1740
|
+
});
|
|
1741
|
+
}
|
|
1742
|
+
closeExport() {
|
|
1743
|
+
this.set({
|
|
1744
|
+
exportOpen: false,
|
|
1745
|
+
exportPassphrase: "",
|
|
1746
|
+
exportDir: ""
|
|
1747
|
+
});
|
|
1748
|
+
}
|
|
1749
|
+
editExport(field, value) {
|
|
1750
|
+
if (field === "passphrase") {
|
|
1751
|
+
this.set({ exportPassphrase: value });
|
|
1752
|
+
return;
|
|
1753
|
+
}
|
|
1754
|
+
this.set({ exportDir: value });
|
|
1755
|
+
}
|
|
1756
|
+
pickExportDir() {
|
|
1757
|
+
const choose = this.choose;
|
|
1758
|
+
if (choose === void 0) return;
|
|
1759
|
+
choose().then((chosen) => {
|
|
1760
|
+
const path = (chosen ?? "").trim();
|
|
1761
|
+
if (path !== "") this.set({ exportDir: path });
|
|
1762
|
+
}, () => void 0);
|
|
1763
|
+
}
|
|
1764
|
+
runExport() {
|
|
1765
|
+
const passphrase = this.state.exportPassphrase.trim();
|
|
1766
|
+
if (passphrase === "") {
|
|
1767
|
+
this.set({
|
|
1768
|
+
message: "导出需要一个口令来加密这个文件",
|
|
1769
|
+
messageFailed: true
|
|
1770
|
+
});
|
|
1771
|
+
return;
|
|
1772
|
+
}
|
|
1773
|
+
this.run("export", () => requestExport({
|
|
1774
|
+
passphrase,
|
|
1775
|
+
dir: this.state.exportDir.trim()
|
|
1776
|
+
}), (result) => ({
|
|
1777
|
+
exportOpen: false,
|
|
1778
|
+
exportPassphrase: "",
|
|
1779
|
+
exportDir: "",
|
|
1780
|
+
exported: result,
|
|
1781
|
+
imported: void 0,
|
|
1782
|
+
message: "",
|
|
1783
|
+
messageFailed: false
|
|
1784
|
+
}));
|
|
1785
|
+
}
|
|
1786
|
+
openImport() {
|
|
1787
|
+
this.set({
|
|
1788
|
+
importOpen: true,
|
|
1789
|
+
exportOpen: false,
|
|
1790
|
+
message: "",
|
|
1791
|
+
messageFailed: false
|
|
1792
|
+
});
|
|
1793
|
+
}
|
|
1794
|
+
closeImport() {
|
|
1795
|
+
this.set({
|
|
1796
|
+
importOpen: false,
|
|
1797
|
+
importPassphrase: "",
|
|
1798
|
+
importName: "",
|
|
1799
|
+
importText: ""
|
|
1800
|
+
});
|
|
1801
|
+
}
|
|
1802
|
+
editImport(field, value) {
|
|
1803
|
+
if (field === "passphrase") this.set({ importPassphrase: value });
|
|
1804
|
+
}
|
|
1805
|
+
pickFile(file) {
|
|
1806
|
+
file.text().then((text) => {
|
|
1807
|
+
this.set({
|
|
1808
|
+
importName: file.name,
|
|
1809
|
+
importText: text,
|
|
1810
|
+
message: "",
|
|
1811
|
+
messageFailed: false
|
|
1812
|
+
});
|
|
1813
|
+
}, (cause) => {
|
|
1814
|
+
this.set({
|
|
1815
|
+
importName: "",
|
|
1816
|
+
importText: "",
|
|
1817
|
+
message: `读不了这个文件:${cause instanceof Error ? cause.message : String(cause)}`,
|
|
1818
|
+
messageFailed: true
|
|
1819
|
+
});
|
|
1820
|
+
});
|
|
1821
|
+
}
|
|
1822
|
+
runImport() {
|
|
1823
|
+
const passphrase = this.state.importPassphrase.trim();
|
|
1824
|
+
if (passphrase === "") {
|
|
1825
|
+
this.set({
|
|
1826
|
+
message: "导入需要这个文件的口令",
|
|
1827
|
+
messageFailed: true
|
|
1828
|
+
});
|
|
1829
|
+
return;
|
|
1830
|
+
}
|
|
1831
|
+
if (this.state.importText === "") {
|
|
1832
|
+
this.set({
|
|
1833
|
+
message: "还没有选择文件",
|
|
1834
|
+
messageFailed: true
|
|
1835
|
+
});
|
|
1836
|
+
return;
|
|
1837
|
+
}
|
|
1838
|
+
this.run("import", () => requestImport({
|
|
1839
|
+
passphrase,
|
|
1840
|
+
text: this.state.importText
|
|
1841
|
+
}), (result) => ({
|
|
1842
|
+
importOpen: false,
|
|
1843
|
+
importPassphrase: "",
|
|
1844
|
+
importName: "",
|
|
1845
|
+
importText: "",
|
|
1846
|
+
exported: void 0,
|
|
1847
|
+
imported: result,
|
|
1848
|
+
reloadNeeded: result.reloadNeeded === true,
|
|
1849
|
+
keysProblems: keyProblemsOf(result),
|
|
1850
|
+
message: "",
|
|
1851
|
+
messageFailed: false
|
|
1852
|
+
})).then(() => this.load());
|
|
1853
|
+
}
|
|
1360
1854
|
answer(yes) {
|
|
1361
1855
|
const kind = this.state.confirm;
|
|
1362
1856
|
this.set({ confirm: "" });
|
|
@@ -1405,7 +1899,17 @@ window.__ModuleLoader__.load({
|
|
|
1405
1899
|
answer: (yes) => this.answer(yes),
|
|
1406
1900
|
install: () => this.install(),
|
|
1407
1901
|
dismiss: () => this.dismiss(),
|
|
1408
|
-
closeOutput: () => this.closeOutput()
|
|
1902
|
+
closeOutput: () => this.closeOutput(),
|
|
1903
|
+
openExport: () => this.openExport(),
|
|
1904
|
+
closeExport: () => this.closeExport(),
|
|
1905
|
+
editExport: (field, value) => this.editExport(field, value),
|
|
1906
|
+
pickExportDir: () => this.pickExportDir(),
|
|
1907
|
+
runExport: () => this.runExport(),
|
|
1908
|
+
openImport: () => this.openImport(),
|
|
1909
|
+
closeImport: () => this.closeImport(),
|
|
1910
|
+
editImport: (field, value) => this.editImport(field, value),
|
|
1911
|
+
pickFile: (file) => this.pickFile(file),
|
|
1912
|
+
runImport: () => this.runImport()
|
|
1409
1913
|
};
|
|
1410
1914
|
}
|
|
1411
1915
|
};
|
|
@@ -1470,6 +1974,8 @@ window.__ModuleLoader__.load({
|
|
|
1470
1974
|
neverChecked: "尚未检查过",
|
|
1471
1975
|
lastSync: "上次同步:{at}",
|
|
1472
1976
|
neverSynced: "尚未同步过",
|
|
1977
|
+
refreshStatus: "刷新状态",
|
|
1978
|
+
statusStale: "状态刷新失败,显示的时间可能已过期;将自动重试。",
|
|
1473
1979
|
noticeTitle: "上次同步替换了内容",
|
|
1474
1980
|
noticeHere: "服务器覆盖了本机:{parts}",
|
|
1475
1981
|
noticeThere: "本机覆盖了服务器:{parts}",
|
|
@@ -1514,6 +2020,33 @@ window.__ModuleLoader__.load({
|
|
|
1514
2020
|
dirProblem: "「{path}」这个目录没读成:{message}",
|
|
1515
2021
|
keyProblem: "「{ref}」这个 API 密钥没读成:{message}",
|
|
1516
2022
|
keyStoreProblem: "本机存的 API 密钥没读成:{message}",
|
|
2023
|
+
backupLegend: "手动备份",
|
|
2024
|
+
exportTitle: "导出",
|
|
2025
|
+
exportHint: "把上面这些打包成一个用口令加密的文件,带到另一台电脑",
|
|
2026
|
+
exportAction: "导出…",
|
|
2027
|
+
importTitle: "导入",
|
|
2028
|
+
importHint: "导入从本插件导出的文件,用它替换上面这些内容",
|
|
2029
|
+
importAction: "导入…",
|
|
2030
|
+
exportPassphraseLabel: "这个文件的口令",
|
|
2031
|
+
exportPassphraseHint: "这个口令只用来加密这份文件,与上面的同步口令无关;带到另一台电脑时要一起记住它。",
|
|
2032
|
+
exportDirLabel: "保存到",
|
|
2033
|
+
exportDirHint: "一个目录,或者一个以 .prop 结尾的文件名;留空保存到用户主目录。",
|
|
2034
|
+
exportDirPlaceholder: "留空保存到用户主目录",
|
|
2035
|
+
exportDirChoose: "选择目录",
|
|
2036
|
+
exportConfirm: "导出",
|
|
2037
|
+
exportCancel: "取消",
|
|
2038
|
+
importPassphraseLabel: "文件的口令",
|
|
2039
|
+
importPassphraseHint: "输入导出这份文件时用的那个口令。",
|
|
2040
|
+
importFileLabel: "要导入的文件",
|
|
2041
|
+
importFileHint: "选一个从本插件导出的 .prop 文件。",
|
|
2042
|
+
importFileChoose: "选择文件",
|
|
2043
|
+
importConfirm: "导入",
|
|
2044
|
+
importCancel: "取消",
|
|
2045
|
+
exportDone: "已导出到 {file}({size},含{parts})。",
|
|
2046
|
+
importDone: "已导入:{parts}。",
|
|
2047
|
+
importSame: "已是最新:{parts} 和文件里的一样,没有改动。",
|
|
2048
|
+
importPending: "文件里的插件还没安装,请在下面点「安装缺少的插件」。",
|
|
2049
|
+
importNoFile: "还没有选择文件。",
|
|
1517
2050
|
relayLegend: "本机转发",
|
|
1518
2051
|
relayEnable: "启用本机转发",
|
|
1519
2052
|
relayListening: "正在转发",
|
|
@@ -1570,6 +2103,8 @@ window.__ModuleLoader__.load({
|
|
|
1570
2103
|
neverChecked: "Never checked yet",
|
|
1571
2104
|
lastSync: "Last sync: {at}",
|
|
1572
2105
|
neverSynced: "Never synced yet",
|
|
2106
|
+
refreshStatus: "Refresh status",
|
|
2107
|
+
statusStale: "Status refresh failed; the displayed times may be stale. Retrying automatically.",
|
|
1573
2108
|
noticeTitle: "The last sync replaced something",
|
|
1574
2109
|
noticeHere: "The server replaced this computer: {parts}",
|
|
1575
2110
|
noticeThere: "This computer replaced the server: {parts}",
|
|
@@ -1614,6 +2149,33 @@ window.__ModuleLoader__.load({
|
|
|
1614
2149
|
dirProblem: "Could not read the directory {path}: {message}",
|
|
1615
2150
|
keyProblem: "Could not read the API key {ref}: {message}",
|
|
1616
2151
|
keyStoreProblem: "Could not read the stored API keys: {message}",
|
|
2152
|
+
backupLegend: "Manual backup",
|
|
2153
|
+
exportTitle: "Export",
|
|
2154
|
+
exportHint: "Pack everything above into one passphrase-encrypted file to carry to another computer",
|
|
2155
|
+
exportAction: "Export…",
|
|
2156
|
+
importTitle: "Import",
|
|
2157
|
+
importHint: "Import a file this plugin exported, replacing what is above",
|
|
2158
|
+
importAction: "Import…",
|
|
2159
|
+
exportPassphraseLabel: "Passphrase for this file",
|
|
2160
|
+
exportPassphraseHint: "It seals this file only, not the sync above; you will need it again on the other computer.",
|
|
2161
|
+
exportDirLabel: "Save to",
|
|
2162
|
+
exportDirHint: "A directory, or a file name ending in .prop; blank saves to your home directory.",
|
|
2163
|
+
exportDirPlaceholder: "Blank saves to your home directory",
|
|
2164
|
+
exportDirChoose: "Choose directory",
|
|
2165
|
+
exportConfirm: "Export",
|
|
2166
|
+
exportCancel: "Cancel",
|
|
2167
|
+
importPassphraseLabel: "Passphrase for the file",
|
|
2168
|
+
importPassphraseHint: "The one used when this file was exported.",
|
|
2169
|
+
importFileLabel: "File to import",
|
|
2170
|
+
importFileHint: "Pick a .prop file this plugin exported.",
|
|
2171
|
+
importFileChoose: "Choose file",
|
|
2172
|
+
importConfirm: "Import",
|
|
2173
|
+
importCancel: "Cancel",
|
|
2174
|
+
exportDone: "Exported to {file} ({size}, with {parts}).",
|
|
2175
|
+
importDone: "Imported: {parts}.",
|
|
2176
|
+
importSame: "Already up to date: {parts} match the file, so nothing changed.",
|
|
2177
|
+
importPending: "The file declares plugins that are not installed; use Install below.",
|
|
2178
|
+
importNoFile: "No file chosen yet.",
|
|
1617
2179
|
relayLegend: "Forwarding on this computer",
|
|
1618
2180
|
relayEnable: "Forward for other programs on this computer",
|
|
1619
2181
|
relayListening: "Forwarding",
|