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/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
- const timer = setInterval(() => props.poll(), STATUS_EVERY_MS);
444
- return () => clearInterval(timer);
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("check")
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.jsx)("p", {
787
+ state.error !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
487
788
  className: WebdavSyncCard_module_default.failed,
488
789
  role: "alert",
489
- children: state.error
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.jsx)("h4", {
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
- /** Everything the card draws from, including which parts disagree. */
979
- function requestStatus() {
980
- return send("GET", ROUTE_STATUS);
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
- const answer = await requestStatus();
1159
- if (!answer.ok) {
1160
- this.set({
1161
- phase: "ready",
1162
- error: answer.error,
1163
- view: void 0
1164
- });
1165
- return;
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.load();
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
- const answer = await requestStatus();
1186
- if (!answer.ok) return;
1187
- if (this.state.dirty) {
1188
- this.set({
1189
- view: answer.data,
1190
- error: ""
1191
- });
1192
- return;
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",