@brftech/filex-core 0.42.2 → 0.43.0

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.
Files changed (234) hide show
  1. package/README.md +14 -2
  2. package/dist/ArchiveViewer-BqU761Wk.js +149 -0
  3. package/dist/ArchiveViewer-BqU761Wk.js.map +1 -0
  4. package/dist/CsvViewer-ByHVK5ga.js +172 -0
  5. package/dist/CsvViewer-ByHVK5ga.js.map +1 -0
  6. package/dist/{DrawioViewer-CC6rJXeP.js → DrawioViewer-Bgrj8-zG.js} +45 -44
  7. package/dist/DrawioViewer-Bgrj8-zG.js.map +1 -0
  8. package/dist/{EpubViewer-CAoTSXTO.js → EpubViewer-DJwUKlHW.js} +50 -50
  9. package/dist/EpubViewer-DJwUKlHW.js.map +1 -0
  10. package/dist/IpynbViewer-Bz0eJ4NS.js +184 -0
  11. package/dist/IpynbViewer-Bz0eJ4NS.js.map +1 -0
  12. package/dist/MermaidViewer-CEDTD_pp.js +136 -0
  13. package/dist/MermaidViewer-CEDTD_pp.js.map +1 -0
  14. package/dist/PsdViewer-CZcxUpZO.js +121 -0
  15. package/dist/PsdViewer-CZcxUpZO.js.map +1 -0
  16. package/dist/TiffViewer-BwI62CbQ.js +141 -0
  17. package/dist/TiffViewer-BwI62CbQ.js.map +1 -0
  18. package/dist/{Viewer3D-CdZeK4j4.js → Viewer3D-DoeZcuMm.js} +16 -16
  19. package/dist/Viewer3D-DoeZcuMm.js.map +1 -0
  20. package/dist/filex-core.js +475 -219
  21. package/dist/filex-core.umd.cjs +165 -108
  22. package/dist/filex-core.umd.cjs.map +1 -1
  23. package/dist/fonts/caveat-latin-ext.woff2 +0 -0
  24. package/dist/fonts/caveat-latin.woff2 +0 -0
  25. package/dist/fonts/dancing-script-latin-ext.woff2 +0 -0
  26. package/dist/fonts/dancing-script-latin.woff2 +0 -0
  27. package/dist/fonts/homemade-apple-latin.woff2 +0 -0
  28. package/dist/fonts/source-serif-4-latin-ext.woff2 +0 -0
  29. package/dist/fonts/source-serif-4-latin.woff2 +0 -0
  30. package/dist/index-DuWnGuv5.js +35901 -0
  31. package/dist/index-DuWnGuv5.js.map +1 -0
  32. package/dist/index.d.ts +5227 -553
  33. package/dist/pdf-CaPYsOUo.js +21302 -0
  34. package/dist/pdf-CaPYsOUo.js.map +1 -0
  35. package/dist/style.css +1 -1
  36. package/package.json +1 -1
  37. package/src/FileExplorer.vue +1055 -253
  38. package/src/assets/fonts/LICENSE-Apache-2.0.txt +202 -0
  39. package/src/assets/fonts/LICENSE-OFL-1.1.txt +93 -0
  40. package/src/assets/fonts/README.md +58 -0
  41. package/src/assets/fonts/caveat-latin-ext.woff2 +0 -0
  42. package/src/assets/fonts/caveat-latin.woff2 +0 -0
  43. package/src/assets/fonts/dancing-script-latin-ext.woff2 +0 -0
  44. package/src/assets/fonts/dancing-script-latin.woff2 +0 -0
  45. package/src/assets/fonts/homemade-apple-latin.woff2 +0 -0
  46. package/src/assets/fonts/source-serif-4-latin-ext.woff2 +0 -0
  47. package/src/assets/fonts/source-serif-4-latin.woff2 +0 -0
  48. package/src/components/AdvancedSearch.vue +49 -0
  49. package/src/components/Breadcrumb.vue +5 -5
  50. package/src/components/ChoiceButtons.vue +152 -0
  51. package/src/components/CommandPalette.vue +5 -5
  52. package/src/components/ConnectionGuideView.vue +1 -1
  53. package/src/components/ConnectionNotice.vue +59 -0
  54. package/src/components/ConnectionsPanel.vue +30 -568
  55. package/src/components/ContextMenu.vue +31 -5
  56. package/src/components/DataTable.vue +1331 -0
  57. package/src/components/FilePane.vue +3 -0
  58. package/src/components/FilterBar.vue +12 -5
  59. package/src/components/GalleryView.vue +50 -2
  60. package/src/components/GridView.vue +51 -3
  61. package/src/components/HomeView.vue +18 -6
  62. package/src/components/InspectorPanel.vue +134 -17
  63. package/src/components/ListView.vue +366 -900
  64. package/src/components/LogoMark.vue +57 -0
  65. package/src/components/NFSExportsPanel.vue +76 -28
  66. package/src/components/OnboardingTour.vue +19 -7
  67. package/src/components/OperationsCenter.vue +77 -1
  68. package/src/components/PendingOpsTray.vue +59 -5
  69. package/src/components/PresenceBar.vue +7 -7
  70. package/src/components/ProductVersion.vue +31 -0
  71. package/src/components/QuickLook.vue +10 -14
  72. package/src/components/RecentlyOpened.vue +31 -15
  73. package/src/components/RecoveryKeyModal.vue +1 -1
  74. package/src/components/RowActions.vue +134 -0
  75. package/src/components/S3KeysPanel.vue +98 -62
  76. package/src/components/SSHKeysPanel.vue +79 -30
  77. package/src/components/SideNav.vue +260 -96
  78. package/src/components/StarButton.vue +1 -0
  79. package/src/components/StorageFields.vue +74 -6
  80. package/src/components/StorageTags.vue +62 -0
  81. package/src/components/TabBar.vue +1 -1
  82. package/src/components/TagKindIcon.vue +56 -0
  83. package/src/components/TagPicker.vue +226 -40
  84. package/src/components/ThemePalette.vue +4 -4
  85. package/src/components/TimeZonePicker.vue +29 -10
  86. package/src/components/TokensPanel.vue +82 -31
  87. package/src/components/Toolbar.vue +28 -6
  88. package/src/components/plugin/PluginConfirmModal.vue +54 -0
  89. package/src/components/plugin/PluginInspectorSection.vue +149 -0
  90. package/src/components/plugin/PluginPageView.vue +339 -0
  91. package/src/components/plugin/PluginViewModal.vue +159 -0
  92. package/src/components/plugin/SurfaceConversation.vue +76 -0
  93. package/src/components/plugin/SurfaceFooterButtons.vue +73 -0
  94. package/src/components/plugin/SurfaceRenderer.vue +299 -0
  95. package/src/components/plugin/SurfaceSections.vue +91 -0
  96. package/src/components/plugin/nodes/PdfFieldEditor.vue +509 -0
  97. package/src/components/plugin/nodes/SurfaceFileChooser.vue +94 -0
  98. package/src/components/plugin/nodes/SurfaceForm.vue +82 -0
  99. package/src/components/plugin/nodes/SurfaceList.vue +127 -0
  100. package/src/components/plugin/nodes/SurfacePdfFields.vue +1285 -0
  101. package/src/components/plugin/nodes/SurfacePeoplePicker.vue +213 -0
  102. package/src/components/plugin/nodes/SurfacePinInput.vue +57 -0
  103. package/src/components/plugin/nodes/SurfacePreview.vue +119 -0
  104. package/src/components/plugin/nodes/SurfaceProgress.vue +48 -0
  105. package/src/components/plugin/nodes/SurfaceSignaturePad.vue +472 -0
  106. package/src/components/plugin/nodes/SurfaceSteps.vue +53 -0
  107. package/src/components/public/PublicLanguagePicker.vue +58 -0
  108. package/src/components/public/PublicLinkPage.vue +286 -0
  109. package/src/components/public/PublicLinkPreview.vue +85 -0
  110. package/src/components/public/PublicPinGate.vue +108 -0
  111. package/src/components/public/PublicRequestBody.vue +204 -0
  112. package/src/components/public/PublicShareBody.vue +188 -0
  113. package/src/components/public/PublicShell.vue +332 -0
  114. package/src/composables/useConnections.ts +8 -2
  115. package/src/composables/useFileApi.ts +183 -44
  116. package/src/composables/useLocale.ts +277 -41
  117. package/src/composables/useNFSExports.ts +8 -10
  118. package/src/composables/useOperations.ts +44 -3
  119. package/src/composables/usePendingOps.ts +59 -8
  120. package/src/composables/usePluginActions.ts +121 -0
  121. package/src/composables/usePluginSurface.ts +223 -0
  122. package/src/composables/usePublicBranding.ts +172 -0
  123. package/src/composables/usePublicLink.ts +573 -0
  124. package/src/composables/usePublicPage.ts +61 -0
  125. package/src/composables/useS3Keys.ts +17 -12
  126. package/src/composables/useSSHKeys.ts +8 -10
  127. package/src/composables/useTokens.ts +8 -12
  128. package/src/composables/useUploadChunked.ts +18 -8
  129. package/src/composables/useViewerFetch.ts +6 -3
  130. package/src/index.ts +404 -8
  131. package/src/lib/actionIcons.ts +46 -1
  132. package/src/lib/advSearch.ts +14 -7
  133. package/src/lib/appLock.ts +130 -0
  134. package/src/lib/connection.ts +110 -0
  135. package/src/lib/destinationTree.ts +19 -3
  136. package/src/lib/direction.ts +353 -0
  137. package/src/lib/dragGhost.ts +6 -0
  138. package/src/lib/dropLimits.ts +69 -0
  139. package/src/lib/errorWords.ts +394 -0
  140. package/src/lib/externalReach.ts +5 -1
  141. package/src/lib/fileFilters.ts +7 -2
  142. package/src/lib/fileIcons.ts +4 -0
  143. package/src/lib/internalPaths.ts +77 -0
  144. package/src/lib/listInput.ts +64 -0
  145. package/src/lib/listing.ts +58 -23
  146. package/src/lib/modalStack.ts +26 -0
  147. package/src/lib/nodeRow.ts +18 -3
  148. package/src/lib/pdfFieldRules.ts +126 -0
  149. package/src/lib/pdfFields.ts +589 -0
  150. package/src/lib/pdfFieldsGeom.ts +203 -0
  151. package/src/lib/pdfjsLoader.ts +51 -0
  152. package/src/lib/personName.ts +47 -0
  153. package/src/lib/pluginApplies.ts +123 -0
  154. package/src/lib/pluginLabel.ts +72 -0
  155. package/src/lib/pluginMenu.ts +253 -0
  156. package/src/lib/pluginPage.ts +58 -0
  157. package/src/lib/plural.ts +181 -0
  158. package/src/lib/popupLayer.ts +103 -0
  159. package/src/lib/prefs.ts +563 -0
  160. package/src/lib/productVersion.ts +35 -0
  161. package/src/lib/publicLayout.ts +39 -0
  162. package/src/lib/serviceGate.ts +104 -0
  163. package/src/lib/shareTtl.ts +25 -11
  164. package/src/lib/signFonts.ts +85 -0
  165. package/src/lib/signaturePad.ts +111 -0
  166. package/src/lib/sortOrder.ts +100 -38
  167. package/src/lib/star.ts +7 -1
  168. package/src/lib/surfaceCell.ts +41 -0
  169. package/src/lib/surfaceConditions.ts +156 -0
  170. package/src/lib/surfaceOpen.ts +106 -0
  171. package/src/lib/surfaceSteps.ts +68 -0
  172. package/src/lib/surfaceValues.ts +191 -0
  173. package/src/lib/symlink.ts +150 -0
  174. package/src/lib/tableColumns.ts +493 -0
  175. package/src/lib/tableEnv.ts +34 -0
  176. package/src/lib/tablePrefs.ts +84 -0
  177. package/src/lib/tags.ts +121 -12
  178. package/src/lib/themes.ts +286 -18
  179. package/src/lib/tour.ts +152 -0
  180. package/src/lib/trashEmpty.ts +111 -0
  181. package/src/lib/trashTimeLeft.ts +23 -0
  182. package/src/lib/uiLocales.ts +460 -0
  183. package/src/lib/viewPrefs.ts +696 -660
  184. package/src/lib/viewPrefsHttp.ts +78 -0
  185. package/src/locales/en.ts +515 -117
  186. package/src/locales/resolve.ts +17 -8
  187. package/src/locales/tr.ts +536 -152
  188. package/src/modals/ConvertModal.vue +62 -11
  189. package/src/modals/DestinationPickerModal.vue +59 -16
  190. package/src/modals/Modal.vue +113 -23
  191. package/src/modals/NewDocumentModal.vue +16 -6
  192. package/src/modals/NewFolderModal.vue +8 -0
  193. package/src/modals/PermissionsModal.vue +272 -163
  194. package/src/modals/PreviewModal.vue +119 -20
  195. package/src/modals/RenameModal.vue +37 -1
  196. package/src/styles/base.css +2890 -260
  197. package/src/styles/sign-fonts.css +118 -0
  198. package/src/styles/variables.css +60 -0
  199. package/src/types/Connections.ts +17 -0
  200. package/src/types/ExplorerConfig.ts +117 -1
  201. package/src/types/FileNode.ts +50 -0
  202. package/src/types/Plugins.ts +494 -0
  203. package/src/types/Public.ts +212 -0
  204. package/src/viewers/ArchiveViewer.vue +93 -48
  205. package/src/viewers/CsvViewer.vue +99 -53
  206. package/src/viewers/DrawioViewer.vue +35 -9
  207. package/src/viewers/EpubViewer.vue +7 -8
  208. package/src/viewers/IpynbViewer.vue +3 -1
  209. package/src/viewers/MermaidViewer.vue +4 -5
  210. package/src/viewers/PdfViewer.vue +8 -17
  211. package/src/viewers/PsdViewer.vue +2 -4
  212. package/src/viewers/TiffViewer.vue +4 -5
  213. package/src/viewers/Viewer3D.vue +1 -1
  214. package/LICENSE +0 -21
  215. package/dist/ArchiveViewer-BUQWLhwX.js +0 -124
  216. package/dist/ArchiveViewer-BUQWLhwX.js.map +0 -1
  217. package/dist/CsvViewer-Bg2_aqzc.js +0 -141
  218. package/dist/CsvViewer-Bg2_aqzc.js.map +0 -1
  219. package/dist/DrawioViewer-CC6rJXeP.js.map +0 -1
  220. package/dist/EpubViewer-CAoTSXTO.js.map +0 -1
  221. package/dist/IpynbViewer-D0GwGpnE.js +0 -184
  222. package/dist/IpynbViewer-D0GwGpnE.js.map +0 -1
  223. package/dist/MermaidViewer-DGwAvRZg.js +0 -137
  224. package/dist/MermaidViewer-DGwAvRZg.js.map +0 -1
  225. package/dist/PsdViewer-DhGweA0m.js +0 -122
  226. package/dist/PsdViewer-DhGweA0m.js.map +0 -1
  227. package/dist/TiffViewer-D0AurvAh.js +0 -142
  228. package/dist/TiffViewer-D0AurvAh.js.map +0 -1
  229. package/dist/Viewer3D-CdZeK4j4.js.map +0 -1
  230. package/dist/index-BArI-B1M.js +0 -26911
  231. package/dist/index-BArI-B1M.js.map +0 -1
  232. package/dist/useViewerFetch-czqbd2Lj.js +0 -25
  233. package/dist/useViewerFetch-czqbd2Lj.js.map +0 -1
  234. package/src/composables/useScrolledX.ts +0 -21
@@ -27,14 +27,41 @@
27
27
 
28
28
  import { computed, getCurrentInstance, inject, type Ref } from 'vue';
29
29
  import type { LocaleCode } from '../types/ExplorerConfig';
30
- import { messages } from '../locales';
30
+ import { localeOwnTable, localesVersion, type OwnLocaleTable } from '../lib/uiLocales';
31
+ import { pluralCategory } from '../lib/plural';
32
+ // ⚠ The table itself, not `../locales` (its index re-exports `locales/resolve`,
33
+ // which reads `lib/uiLocales` — the cycle uiLocales avoids the same way).
34
+ import { en as EN } from '../locales/en';
35
+ import { foreignText, isolateLtrRuns, isolateValue, localeDir } from '../lib/direction';
31
36
  import { EXPLORER_CLOCK, activeTimeZoneFor, deviceTimeZone } from '../lib/timezone';
32
37
 
33
38
  /* ── the tag ──────────────────────────────────────────────────────────── */
34
39
 
35
- /** The BCP-47 tag a catalogue code means. ONE mapping, for every surface. */
40
+ /**
41
+ * The BCP-47 tag a catalogue code means. ONE mapping, for every surface.
42
+ *
43
+ * ⚠ `tr` → `tr-TR` and `en` (or nothing) → `en-US` are pinned — the two
44
+ * catalogues the product ships, and what every date and size on screen has
45
+ * been measured against. Every OTHER code is a language pack's, and it is
46
+ * its own tag: it used to fall through to `en-US`, so a Spanish interface
47
+ * printed "Sep 12, 2026, 3:25 PM" beside Spanish words (measured 2026-09-21).
48
+ * A pack that needs a region's conventions names itself with the region
49
+ * (`es-mx`, `pt-br`) and gets them; `Intl` canonicalises the case.
50
+ */
51
+ const tagCache = new Map<string, string>();
36
52
  export function localeTag(code: LocaleCode | string | undefined): string {
37
- return code === 'tr' ? 'tr-TR' : 'en-US';
53
+ if (!code || code === 'en') return 'en-US';
54
+ if (code === 'tr') return 'tr-TR';
55
+ let tag = tagCache.get(code);
56
+ if (!tag) {
57
+ try {
58
+ tag = Intl.getCanonicalLocales(code)[0] || 'en-US';
59
+ } catch {
60
+ tag = 'en-US';
61
+ }
62
+ tagCache.set(code, tag);
63
+ }
64
+ return tag;
38
65
  }
39
66
 
40
67
  /* ── bytes ────────────────────────────────────────────────────────────── */
@@ -110,9 +137,11 @@ export interface ByteSizeOptions {
110
137
  * back as "10 GB" and a stored volume matches the invoice beside it.
111
138
  *
112
139
  * `base: 1024` exists for exactly one reason: mirroring a number some other
113
- * program already printed. Today that is the share message, which has to read
114
- * like `humanSize()` in backend/internal/api/handlers/mail_templates.go.
115
- * That is an argument to this function, not a fifth copy of it.
140
+ * program already printed. Nothing in the product needs it today — the share
141
+ * message used it to copy the server's e-mail (`humanSize()`, base 1024), and
142
+ * the server writes sizes by THESE rules now (backend/internal/srvtext
143
+ * `Bytes`), so the message, the mail and the listing say one thing. If a
144
+ * mirror is ever needed again it is an argument here, not a fifth copy.
116
145
  */
117
146
  export function formatByteSize(
118
147
  bytes: number | null | undefined,
@@ -215,6 +244,37 @@ export function formatInstant(
215
244
  }
216
245
  }
217
246
 
247
+ const DAY = /^(\d{4})-(\d{2})-(\d{2})$/;
248
+ const dayFormatters = new Map<string, Intl.DateTimeFormat>();
249
+
250
+ /**
251
+ * A calendar DAY (`YYYY-MM-DD`) for a human: the same words formatDate's date
252
+ * part prints ("29 Eyl 2026"), but read as that day, not as an instant — it is
253
+ * formatted in UTC, so no viewer's time zone can move a due date to the day
254
+ * before. '' for anything that is not such a day.
255
+ *
256
+ * ⚠ Here, beside formatInstant, because this file is the home of "a date for
257
+ * a human" (web/tests/quality/duplication.test.ts, `datetime-format`): an
258
+ * app's date column (lib/surfaceCell) must not grow a formatter of its own.
259
+ */
260
+ export function formatCalendarDay(day: string, code: LocaleCode | string | undefined): string {
261
+ const m = DAY.exec(day.trim());
262
+ if (!m) return '';
263
+ const d = new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3])));
264
+ if (Number.isNaN(d.getTime())) return '';
265
+ const tag = localeTag(code);
266
+ let fmt = dayFormatters.get(tag);
267
+ try {
268
+ if (!fmt) {
269
+ fmt = new Intl.DateTimeFormat(tag, { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' });
270
+ dayFormatters.set(tag, fmt);
271
+ }
272
+ return fmt.format(d);
273
+ } catch {
274
+ return '';
275
+ }
276
+ }
277
+
218
278
  /** Seconds or milliseconds — the backend has sent both. Normalised once. */
219
279
  function toDate(ms: number | undefined | null): Date | null {
220
280
  if (!ms) return null;
@@ -222,6 +282,67 @@ function toDate(ms: number | undefined | null): Date | null {
222
282
  return Number.isNaN(d.getTime()) ? null : d;
223
283
  }
224
284
 
285
+ /** Anything a date arrives as: epoch seconds or milliseconds, an ISO string,
286
+ * a Date. Null for nothing or for something that is not an instant. */
287
+ export type WhenInput = number | string | Date | null | undefined;
288
+
289
+ function instantOf(value: WhenInput): Date | null {
290
+ if (value == null || value === '') return null;
291
+ if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value;
292
+ if (typeof value === 'number') return toDate(value);
293
+ const d = new Date(value);
294
+ return Number.isNaN(d.getTime()) ? null : d;
295
+ }
296
+
297
+ const DATE_PART: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric', year: 'numeric' };
298
+ // ⚠ The clock is the LANGUAGE's short time, not a field list: `hour: 'numeric'`
299
+ // printed Turkish "8:05" where Turkish writes "08:05", and `'2-digit'` would
300
+ // print English "08:05 AM". Only noticed on CI: its zone is UTC, so the
301
+ // test's 08:05Z stayed before ten there and became 11:05 at +3 (2026-09-24).
302
+ const TIME_PART: Intl.DateTimeFormatOptions = { timeStyle: 'short' };
303
+
304
+ /**
305
+ * An instant as a person reads it — THE date format of the product: the
306
+ * explorer's, "Sep 21, 2026, 2:50 PM" / "21 Eyl 2026, 14:50", in the viewer's
307
+ * language and on the viewer's chosen clock. `time: true` adds the clock.
308
+ *
309
+ * ⚠⚠ One definition for every surface (QA, 2026-09-21): the explorer printed
310
+ * "21 Eyl 2026, 14:50", the admin tables and the share dialog "21 Eyl 2026
311
+ * 15:02" (a `dateStyle: 'medium'` pair and a `day: '2-digit', hour: '2-digit'`
312
+ * set of their own), and English times read "2:50 PM" on one screen and
313
+ * "02:51 PM" on the next. The admin panel's `formatDate`, `useLocale`'s
314
+ * `formatDate` and the share lines all call this now; nothing else builds a
315
+ * date formatter for a person to read (scripts/dup-scan.mjs, `datetime-format`).
316
+ */
317
+ export function formatWhen(
318
+ value: WhenInput,
319
+ code: LocaleCode | string | undefined,
320
+ opts: { time?: boolean } = {},
321
+ /** The explorer whose clock applies (EXPLORER_CLOCK); omitted = page-wide. */
322
+ owner?: symbol,
323
+ ): string {
324
+ const d = instantOf(value);
325
+ if (!d) return '';
326
+ const date = formatInstant(d, code, DATE_PART, owner);
327
+ return opts.time ? `${date}, ${formatInstant(d, code, TIME_PART, owner)}` : date;
328
+ }
329
+
330
+ /**
331
+ * The whole truth, for a tooltip behind a short date: full date, seconds, the
332
+ * zone's name AND its IANA id — "whose 14:05 is this?" answered. The IANA id
333
+ * rides along because half the world's zones display as "GMT+3".
334
+ */
335
+ export function formatWhenFull(
336
+ value: WhenInput,
337
+ code: LocaleCode | string | undefined,
338
+ owner?: symbol,
339
+ ): string {
340
+ const d = instantOf(value);
341
+ if (!d) return '';
342
+ const zone = activeTimeZoneFor(owner) ?? deviceTimeZone();
343
+ return `${formatInstant(d, code, { dateStyle: 'full', timeStyle: 'long' }, owner)} (${zone})`;
344
+ }
345
+
225
346
  /* ── counted messages ─────────────────────────────────────────────────── */
226
347
 
227
348
  /**
@@ -231,8 +352,14 @@ function toDate(ms: number | undefined | null): Date | null {
231
352
  export const COUNT_VARS = ['count', 'n', 'days'] as const;
232
353
 
233
354
  /**
234
- * Which catalogue key a counted message reads: `key_one` when the count is
235
- * exactly one and the catalogue has that form, otherwise `key`.
355
+ * Which key of ONE table a counted message reads: `key_<category>` when the
356
+ * count falls in a category other than `other` in `lang` (CLDR, via
357
+ * `lib/plural`) and the table has that form, otherwise the plain `key` — which
358
+ * IS the `other` form (there is no `_other` key).
359
+ *
360
+ * The forms: `_zero`, `_one`, `_two`, `_few`, `_many`. A language writes the
361
+ * ones it has — English and Turkish `_one`, Russian `_one` `_few` `_many`,
362
+ * Arabic all five — and never `_other`.
236
363
  *
237
364
  * ⚠⚠ ONE rule, here, for every counted string in the package. The catalogue
238
365
  * is a flat table with no plural machinery, so a `{n} items` message printed
@@ -242,43 +369,162 @@ export const COUNT_VARS = ['count', 'n', 'days'] as const;
242
369
  * and the empty-trash confirmation); every other counted message had not, and
243
370
  * a few hedged with "item(s)". A rule each caller has to remember is a rule
244
371
  * the next caller forgets, so `t()` applies it and the catalogue only has to
245
- * SAY the singular.
372
+ * SAY the forms.
373
+ *
374
+ * ⚠ Until v0.43.0 the rule was English's alone ("`_one` for exactly 1"),
375
+ * which no language with few/many can be written in — the Arabic pack could
376
+ * not say "2 files" at all.
246
377
  *
247
378
  * ⚠ Turkish does not inflect a noun after a number ("1 öğe", "5 öğe"), so its
248
379
  * `_one` entries read exactly like the plain ones. They exist for key parity,
249
- * not because the language needs them. `web/tests/i18n/corePlurals.test.ts`
380
+ * not because the language needs them. `web/tests/i18n/plurals.test.ts`
250
381
  * fails an English counted message that has no singular.
382
+ *
383
+ * `lang` defaults to English — the rule this function had before it had the
384
+ * argument. `t()` does not call this on a merged table; see `pickMessage`.
251
385
  */
252
386
  export function countedKey(
253
387
  key: string,
254
388
  vars: Record<string, string | number>,
255
389
  has: (key: string) => boolean,
390
+ lang = 'en',
256
391
  ): string {
257
392
  const counter = COUNT_VARS.find((v) => v in vars);
258
- if (counter === undefined || Number(vars[counter]) !== 1) return key;
259
- const one = `${key}_one`;
260
- return has(one) ? one : key;
393
+ if (counter === undefined) return key;
394
+ const cat = pluralCategory(lang, Number(vars[counter]));
395
+ if (cat === 'other') return key;
396
+ const form = `${key}_${cat}`;
397
+ return has(form) ? form : key;
398
+ }
399
+
400
+ /** A catalogue value, or undefined — never an `Object.prototype` member. */
401
+ function entry(table: Readonly<Record<string, string>>, key: string): string | undefined {
402
+ const v = table[key];
403
+ return typeof v === 'string' && v ? v : undefined;
404
+ }
405
+
406
+ /**
407
+ * The raw (uninterpolated) message a key reads in a language.
408
+ *
409
+ * 1. the form the count's category calls for, if the language WROTE it;
410
+ * 2. else the language's own plain `key` (its `other` form);
411
+ * 3. else English as a whole — English's form by English's rule
412
+ * (`key_one` for 1), then English's plain `key`;
413
+ * 4. else the key itself.
414
+ *
415
+ * ⚠⚠ Step 2 before step 3, and `own` must be the language's OWN words
416
+ * (`localeOwnTable`), not the English-under-pack merge (`localeTable`). With
417
+ * the merge, a Spanish pack that wrote only `toast.restored` still "had"
418
+ * `toast.restored_one` — the English one underneath — and a count of one
419
+ * printed "1 item restored" in the middle of a Spanish screen. A language's
420
+ * own plain form, even if it reads as a plural for 1, is its translator's
421
+ * choice; an English word in its place is nobody's.
422
+ */
423
+ export function pickMessage(
424
+ own: OwnLocaleTable,
425
+ key: string,
426
+ vars: Record<string, string | number> = {},
427
+ ): string {
428
+ const mine = own.strings;
429
+ const form = countedKey(key, vars, (k) => entry(mine, k) !== undefined, own.lang);
430
+ const said = entry(mine, form) ?? entry(mine, key);
431
+ if (said !== undefined) return said;
432
+ if (mine === EN) return key;
433
+ const enForm = countedKey(key, vars, (k) => entry(EN, k) !== undefined, 'en');
434
+ return entry(EN, enForm) ?? entry(EN, key) ?? key;
435
+ }
436
+
437
+ /** `{name}` placeholders, replaced verbatim. */
438
+ function interpolate(raw: string, vars: Record<string, string | number>): string {
439
+ return Object.entries(vars).reduce(
440
+ (acc, [k, v]) => acc.replaceAll(`{${k}}`, String(v)),
441
+ raw,
442
+ );
261
443
  }
262
444
 
263
- export function useLocale(localeRef: Ref<LocaleCode> | (() => LocaleCode)) {
264
- const code = (): LocaleCode =>
445
+ /**
446
+ * One message, rendered: the plural form picked on the ORIGINAL vars (a count
447
+ * wrapped in isolation marks is no longer a number to pick on), the values
448
+ * put in, and — RTL only — a name interpolated into the sentence isolated so
449
+ * its own letters decide its direction (`isolateValue`, strings only), and a
450
+ * `3 / 10` or a `tag:…` kept reading left to right (`isolateLtrRuns`). A
451
+ * left-to-right string comes back exactly as it always did.
452
+ *
453
+ * ⚠ The one path for `t()` and `translate()`, so a sentence built outside a
454
+ * component (lib/shareTtl) reads the same way under Arabic as one inside.
455
+ */
456
+ function render(
457
+ table: OwnLocaleTable,
458
+ key: string,
459
+ vars: Record<string, string | number>,
460
+ rtl: boolean,
461
+ ): string {
462
+ const raw = pickMessage(table, key, vars);
463
+ if (!rtl) return interpolate(raw, vars);
464
+ const isolated: Record<string, string | number> = {};
465
+ for (const [k, v] of Object.entries(vars)) isolated[k] = typeof v === 'string' ? isolateValue(v) : v;
466
+ return isolateLtrRuns(interpolate(raw, isolated));
467
+ }
468
+
469
+ /**
470
+ * `t()` for code that is not a component (`lib/shareTtl`): the same lookup,
471
+ * the same plural rule, the same placeholders. ⚠ Not reactive — a caller that
472
+ * renders the result in a template must read `localesVersion` itself.
473
+ */
474
+ export function translate(
475
+ code: LocaleCode | string | undefined,
476
+ key: string,
477
+ vars: Record<string, string | number> = {},
478
+ ): string {
479
+ return render(localeOwnTable(code), key, vars, localeDir(code) === 'rtl');
480
+ }
481
+
482
+ export function useLocale(
483
+ /**
484
+ * ⚠ Widened past `LocaleCode` on purpose (v3 §5). The list of languages
485
+ * the interface offers is no longer the two this package ships: an app
486
+ * plugin may add one (`lib/uiLocales`), so the active code can be a tag
487
+ * this file has never heard of. It resolves through `localeOwnTable`, and
488
+ * every key the language does not have reads English.
489
+ */
490
+ localeRef: Ref<LocaleCode | string> | (() => LocaleCode | string),
491
+ ) {
492
+ const code = (): string =>
265
493
  typeof localeRef === 'function' ? localeRef() : localeRef.value;
266
494
 
267
- const lookup = computed(() => messages[code()] ?? messages.en);
495
+ /**
496
+ * ⚠ `localesVersion` is READ here so the table is re-derived when an app
497
+ * installs or removes a language. Without it a screen already on the page
498
+ * would keep the table it was mounted with — an app's own words would
499
+ * arrive one navigation late, which is the kind of miss nobody reports as
500
+ * a bug and everybody works around.
501
+ */
502
+ const lookup = computed(() => {
503
+ void localesVersion.value;
504
+ return localeOwnTable(code());
505
+ });
268
506
 
269
507
  // The explorer this component sits in, whose clock its dates are read on
270
508
  // (lib/timezone, timeZoneSourcesFor). Outside a component, or outside any
271
509
  // explorer, the page-wide answer.
272
510
  const clock = getCurrentInstance() ? inject(EXPLORER_CLOCK, undefined) : undefined;
273
511
 
512
+ /** ⚠ RTL — which way this surface's language is written (lib/direction). */
513
+ const dir = computed(() => localeDir(code()));
514
+
274
515
  function t(key: string, vars: Record<string, string | number> = {}): string {
275
- const table = lookup.value;
276
- const raw = table[countedKey(key, vars, (k) => k in table)] ?? key;
277
- return Object.entries(vars).reduce(
278
- (acc, [k, v]) => acc.replaceAll(`{${k}}`, String(v)),
279
- raw,
280
- );
516
+ return render(lookup.value, key, vars, dir.value === 'rtl');
281
517
  }
518
+ /**
519
+ * ⚠⚠ Text filex did NOT write, said the way this surface's language needs:
520
+ * the server's own sentence, an installed app's words, a notification built
521
+ * from what happened. None of it passes through `t()`, so none of it was
522
+ * isolated — see lib/direction `foreignText`. It rides ON `t` so that the
523
+ * helpers a component hands its translator to (lib/errorWords `sayFailure`,
524
+ * `jobFailure`, `opFailure`) get the reader's direction without five more
525
+ * signatures to thread a locale code through.
526
+ */
527
+ t.foreign = (text: string): string => foreignText(code(), text);
282
528
 
283
529
  /** A byte count in the viewer's language and number format. */
284
530
  function formatSize(bytes: number | undefined | null): string {
@@ -328,20 +574,7 @@ export function useLocale(localeRef: Ref<LocaleCode> | (() => LocaleCode)) {
328
574
  * device's zone, resolved live by Intl rather than frozen at first run.
329
575
  */
330
576
  function formatDate(ms: number | undefined | null, opts: { time?: boolean } = {}): string {
331
- const d = toDate(ms);
332
- if (!d) return '';
333
- const date = formatInstant(
334
- d,
335
- code(),
336
- {
337
- month: 'short',
338
- day: 'numeric',
339
- year: 'numeric',
340
- },
341
- clock,
342
- );
343
- if (!opts.time) return date;
344
- return `${date}, ${formatInstant(d, code(), { hour: 'numeric', minute: '2-digit' }, clock)}`;
577
+ return formatWhen(ms, code(), opts, clock);
345
578
  }
346
579
 
347
580
  /**
@@ -356,11 +589,7 @@ export function useLocale(localeRef: Ref<LocaleCode> | (() => LocaleCode)) {
356
589
  * zones render as "GMT+3".
357
590
  */
358
591
  function formatDateFull(ms: number | undefined | null): string {
359
- const d = toDate(ms);
360
- if (!d) return '';
361
- const zone = activeTimeZoneFor(clock) ?? deviceTimeZone();
362
- const stamp = formatInstant(d, code(), { dateStyle: 'full', timeStyle: 'long' }, clock);
363
- return `${stamp} (${zone})`;
592
+ return formatWhenFull(ms, code(), clock);
364
593
  }
365
594
 
366
595
  /**
@@ -414,5 +643,12 @@ export function useLocale(localeRef: Ref<LocaleCode> | (() => LocaleCode)) {
414
643
  */
415
644
  toDate,
416
645
  nodeDisplayName,
646
+ /**
647
+ * ⚠ RTL — which way this surface's language is written (`lib/direction`,
648
+ * the server's one list). Bind it as `dir` on anything Teleported out of
649
+ * the explorer's root: under `<body>` a menu inherits the HOST page's
650
+ * direction, not the language its own words are in.
651
+ */
652
+ dir,
417
653
  };
418
654
  }
@@ -11,6 +11,7 @@ import type { ExplorerConfig } from '../types/ExplorerConfig';
11
11
  import type { NFSConnection, NFSExport, NFSExportCreated } from '../types/NFSExports';
12
12
  import { connectionsBase } from './useConnections';
13
13
  import { useFileApi } from './useFileApi';
14
+ import { serverWords } from '../lib/errorWords';
14
15
 
15
16
  export function useNFSExports(config: ExplorerConfig) {
16
17
  const api = useFileApi(config);
@@ -26,17 +27,14 @@ export function useNFSExports(config: ExplorerConfig) {
26
27
  /** The path, held only while the user is looking at it. */
27
28
  const revealed = ref<NFSExportCreated | null>(null);
28
29
 
30
+ /** A refusal, said — lib/errorWords `serverWords`, the one rule every
31
+ * connection panel shares (it used to print the server's `error` field as
32
+ * it came: an environment variable, to a regular user). ⚠ The locale is
33
+ * passed because what comes back may be the SERVER's own sentence: it
34
+ * never met a translator, and an Arabic panel needs its machine runs
35
+ * isolated (lib/direction `foreignText`). */
29
36
  function messageOf(e: unknown): string {
30
- const err = e as { message?: string; detail?: string } | null;
31
- if (err?.detail) {
32
- try {
33
- const parsed = JSON.parse(err.detail) as { error?: string };
34
- if (parsed?.error) return parsed.error;
35
- } catch {
36
- /* not JSON */
37
- }
38
- }
39
- return err?.message || String(e);
37
+ return serverWords(e, config.locale);
40
38
  }
41
39
 
42
40
  function statusOf(e: unknown): number | undefined {
@@ -25,7 +25,9 @@
25
25
 
26
26
  import { computed, getCurrentScope, onScopeDispose, ref } from 'vue';
27
27
 
28
- export type OperationKind = 'upload' | 'copy' | 'move' | 'delete' | 'convert' | 'archive';
28
+ /** `plugin` — an app-plugin job (docs/APP-PLUGINS-API.md); also what any
29
+ * queue kind this package has no drawing for is shown as. */
30
+ export type OperationKind = 'upload' | 'copy' | 'move' | 'delete' | 'convert' | 'archive' | 'plugin' | 'trash';
29
31
  export type OperationStatus = 'running' | 'done' | 'error' | 'aborted';
30
32
 
31
33
  /** What a publisher hands to `sync()` for one row. */
@@ -38,7 +40,11 @@ export interface OperationInput {
38
40
  /** 0..100, or null for indeterminate (renderer shows a spinner). */
39
41
  percent: number | null;
40
42
  status: OperationStatus;
43
+ /** The failure, SAID (lib/errorWords) — never a raw server string. */
41
44
  error?: string | null;
45
+ /** The raw words behind it — set only for a caller who administers the
46
+ * instance; drawn as a second line under the sentence. */
47
+ errorDetail?: string | null;
42
48
  /** Queue op accepted but not started yet ("Queued"). */
43
49
  queued?: boolean;
44
50
  /** Progress counters for queue ops (3/5 items). */
@@ -49,6 +55,20 @@ export interface OperationInput {
49
55
  totalBytes?: number;
50
56
  cancellable?: boolean;
51
57
  retryable?: boolean;
58
+ /** A plugin job's last progress message — shown under the name while it runs. */
59
+ message?: string | null;
60
+ /** Files a finished job produced (adapter-qualified paths); the row offers "Open". */
61
+ outputs?: string[];
62
+ /**
63
+ * An app-plugin job's output mode — `sibling` (a new file beside the
64
+ * original) or `version` (the same file, a new version of it).
65
+ *
66
+ * ⚠ It is on the row because the two are indistinguishable once the job
67
+ * lands: "Open" navigates to `outputs[0]`, which in `version` mode IS the
68
+ * input the person started from, so without the chip the tray reads as if
69
+ * nothing was produced. Absent for every other kind.
70
+ */
71
+ outputMode?: string | null;
52
72
  }
53
73
 
54
74
  export interface OperationActions {
@@ -56,6 +76,8 @@ export interface OperationActions {
56
76
  /** Clean the row out of the OWNING source list (called on retire/dismiss). */
57
77
  dismiss?: () => void;
58
78
  retry?: () => void;
79
+ /** Reveal one of the row's outputs in the listing. */
80
+ open?: (path: string) => void;
59
81
  }
60
82
 
61
83
  export interface Operation {
@@ -66,6 +88,7 @@ export interface Operation {
66
88
  percent: number | null;
67
89
  status: OperationStatus;
68
90
  error: string | null;
91
+ errorDetail: string | null;
69
92
  queued: boolean;
70
93
  doneCount: number | null;
71
94
  totalCount: number | null;
@@ -73,6 +96,9 @@ export interface Operation {
73
96
  totalBytes: number | null;
74
97
  cancellable: boolean;
75
98
  retryable: boolean;
99
+ message: string | null;
100
+ outputs: string[];
101
+ outputMode: string | null;
76
102
  startedAt: number;
77
103
  settledAt: number | null;
78
104
  }
@@ -89,6 +115,7 @@ function toOperation(key: string, input: OperationInput, prev?: Operation): Oper
89
115
  percent: input.percent,
90
116
  status: input.status,
91
117
  error: input.error ?? null,
118
+ errorDetail: input.errorDetail ?? null,
92
119
  queued: input.queued ?? false,
93
120
  doneCount: input.doneCount ?? null,
94
121
  totalCount: input.totalCount ?? null,
@@ -96,6 +123,9 @@ function toOperation(key: string, input: OperationInput, prev?: Operation): Oper
96
123
  totalBytes: input.totalBytes ?? null,
97
124
  cancellable: input.cancellable ?? false,
98
125
  retryable: input.retryable ?? false,
126
+ message: input.message ?? null,
127
+ outputs: input.outputs ?? [],
128
+ outputMode: input.outputMode ?? null,
99
129
  startedAt: prev?.startedAt ?? Date.now(),
100
130
  settledAt: prev?.settledAt ?? (terminal ? Date.now() : null),
101
131
  };
@@ -204,8 +234,11 @@ export function useOperations() {
204
234
  }
205
235
  for (const key of [...actions.keys()]) {
206
236
  if (key.startsWith(prefix) && !present.has(key) && !active.value.some((o) => o.key === key)) {
207
- // keep actions for rows still on screen (sticky errors)
208
- if (!next.some((o) => o.key === key)) actions.delete(key);
237
+ // keep actions for rows still on screen (sticky errors) and for
238
+ // history rows that can still be opened (a finished job's outputs)
239
+ if (next.some((o) => o.key === key)) continue;
240
+ if (history.value.some((o) => o.key === key && o.outputs.length > 0)) continue;
241
+ actions.delete(key);
209
242
  }
210
243
  }
211
244
 
@@ -229,6 +262,13 @@ export function useOperations() {
229
262
  acts?.dismiss?.();
230
263
  }
231
264
 
265
+ /** Reveal a finished row's output — the first one unless a path is named. */
266
+ function open(key: string, path?: string) {
267
+ const row = active.value.find((o) => o.key === key) ?? history.value.find((o) => o.key === key);
268
+ const target = path ?? row?.outputs[0];
269
+ if (target) actions.get(key)?.open?.(target);
270
+ }
271
+
232
272
  function clearHistory() {
233
273
  history.value = [];
234
274
  }
@@ -284,6 +324,7 @@ export function useOperations() {
284
324
  cancel,
285
325
  retry,
286
326
  dismiss,
327
+ open,
287
328
  clearHistory,
288
329
  };
289
330
  }