@chance722/dsh-inbox 0.2.2 → 0.2.4

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
@@ -624,6 +624,14 @@ window.__ModuleLoader__.load({
624
624
  var INBOX_ENDPOINT_TAGS = "tags";
625
625
  var INBOX_ENDPOINT_SECRET = "secret";
626
626
  var INBOX_ENDPOINT_PUSH = "push";
627
+ var DEFAULT_SYNC_DIRECTORY = "inbox";
628
+ function syncDirectory(raw) {
629
+ const trimmed = (raw ?? "").trim().replace(/^\/+|\/+$/g, "");
630
+ return trimmed.length === 0 ? DEFAULT_SYNC_DIRECTORY : trimmed;
631
+ }
632
+ function syncRootFor(raw) {
633
+ return `${syncDirectory(raw)}/sync`;
634
+ }
627
635
  function isSuccess(status) {
628
636
  return status >= 200 && status < 300;
629
637
  }
@@ -801,6 +809,11 @@ window.__ModuleLoader__.load({
801
809
  "settings.region": "\u533A\u57DF",
802
810
  "settings.bucketAddress": "\u5730\u5740",
803
811
  "settings.bucketDir": "\u76EE\u5F55",
812
+ "settings.adoptForeign": "\u540C\u65F6\u5408\u5E76\u522B\u7684\u540C\u6B65\u76EE\u5F55",
813
+ "settings.adoptForeignHint": "\uFF08\u6362\u8FC7\u76EE\u5F55\u7684\u673A\u5668\u7559\u5728\u4E91\u7AEF\u7684\u8BB0\u5F55\u4E5F\u4F1A\u62C9\u56DE\u6765\uFF1B\u9ED8\u8BA4\u5173\uFF09",
814
+ // The default is the thing people get wrong: blank and `/` are the same as
815
+ // `inbox`, and the sync tree is `<directory>/sync`.
816
+ "settings.directoryPlaceholder": "inbox\uFF08\u9ED8\u8BA4\uFF1B\u7559\u7A7A\u6216 / \u90FD\u7B49\u4E8E\u5B83\uFF09",
804
817
  "settings.dirS3.lead": "\u76EE\u5F55\u90A3\u4E00\u680F\u540C\u65F6\u662F S3 \u7684 key \u524D\u7F00\uFF08\u9ED8\u8BA4 ",
805
818
  "settings.dirS3.mid": "\uFF0C\u4F1A\u8F6C\u6210 ",
806
819
  "settings.dirS3.tail": "\uFF09\u3002",
@@ -816,6 +829,7 @@ window.__ModuleLoader__.load({
816
829
  "settings.save": "\u4FDD\u5B58",
817
830
  "settings.probe": "\u81EA\u68C0",
818
831
  "settings.details": "\u8BE6\u60C5\uFF08{count} \u6B21\u8BF7\u6C42\uFF09",
832
+ "settings.syncRootNow": "\u540C\u6B65\u6839\uFF1A{root}",
819
833
  "settings.noServiceBody": "\u8FD9\u4E2A\u7EC4\u5408\u91CC\u6CA1\u6709\u8BBE\u7F6E\u670D\u52A1\uFF0C\u5730\u5740\u6539\u4E0D\u4E86\u2014\u2014\u4F60\u591A\u534A\u5728\u7528 headless \u5F62\u6001\u5F00\u53D1\u3002",
820
834
  "settings.bucketHint": "\u522B\u7684\u8BBE\u5907\u5F80\u8FD9\u91CC\u6254\u4E1C\u897F",
821
835
  // Notices (the one-line status under the toolbar).
@@ -849,16 +863,24 @@ window.__ModuleLoader__.load({
849
863
  "sync.pushUnconfigured": "\u63A8\u9001\uFF1A\u8FD8\u6CA1\u914D\u7F6E\u8FDC\u7AEF",
850
864
  "sync.pushFailed": "\u63A8\u9001\u5931\u8D25\uFF1A{reason}",
851
865
  "sync.pushUnknown": "\u672A\u77E5\u539F\u56E0",
852
- "sync.pushUpToDate": "\u63A8\u9001\uFF1A\u6CA1\u6709\u65B0\u5185\u5BB9\uFF08{count} \u6761\u5DF2\u662F\u6700\u65B0\uFF09",
853
- "sync.pushDone": "\u63A8\u9001\uFF1A{records} \u6761\u8BB0\u5F55 / {attachments} \u4E2A\u9644\u4EF6",
854
866
  "sync.pushPartial": "{head} \xB7 \u90E8\u5206\u5931\u8D25\uFF1A{reason}",
855
867
  "sync.pullNoAddress": "\u8FD8\u6CA1\u914D\u7F6E\u5730\u5740",
856
868
  "sync.pullFailed": "\u62C9\u53D6\u5931\u8D25\uFF1A{reason}",
857
- "sync.pullNothingNew": "\u4E91\u7AEF\u7684\u8BB0\u5F55\u6CA1\u6709\u65B0\u7684",
858
- "sync.pullMerged": "\u4ECE\u4E91\u7AEF\u5408\u5E76 {count} \u6761{attachments}",
859
- "sync.pullAttachments": " / {count} \u4E2A\u9644\u4EF6",
860
- "sync.pullDone": "\u62C9\u53D6\u5B8C\u6210\uFF1A\u8FDC\u7AEF\u5217\u51FA {listed} \u9879\uFF0C\u65B0\u5165\u5E93 {pulled} \u6761\uFF0C\u8DF3\u8FC7 {skipped} \u6761{tail}",
861
- "sync.pullFailedCount": " \xB7 \u5931\u8D25 {count} \u6761",
869
+ // The refresh button's tooltip: counts first, reasons only when something was
870
+ // skipped. The toast carries the two numbers alone.
871
+ "sync.detailPushedIdle": "\u63A8\u9001 0 \u6761\uFF08{count} \u6761\u5DF2\u662F\u6700\u65B0\uFF09",
872
+ "sync.detailPushed": "\u63A8\u9001 {records} \u6761 / {attachments} \u4E2A\u9644\u4EF6",
873
+ "sync.detailPulled": "\u62C9\u53D6 {count} \u6761 \xB7 \u4E91\u7AEF {records} \u6761\u8BB0\u5F55 / {files} \u4E2A\u9644\u4EF6",
874
+ "sync.detailAdded": "\uFF08\u5176\u4E2D {count} \u6761\u662F\u65B0\u8BB0\u5F55\uFF09",
875
+ "sync.detailSkip": "\uFF08\u8DF3\u8FC7 {skipped}\uFF1A\u81EA\u5DF1\u7684 {sync}\u3001\u66F4\u65E7 {older}\uFF09",
876
+ "sync.pullForeignSync": " \xB7 \u26A0\uFE0F \u53E6\u6709 {records} \u6761\u8BB0\u5F55\u5728\u522B\u7684\u540C\u6B65\u76EE\u5F55\uFF1A{roots}",
877
+ "sync.detailForeign": " \xB7 \u26A0\uFE0F \u522B\u7684\u540C\u6B65\u76EE\u5F55\uFF1A{roots}\uFF08{records} \u6761\u8BB0\u5F55\uFF1B\u672C\u673A {ours}\uFF09",
878
+ // The toast: two numbers, nothing else. Everything else about a sync is
879
+ // detail, and detail belongs in a line that does not vanish.
880
+ "sync.shortPushed": "\u63A8\u9001 {count} \u6761",
881
+ "sync.shortPulled": "\u62C9\u53D6 {count} \u6761",
882
+ "sync.shortIdle": "\u5DF2\u662F\u6700\u65B0",
883
+ "sync.pullFailures": " \xB7 \u5931\u8D25 {count}\uFF1A{reason}",
862
884
  "sync.thisPage": " \xB7 \u672C\u9875 {count} \u6761",
863
885
  "settings.status": "{settings} \xB7 {webdav} \xB7 {s3}",
864
886
  "settings.statusOn": "\u8BBE\u7F6E\u670D\u52A1\u5728",
@@ -927,10 +949,15 @@ window.__ModuleLoader__.load({
927
949
  "manual.5.auto.body": "\u5165\u5E93/\u6539\u52A8\u540E\u51E0\u79D2\u81EA\u52A8\u63A8\u9001\u4E00\u6B21\uFF08\u9632\u6296\uFF0C\u8FDE\u7740\u5B58\u4E94\u6761\u53EA\u4F1A\u63A8\u4E00\u6B21\uFF09\u3002",
928
950
  "manual.5.manual.label": "\u624B\u52A8",
929
951
  "manual.5.manual.body": "\u53F3\u4E0A\u89D2\u300C\u5237\u65B0\u300D= \u4E00\u6B21\u5B8C\u6574\u540C\u6B65\uFF1A\u5148\u63A8\u672C\u673A\u6539\u52A8\uFF0C\u518D\u62C9\u522B\u4EBA\u7684\uFF0C\u7136\u540E\u91CD\u8BFB\u5217\u8868\u3002",
952
+ // The one rule that decides whether two machines see each other at all.
953
+ "manual.5.twoMachines.label": "\u4E24\u53F0\u673A\u5668",
954
+ "manual.5.twoMachines.body": "\u4E24\u8FB9\u7684\u300C\u76EE\u5F55\u300D\u8981\u4E00\u6837\uFF08\u7559\u7A7A\u3001/\u3001inbox \u662F\u540C\u4E00\u4E2A\u610F\u601D\uFF09\u3002\u4E0D\u4E00\u6837\u65F6\u9ED8\u8BA4\u4E0D\u4E92\u901A\uFF1A\u5237\u65B0\u4F1A\u63D0\u793A\u300C\u53E6\u6709 N \u6761\u8BB0\u5F55\u5728\u522B\u7684\u540C\u6B65\u76EE\u5F55\u300D\u2014\u2014\u628A\u5B83\u4EEC\u6539\u6210\u540C\u4E00\u4E2A\uFF0C\u6216\u8005\u6253\u5F00\u8BBE\u7F6E\u91CC\u7684\u300C\u540C\u65F6\u5408\u5E76\u522B\u7684\u540C\u6B65\u76EE\u5F55\u300D\uFF0C\u90FD\u80FD\u5408\u8FC7\u6765\u3002",
930
955
  "manual.5.delete.label": "\u5220\u9664",
931
956
  "manual.5.delete.body": "\u9762\u677F\u91CC\u7684\u300C\u5220\u9664\u300D\u53EA\u662F\u628A\u5B83\u653E\u8FDB\u56DE\u6536\u7AD9\uFF08\u522B\u7684\u8BBE\u5907\u4E5F\u4F1A\u77E5\u9053\u5B83\u88AB\u5220\u4E86\uFF0C\u4E0D\u4F1A\u53C8\u88AB\u62C9\u56DE\u6765\uFF09\uFF1B\u300C\u6E05\u7A7A\u56DE\u6536\u7AD9\u300D\u624D\u662F\u771F\u5220\uFF0C\u4E91\u7AEF\u90A3\u4EFD\u4E5F\u4F1A\u4E00\u8D77\u5220\u3002",
932
- "manual.5.conflict.label": "\u51B2\u7A81",
933
- "manual.5.conflict.body": "\u591A\u8BBE\u5907\u6539\u540C\u4E00\u6761\u6309\u65F6\u95F4\u540E\u5199\u8D62\uFF0C\u4E0D\u7559\u51B2\u7A81\u526F\u672C\u3002\u4E0A\u4E91\u7684\u5BC6\u6587\u53EA\u6709\u8D26\u5BC6\u6B63\u6587\uFF0C\u5176\u4F59\u662F\u660E\u6587 \u2014\u2014 \u6876\u52A1\u5FC5\u8981\u6709\u8BBF\u95EE\u63A7\u5236\u3002",
957
+ "manual.5.merge.label": "\u5408\u5E76",
958
+ "manual.5.merge.body": "\u62C9\u56DE\u6765\u6309\u8BB0\u5F55\u7684 id + \u66F4\u65B0\u65F6\u95F4\u5408\u5E76\uFF1A\u8C01\u65B0\u8C01\u8D62\uFF0C\u4E0D\u7559\u51B2\u7A81\u526F\u672C\u3002",
959
+ "manual.5.cloud.label": "\u4E0A\u4E91\u4EC0\u4E48",
960
+ "manual.5.cloud.body": "\u8BB0\u5F55\u4E0E\u9644\u4EF6\u4E00\u8D77\u8D70\uFF1B\u53EA\u6709\u8D26\u5BC6\u6B63\u6587\u662F\u5BC6\u6587\uFF0C\u5176\u4F59\uFF08\u6587\u672C\u3001\u94FE\u63A5\u3001\u5907\u6CE8\u3001\u9644\u4EF6\uFF09\u662F\u660E\u6587\u2014\u2014\u6876\u52A1\u5FC5\u8BBE\u8BBF\u95EE\u63A7\u5236\u3002",
934
961
  "manual.6.title": "6. \u5728\u5BF9\u8BDD\u91CC\u53D6\u56DE\u6765",
935
962
  "manual.6.query.label": "\u67E5",
936
963
  "manual.6.query.body": "\u5728\u5BF9\u8BDD\u91CC\u76F4\u63A5\u95EE\uFF1A\u300C\u6211\u7684\u6536\u4EF6\u7BB1\u91CC\u6709\u54EA\u4E9B\u8FD8\u6CA1\u770B\u7684\u94FE\u63A5\uFF1F\u300D\u52A9\u624B\u4F1A\u53BB\u4ED3\u5E93\u91CC\u627E\uFF0C \u6700\u591A\u5217 10 \u6761\uFF0C\u5E76\u544A\u8BC9\u4F60\u8FD8\u5269\u51E0\u6761\u3002\u53EB\u5B83\u300C\u6536\u4EF6\u7BB1\u300D\u300C\u4ED3\u5E93\u300D\u300C\u4E2A\u4EBA\u4ED3\u5E93\u300D\u8FD8\u662F\u300Cinbox\u300D\u90FD\u8BA4\u3002",
@@ -1057,6 +1084,9 @@ window.__ModuleLoader__.load({
1057
1084
  "settings.region": "Region",
1058
1085
  "settings.bucketAddress": "Address",
1059
1086
  "settings.bucketDir": "Directory",
1087
+ "settings.adoptForeign": "Merge other sync directories too",
1088
+ "settings.adoptForeignHint": "(brings back records a machine left under an older directory; off by default)",
1089
+ "settings.directoryPlaceholder": "inbox (the default \u2014 blank or / means the same)",
1060
1090
  "settings.dirS3.lead": "the directory is also the key prefix for S3 (default ",
1061
1091
  "settings.dirS3.mid": ", which becomes ",
1062
1092
  "settings.dirS3.tail": ").",
@@ -1072,6 +1102,7 @@ window.__ModuleLoader__.load({
1072
1102
  "settings.save": "Save",
1073
1103
  "settings.probe": "Test",
1074
1104
  "settings.details": "Details ({count} requests)",
1105
+ "settings.syncRootNow": "sync root: {root}",
1075
1106
  "settings.noServiceBody": "this composition has no settings service, so the address cannot be changed \u2014 you are most likely running the headless shape.",
1076
1107
  "settings.bucketHint": "other devices drop things here",
1077
1108
  "notice.listFailed": "Could not read the list: {reason}",
@@ -1103,16 +1134,20 @@ window.__ModuleLoader__.load({
1103
1134
  "sync.pushUnconfigured": "Push: no remote configured yet",
1104
1135
  "sync.pushFailed": "Push failed: {reason}",
1105
1136
  "sync.pushUnknown": "unknown reason",
1106
- "sync.pushUpToDate": "Push: nothing new ({count} already current)",
1107
- "sync.pushDone": "Push: {records} records / {attachments} attachment(s)",
1108
1137
  "sync.pushPartial": "{head} \xB7 partly failed: {reason}",
1109
1138
  "sync.pullNoAddress": "No address configured yet",
1110
1139
  "sync.pullFailed": "Pull failed: {reason}",
1111
- "sync.pullNothingNew": "Nothing new in the cloud",
1112
- "sync.pullMerged": "Merged {count} from the cloud{attachments}",
1113
- "sync.pullAttachments": " / {count} attachment(s)",
1114
- "sync.pullDone": "Pull done: {listed} listed remotely, {pulled} filed, {skipped} skipped{tail}",
1115
- "sync.pullFailedCount": " \xB7 {count} failed",
1140
+ "sync.detailPushedIdle": "pushed 0 ({count} already up to date)",
1141
+ "sync.detailPushed": "pushed {records} / {attachments} attachments",
1142
+ "sync.detailPulled": "pulled {count} \xB7 the cloud holds {records} records / {files} attachments",
1143
+ "sync.detailAdded": " ({count} of them new)",
1144
+ "sync.detailSkip": " ({skipped} skipped: {sync} ours, {older} older)",
1145
+ "sync.pullForeignSync": " \xB7 \u26A0\uFE0F {records} records live in another sync directory: {roots}",
1146
+ "sync.detailForeign": " \xB7 \u26A0\uFE0F another sync directory: {roots} ({records} records; ours is {ours})",
1147
+ "sync.shortPushed": "pushed {count}",
1148
+ "sync.shortPulled": "pulled {count}",
1149
+ "sync.shortIdle": "up to date",
1150
+ "sync.pullFailures": " \xB7 {count} failed: {reason}",
1116
1151
  "sync.thisPage": " \xB7 {count} on this page",
1117
1152
  "settings.status": "{settings} \xB7 {webdav} \xB7 {s3}",
1118
1153
  "settings.statusOn": "settings service present",
@@ -1178,10 +1213,14 @@ window.__ModuleLoader__.load({
1178
1213
  "manual.5.auto.body": "a few seconds after a capture or an edit a push goes out (debounced \u2014 five quick saves push once).",
1179
1214
  "manual.5.manual.label": "Manual",
1180
1215
  "manual.5.manual.body": "\u201CRefresh\u201D in the top right is a full sync: push local changes, pull the others, then re-read the list.",
1216
+ "manual.5.twoMachines.label": "Two machines",
1217
+ "manual.5.twoMachines.body": "Both machines need the same \u300C\u76EE\u5F55\u300D (blank, / and inbox all mean the same directory). While they differ nothing crosses over by default: the refresh says \u201CN records live in another sync directory\u201D, and either matching them or ticking \u201CMerge other sync directories too\u201D brings them in.",
1181
1218
  "manual.5.delete.label": "Deleting",
1182
1219
  "manual.5.delete.body": "\u201CDelete\u201D in the panel only moves a record to the bin (other devices learn it is gone instead of pushing it back); emptying the bin is the real delete, and the cloud copy goes with it.",
1183
- "manual.5.conflict.label": "Conflicts",
1184
- "manual.5.conflict.body": "two devices editing one record: the later write wins, no conflict copies. Only credential bodies travel encrypted \u2014 everything else is plain text up there, so the bucket needs access control.",
1220
+ "manual.5.merge.label": "Merging",
1221
+ "manual.5.merge.body": "Incoming records merge by id + timestamp: the newer write wins, no conflict copies.",
1222
+ "manual.5.cloud.label": "What goes up",
1223
+ "manual.5.cloud.body": "Records and attachments both travel; only credential bodies are ciphertext \u2014 text, links, notes and attachments are plain text, so the bucket needs access control.",
1185
1224
  "manual.6.title": "6. Getting things back in the conversation",
1186
1225
  "manual.6.query.label": "Ask",
1187
1226
  "manual.6.query.body": "just ask: \u201Cwhich links in my inbox haven\u2019t I read yet?\u201D The assistant searches the vault, lists at most 10, and tells you how many are left. \u6536\u4EF6\u7BB1 / \u4ED3\u5E93 / inbox all mean the same shelf.",
@@ -1744,7 +1783,12 @@ window.__ModuleLoader__.load({
1744
1783
  manual ran off the bottom of the screen with no visible frame.
1745
1784
  */
1746
1785
  width: "min(560px, 100%)",
1747
- maxHeight: "92vh",
1786
+ /*
1787
+ 80vh rather than 92vh: at 92 the card still felt like a full screen
1788
+ and it pushed the panel out of sight, while the point of the manual
1789
+ is to be readable *beside* what it explains (asked 2026-09-21).
1790
+ */
1791
+ maxHeight: "80vh",
1748
1792
  // Border inside the width and the cap: without it the card is 2px
1749
1793
  // wider than the number and 2px taller than the overlay's room.
1750
1794
  boxSizing: "border-box",
@@ -1795,6 +1839,7 @@ window.__ModuleLoader__.load({
1795
1839
  /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(
1796
1840
  "div",
1797
1841
  {
1842
+ className: "dsh-inbox-scroll",
1798
1843
  style: {
1799
1844
  padding: 18,
1800
1845
  display: "flex",
@@ -1838,8 +1883,10 @@ window.__ModuleLoader__.load({
1838
1883
  /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(Section, { title: t("manual.5.title"), children: [
1839
1884
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(Line, { label: t("manual.5.auto.label"), children: t("manual.5.auto.body") }),
1840
1885
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(Line, { label: t("manual.5.manual.label"), children: t("manual.5.manual.body") }),
1841
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(Line, { label: t("manual.5.delete.label"), children: t("manual.5.delete.body") }),
1842
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(Line, { label: t("manual.5.conflict.label"), children: t("manual.5.conflict.body") })
1886
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(Line, { label: t("manual.5.merge.label"), children: t("manual.5.merge.body") }),
1887
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(Line, { label: t("manual.5.twoMachines.label"), children: t("manual.5.twoMachines.body") }),
1888
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(Line, { label: t("manual.5.cloud.label"), children: t("manual.5.cloud.body") }),
1889
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(Line, { label: t("manual.5.delete.label"), children: t("manual.5.delete.body") })
1843
1890
  ] }),
1844
1891
  /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(Section, { title: t("manual.6.title"), children: [
1845
1892
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(Line, { label: t("manual.6.query.label"), children: t("manual.6.query.body") }),
@@ -2084,6 +2131,7 @@ window.__ModuleLoader__.load({
2084
2131
  const [text, setText] = import_react8.default.useState("");
2085
2132
  const [staged, setStaged] = import_react8.default.useState([]);
2086
2133
  const [notice, setNotice] = import_react8.default.useState();
2134
+ const [syncDetail, setSyncDetail] = import_react8.default.useState();
2087
2135
  const [busy, setBusy] = import_react8.default.useState(false);
2088
2136
  const [dragging, setDragging] = import_react8.default.useState(false);
2089
2137
  const picker = import_react8.default.useRef(null);
@@ -2236,19 +2284,27 @@ window.__ModuleLoader__.load({
2236
2284
  setNotice(t("notice.syncing"));
2237
2285
  try {
2238
2286
  const pushed = await call(INBOX_ENDPOINT_PUSH, {});
2239
- const pushLine = pushed.ok ? describePush(pushed.value) : t("notice.pushFailed", { reason: pushed.error.message });
2240
2287
  const pulled = await call(INBOX_ENDPOINT_PULL, {});
2241
- let suffix = "";
2242
- if (pulled.ok) {
2243
- const result = pulled.value;
2244
- suffix = ` \xB7 ${describePull(result)}`;
2245
- } else {
2246
- suffix = ` \xB7 ${t("notice.pullFailed", { reason: pulled.error.message })}`;
2247
- }
2248
2288
  await refresh();
2289
+ const pushFail = pushed.ok ? void 0 : t("notice.pushFailed", { reason: pushed.error.message });
2290
+ const pullFail = pulled.ok ? void 0 : t("notice.pullFailed", { reason: pulled.error.message });
2291
+ const pushCount = pushed.ok ? writtenBy(pushed.value) : 0;
2292
+ const pullCount = pulled.ok ? arrivedFrom(pulled.value) : 0;
2293
+ const warnings = pulled.ok ? syncWarnings(pulled.value) : "";
2249
2294
  const total = list?.matched;
2295
+ const idle = pushFail === void 0 && pullFail === void 0 && pushCount === 0 && pullCount === 0;
2296
+ const short = idle ? t("sync.shortIdle") : [
2297
+ pushFail ?? t("sync.shortPushed", { count: pushCount }),
2298
+ pullFail ?? t("sync.shortPulled", { count: pullCount })
2299
+ ].join(" \xB7 ");
2250
2300
  setNotice(
2251
- `${pushLine}${suffix}${total === void 0 ? "" : t("sync.thisPage", { count: total })}`
2301
+ `${short}${warnings}${total === void 0 ? "" : t("sync.thisPage", { count: total })}`
2302
+ );
2303
+ setSyncDetail(
2304
+ [
2305
+ pushed.ok ? describePush(pushed.value) : t("notice.pushFailed", { reason: pushed.error.message }),
2306
+ pulled.ok ? describePull(pulled.value) : t("notice.pullFailed", { reason: pulled.error.message })
2307
+ ].join(" \xB7 ")
2252
2308
  );
2253
2309
  } finally {
2254
2310
  setBusy(false);
@@ -2449,6 +2505,28 @@ window.__ModuleLoader__.load({
2449
2505
  if (entry.url !== void 0) setZoom({ href: entry.url, label: headingOf(entry) });
2450
2506
  }, []);
2451
2507
  return /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("div", { ref: panelRef, style: { ...panelStyle, colorScheme: scheme }, children: [
2508
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("style", { children: `
2509
+ /*
2510
+ Chromium ignores ::-webkit-scrollbar the moment the standard
2511
+ scrollbar-width is set, so the two engines get the rule they can use:
2512
+ engines that know ::-webkit-scrollbar get the 8px bar, and Firefox \u2014
2513
+ which does not \u2014 falls back to its own thin one. Measured: with both
2514
+ set, Chromium kept its 10px thin bar and the 8px rule did nothing.
2515
+ */
2516
+ @supports not selector(::-webkit-scrollbar) {
2517
+ .dsh-inbox-scroll { scrollbar-width: thin; }
2518
+ }
2519
+ .dsh-inbox-scroll::-webkit-scrollbar { width: 8px; height: 8px; }
2520
+ .dsh-inbox-scroll::-webkit-scrollbar-track { background: transparent; }
2521
+ .dsh-inbox-scroll::-webkit-scrollbar-thumb {
2522
+ background: color-mix(in srgb, currentColor 22%, transparent);
2523
+ border-radius: 8px;
2524
+ }
2525
+ .dsh-inbox-scroll::-webkit-scrollbar-thumb:hover {
2526
+ background: color-mix(in srgb, currentColor 34%, transparent);
2527
+ }
2528
+ .dsh-inbox-scroll::-webkit-scrollbar-corner { background: transparent; }
2529
+ ` }),
2452
2530
  /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("header", { children: /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("div", { style: { display: "flex", alignItems: "flex-start", gap: 10 }, children: [
2453
2531
  /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("div", { children: [
2454
2532
  /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("h2", { style: { margin: "0 0 4px" }, children: "dsh-inbox" }),
@@ -2853,6 +2931,7 @@ window.__ModuleLoader__.load({
2853
2931
  "button",
2854
2932
  {
2855
2933
  type: "button",
2934
+ title: syncDetail,
2856
2935
  style: { ...buttonStyle, height: CONTROL_HEIGHT, boxSizing: "border-box" },
2857
2936
  disabled: busy,
2858
2937
  onClick: () => void refreshAll(),
@@ -3033,6 +3112,7 @@ window.__ModuleLoader__.load({
3033
3112
  /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
3034
3113
  "div",
3035
3114
  {
3115
+ className: "dsh-inbox-scroll",
3036
3116
  style: listMode === "compact" ? {
3037
3117
  display: "flex",
3038
3118
  flexDirection: "column",
@@ -3201,28 +3281,47 @@ window.__ModuleLoader__.load({
3201
3281
  )
3202
3282
  ] });
3203
3283
  }
3284
+ function writtenBy(result) {
3285
+ return result.status === "ok" || result.status === "partial" ? result.pushed : 0;
3286
+ }
3287
+ function arrivedFrom(result) {
3288
+ return result.status === "ok" ? result.pulled + (result.merged ?? 0) : 0;
3289
+ }
3290
+ function syncWarnings(result) {
3291
+ return warningsOf(result, false);
3292
+ }
3293
+ function warningsOf(result, verbose) {
3294
+ const roots = result.foreignSyncRoots ?? [];
3295
+ const foreign = roots.length === 0 ? "" : verbose ? t("sync.detailForeign", {
3296
+ roots: roots.join("\u3001"),
3297
+ ours: result.syncRoot ?? "",
3298
+ records: result.foreignRecords ?? 0
3299
+ }) : t("sync.pullForeignSync", { roots: roots.join("\u3001"), records: result.foreignRecords ?? 0 });
3300
+ const failed = result.failed > 0 ? t("sync.pullFailures", { count: result.failed, reason: result.reason ?? "" }) : "";
3301
+ return `${foreign}${failed}`;
3302
+ }
3204
3303
  function describePush(result) {
3205
3304
  if (result.status === "unconfigured") return t("sync.pushUnconfigured");
3206
3305
  if (result.status === "failed") return t("sync.pushFailed", { reason: result.reason ?? t("sync.pushUnknown") });
3207
- const head = result.pushed === 0 && result.attachments === 0 ? t("sync.pushUpToDate", { count: result.skipped }) : t("sync.pushDone", { records: result.pushed, attachments: result.attachments });
3306
+ const head = result.pushed === 0 && result.attachments === 0 ? t("sync.detailPushedIdle", { count: result.skipped }) : t("sync.detailPushed", { records: result.pushed, attachments: result.attachments });
3208
3307
  return result.status === "partial" ? t("sync.pushPartial", { head, reason: result.reason ?? "" }) : head;
3209
3308
  }
3210
3309
  function describePull(result) {
3211
3310
  if (result.status === "unconfigured") return result.reason ?? t("sync.pullNoAddress");
3212
3311
  if (result.status === "failed") return t("sync.pullFailed", { reason: result.reason ?? t("sync.pushUnknown") });
3213
3312
  const merged = result.merged ?? 0;
3214
- const attachments = result.attachments ?? 0;
3215
- const syncPart = merged === 0 && attachments === 0 ? t("sync.pullNothingNew") : t("sync.pullMerged", {
3216
- count: merged,
3217
- attachments: attachments === 0 ? "" : t("sync.pullAttachments", { count: attachments })
3313
+ const head = t("sync.detailPulled", {
3314
+ count: result.pulled + merged,
3315
+ records: result.remoteRecords ?? 0,
3316
+ files: result.remoteAttachments ?? 0
3218
3317
  });
3219
- if (result.pulled === 0 && result.skipped === 0 && result.failed === 0) return syncPart;
3220
- return t("sync.pullDone", {
3221
- listed: result.listed,
3222
- pulled: result.pulled,
3318
+ const gained = (result.added ?? 0) === 0 ? "" : t("sync.detailAdded", { count: result.added ?? 0 });
3319
+ const skipped = result.skipped === 0 ? "" : t("sync.detailSkip", {
3223
3320
  skipped: result.skipped,
3224
- tail: `${syncPart}${result.failed > 0 ? t("sync.pullFailedCount", { count: result.failed }) : ""}`
3321
+ sync: result.skippedSync ?? 0,
3322
+ older: result.skippedOlder ?? 0
3225
3323
  });
3324
+ return `${head}${gained}${skipped}${warningsOf(result, true)}`;
3226
3325
  }
3227
3326
  function EncryptionSettings({ call }) {
3228
3327
  const [status, setStatus] = import_react8.default.useState();
@@ -3317,6 +3416,7 @@ window.__ModuleLoader__.load({
3317
3416
  const [status, setStatus] = import_react8.default.useState();
3318
3417
  const [baseUrl, setBaseUrl] = import_react8.default.useState("");
3319
3418
  const [directory, setDirectory] = import_react8.default.useState("/inbox");
3419
+ const [adoptForeignRoots, setAdoptForeignRoots] = import_react8.default.useState(false);
3320
3420
  const [username, setUsername] = import_react8.default.useState("");
3321
3421
  const [password, setPassword] = import_react8.default.useState("");
3322
3422
  const [protocol, setProtocol] = import_react8.default.useState("webdav");
@@ -3341,6 +3441,7 @@ window.__ModuleLoader__.load({
3341
3441
  setStatus(next);
3342
3442
  setBaseUrl(next.settings.baseUrl);
3343
3443
  setDirectory(next.settings.directory);
3444
+ setAdoptForeignRoots(next.settings.adoptForeignRoots === true);
3344
3445
  setUsername(next.settings.username);
3345
3446
  setProtocol(next.settings.protocol);
3346
3447
  setEndpoint(next.settings.endpoint);
@@ -3370,6 +3471,7 @@ window.__ModuleLoader__.load({
3370
3471
  protocol,
3371
3472
  baseUrl,
3372
3473
  directory,
3474
+ adoptForeignRoots,
3373
3475
  username,
3374
3476
  // Sending nothing leaves the stored password alone; sending "" clears it.
3375
3477
  ...password.length === 0 ? {} : { password },
@@ -3471,11 +3573,15 @@ window.__ModuleLoader__.load({
3471
3573
  value: directory,
3472
3574
  disabled: busy,
3473
3575
  onChange: (event) => setDirectory(event.target.value),
3474
- placeholder: "/inbox",
3576
+ placeholder: t("settings.directoryPlaceholder"),
3475
3577
  style: { ...inputStyle, flex: 1 }
3476
3578
  }
3477
3579
  ),
3478
- /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("span", { style: { opacity: 0.6 }, children: t("settings.bucketHint") })
3580
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("span", { style: { opacity: 0.6 }, children: [
3581
+ t("settings.bucketHint"),
3582
+ " \xB7 ",
3583
+ t("settings.syncRootNow", { root: syncRootFor(directory) })
3584
+ ] })
3479
3585
  ] }),
3480
3586
  /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("label", { style: { display: "flex", gap: 8, alignItems: "center" }, children: [
3481
3587
  /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("span", { style: { opacity: 0.7, minWidth: 64 }, children: t("settings.username") }),
@@ -3581,15 +3687,39 @@ window.__ModuleLoader__.load({
3581
3687
  }
3582
3688
  )
3583
3689
  ] }),
3584
- /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("p", { style: { margin: 0, opacity: 0.6 }, children: [
3585
- t("settings.dirS3.lead"),
3586
- /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("code", { children: "/inbox" }),
3587
- t("settings.dirS3.mid"),
3588
- /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("code", { children: "inbox/" }),
3589
- t("settings.dirS3.tail")
3690
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("label", { style: { display: "flex", gap: 8, alignItems: "center" }, children: [
3691
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("span", { style: { opacity: 0.7, minWidth: 64 }, children: t("settings.bucketDir") }),
3692
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
3693
+ "input",
3694
+ {
3695
+ value: directory,
3696
+ disabled: busy,
3697
+ onChange: (event) => setDirectory(event.target.value),
3698
+ placeholder: t("settings.directoryPlaceholder"),
3699
+ style: { ...inputStyle, flex: 1 }
3700
+ }
3701
+ ),
3702
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("span", { style: { opacity: 0.6 }, children: [
3703
+ t("settings.bucketHint"),
3704
+ " \xB7 ",
3705
+ t("settings.syncRootNow", { root: syncRootFor(directory) })
3706
+ ] })
3590
3707
  ] })
3591
3708
  ] }),
3592
3709
  /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("div", { style: { display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" }, children: [
3710
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("label", { style: { display: "flex", gap: 6, alignItems: "center", flex: "1 0 100%" }, children: [
3711
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
3712
+ "input",
3713
+ {
3714
+ type: "checkbox",
3715
+ checked: adoptForeignRoots,
3716
+ disabled: busy,
3717
+ onChange: (event) => setAdoptForeignRoots(event.target.checked)
3718
+ }
3719
+ ),
3720
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("span", { children: t("settings.adoptForeign") }),
3721
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("span", { style: { opacity: 0.6 }, children: t("settings.adoptForeignHint") })
3722
+ ] }),
3593
3723
  /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("button", { type: "button", style: buttonStyle, disabled: busy, onClick: () => void save(), children: t("settings.save") }),
3594
3724
  /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("button", { type: "button", style: buttonStyle, disabled: busy, onClick: () => void selfTest(), children: t("settings.probe") }),
3595
3725
  detailNotice(notice)
@@ -4020,6 +4150,7 @@ window.__ModuleLoader__.load({
4020
4150
  /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(
4021
4151
  "div",
4022
4152
  {
4153
+ className: "dsh-inbox-scroll",
4023
4154
  style: {
4024
4155
  flex: 1,
4025
4156
  minHeight: 0,