@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
@@ -0,0 +1,69 @@
1
+ /**
2
+ * dropLimits — what a file request (`/d/<token>`) will take, decided BEFORE a
3
+ * byte is sent.
4
+ *
5
+ * ⚠⚠ Why this exists (QA, 2026-09-21): the page sent every file it was given
6
+ * and let the server refuse it. A PDF-only link handed a .txt uploaded it
7
+ * anyway, got `415 ext_not_allowed` back, and printed the only fallback it had
8
+ * — the app-plugin runtime's "The app returned an error". A person who is not
9
+ * a filex user, sent a link by somebody who asked them for a document, should
10
+ * learn "that type is not accepted" before a transfer starts, not after.
11
+ *
12
+ * The rules are the SERVER's, restated — `handlers/drop.go` stays the
13
+ * authority and refuses whatever gets past this:
14
+ * · the extension, lower-cased, without its dot, must be in the list
15
+ * (`extAllowed`; an empty list allows every type);
16
+ * · a file may be at most `max_file_size_mb` MiB (`MaxFileSizeMB << 20` —
17
+ * binary megabytes, which is why this is not `formatSize`'s decimal MB);
18
+ * · one submission carries at most `max_files` files, and never more than
19
+ * the link has left (`uploads_left`).
20
+ */
21
+ import type { PublicDropLimits } from '../types/Public';
22
+
23
+ /** Why one file is not sent. `too_many` carries how many the submission could take. */
24
+ export type DropRefusal =
25
+ | { code: 'ext'; name: string }
26
+ | { code: 'too_large'; name: string; mb: number }
27
+ | { code: 'too_many'; count: number };
28
+
29
+ export interface DropCheck<F> {
30
+ accepted: F[];
31
+ refused: Array<{ file: F; reason: DropRefusal }>;
32
+ }
33
+
34
+ /** The server's extension test (`extAllowed`). */
35
+ export function dropExtAllowed(name: string, allow: readonly string[] | undefined): boolean {
36
+ const list = (allow ?? []).map((e) => e.trim().replace(/^\./, '').toLowerCase()).filter(Boolean);
37
+ if (!list.length) return true;
38
+ const dot = name.lastIndexOf('.');
39
+ const ext = dot > 0 || (dot === 0 && name.length > 1) ? name.slice(dot + 1).toLowerCase() : '';
40
+ return list.includes(ext);
41
+ }
42
+
43
+ /**
44
+ * Split what a person picked into what will be sent and what will not, with
45
+ * the reason for each refusal. The order is kept: the first files that fit are
46
+ * the ones that go, exactly as the no-JavaScript page picks them.
47
+ */
48
+ export function checkDropFiles<F extends { name: string; size: number }>(
49
+ files: readonly F[],
50
+ limits: PublicDropLimits | undefined,
51
+ uploadsLeft?: number | null,
52
+ ): DropCheck<F> {
53
+ const out: DropCheck<F> = { accepted: [], refused: [] };
54
+ const maxMb = limits?.max_file_size_mb ?? 0;
55
+ let room = limits?.max_files && limits.max_files > 0 ? limits.max_files : Infinity;
56
+ if (typeof uploadsLeft === 'number' && Number.isFinite(uploadsLeft)) room = Math.min(room, Math.max(0, uploadsLeft));
57
+ for (const f of files) {
58
+ if (!dropExtAllowed(f.name, limits?.allowed_ext)) {
59
+ out.refused.push({ file: f, reason: { code: 'ext', name: f.name } });
60
+ } else if (maxMb > 0 && f.size > maxMb * 1024 * 1024) {
61
+ out.refused.push({ file: f, reason: { code: 'too_large', name: f.name, mb: maxMb } });
62
+ } else if (out.accepted.length >= room) {
63
+ out.refused.push({ file: f, reason: { code: 'too_many', count: room } });
64
+ } else {
65
+ out.accepted.push(f);
66
+ }
67
+ }
68
+ return out;
69
+ }
@@ -0,0 +1,394 @@
1
+ /**
2
+ * errorWords — what a FAILURE says to a person. One place, every screen.
3
+ *
4
+ * ⚠⚠ The rule (owner, 2026-09-21, after the QA sweep): a failure is said as a
5
+ * translated sentence that says what happened and, where it applies, what to
6
+ * do. An administrator may see the technical detail as a SECOND line. A
7
+ * regular user never sees an environment variable, a status code or a JSON
8
+ * body. The sweep found all three on screen: "Config fetch 503: {…}", "set
9
+ * FILEX_SECRET_KEY to issue S3 access keys" in a regular user's panel,
10
+ * "save failed: 500 {…}" under an editor, "engine libreoffice is not
11
+ * installed on this host" in the operations centre.
12
+ *
13
+ * So:
14
+ * - `requestFailure` turns an HTTP refusal into an Error whose `message` is
15
+ * already the sentence, with `status`, the refusal's `code` and the raw
16
+ * body (`detail`) riding along for whoever may see them. useFileApi's
17
+ * `jsonFetch` / `fetchBlob` / uploads and every component that calls
18
+ * `fetch` itself go through it — one table of codes, not one per screen.
19
+ * - `sayFailure` turns ANY caught failure into `{ text, detail? }`: the
20
+ * sentence for everybody, the raw words only when the caller may
21
+ * administer the instance (`caller_admin`).
22
+ * - `jobFailure` is the operations centre's case: the server classifies a
23
+ * failed app job (`error_code`), and an app's own words are shown as
24
+ * they are unless they look like plumbing (`looksTechnical`).
25
+ */
26
+ import { foreignText } from './direction';
27
+ import { localeTable } from './uiLocales';
28
+
29
+ type Vars = Record<string, string | number>;
30
+
31
+ /**
32
+ * A translator.
33
+ *
34
+ * ⚠⚠ `foreign` is how a failure carries its reader's DIRECTION. Half of what
35
+ * this file puts on a screen is text filex did not write — the server's own
36
+ * sentence, an app's own words — and none of it passes through
37
+ * `useLocale().t` or the admin panel's post-translation hook, so in Arabic
38
+ * none of it was isolated: a path, a URL or a token syntax inside the
39
+ * sentence lost its neutral characters to the paragraph's direction
40
+ * (lib/direction `foreignText`, measured on `server.token.scope_unknown`).
41
+ * Threading a locale code through five signatures would have been five
42
+ * chances to forget it; the translator already travels with every call, so it
43
+ * carries the answer. A `t` that does not know (a test stub, a literal
44
+ * fallback) simply passes the text through.
45
+ */
46
+ export interface T {
47
+ (key: string, vars?: Vars): string;
48
+ /** Machine runs inside this language's sentences, isolated (RTL only). */
49
+ foreign?: (text: string) => string;
50
+ }
51
+
52
+ /** Say `text` — which filex did not write — the way `t`'s language needs. */
53
+ function foreignWith(t: T | undefined, text: string): string {
54
+ return typeof t?.foreign === 'function' ? t.foreign(text) : text;
55
+ }
56
+
57
+ /** A translator for a bare locale code — for callers with no `useLocale`. */
58
+ export function wordsIn(locale: string | undefined): T {
59
+ const t: T = (key, vars = {}) => {
60
+ const raw = localeTable(locale)[key] ?? key;
61
+ const said = Object.entries(vars).reduce((acc, [k, v]) => acc.replaceAll(`{${k}}`, String(v)), raw);
62
+ // ⚠ Isolated like `useLocale().t`'s output: this path never reached
63
+ // `render()`, so an engine name or a size pair in a failure's sentence was
64
+ // drawn raw in an Arabic panel while the same words elsewhere were right.
65
+ return foreignText(locale, said);
66
+ };
67
+ t.foreign = (text: string) => foreignText(locale, text);
68
+ return t;
69
+ }
70
+
71
+ /** An HTTP refusal, said. `message` is the sentence; the rest is for code
72
+ * that branches on it and for an administrator's second line. */
73
+ export interface RequestFailure extends Error {
74
+ status?: number;
75
+ /** The refusal's own code (`{"error":"read_only"}` → `read_only`), or ''. */
76
+ code?: string;
77
+ /** The raw body, clipped — NEVER shown to a regular user. */
78
+ detail?: string;
79
+ /** Marks an Error whose `message` is already the sentence. */
80
+ said?: true;
81
+ /** `admin_hint` — the fix, which the SERVER sends only to a caller who can
82
+ * act on it (handlers/tenantown.go `callerMayConfigureInstance`). */
83
+ hint?: string;
84
+ }
85
+
86
+ const STATUS_KEYS: Record<number, string> = {
87
+ 400: 'err.status.400',
88
+ 401: 'err.status.401',
89
+ 403: 'err.status.403',
90
+ 404: 'err.status.404',
91
+ 409: 'err.status.409',
92
+ 413: 'err.status.413',
93
+ 415: 'err.status.415',
94
+ 422: 'err.status.422',
95
+ 429: 'err.status.429',
96
+ 500: 'err.status.500',
97
+ 501: 'err.status.501',
98
+ /* ⚠ 502 and 504 are what an APP answers with (handlers/app_plugins.go
99
+ callFail: any plugin error, and a plugin that ran out of time). */
100
+ 502: 'err.status.502',
101
+ 503: 'err.status.503',
102
+ 504: 'err.status.504',
103
+ };
104
+
105
+ /** A status's words. ⚠ An unlisted status is NOT printed as "Error (418)":
106
+ * a number is not a sentence, and it is the one thing this file exists to
107
+ * keep off the screen. */
108
+ export function statusWords(status: number, locale: string | undefined): string {
109
+ return statusWordsWith(status, wordsIn(locale));
110
+ }
111
+
112
+ function statusWordsWith(status: number, t: T): string {
113
+ return t(STATUS_KEYS[status] ?? (status >= 500 ? 'err.status.500' : 'err.status.other'));
114
+ }
115
+
116
+ /**
117
+ * Whether a status's own words tell a person something the caller's words
118
+ * would not: a refusal they can act on (401 sign in, 403 not allowed, 404
119
+ * gone, 409 conflict, 413 too large, 429 slow down…). A 5xx, or a status with
120
+ * no words of its own, says only "it failed" — the caller's sentence for what
121
+ * it was doing ("Your changes could not be saved") says more.
122
+ */
123
+ export function statusIsTelling(status: number): boolean {
124
+ return status >= 400 && status < 500 && status in STATUS_KEYS;
125
+ }
126
+
127
+ /**
128
+ * Refusals a person can act on, by the code or words the server answers with.
129
+ *
130
+ * ⚠⚠ A read-only storage answers in two shapes — `409 {"error":"read_only"}`
131
+ * from the app and public-API paths, `403 {"error":"storage is read-only"}`
132
+ * from the manager's mutating verbs — and by status alone they read "Already
133
+ * exists / conflict" and "You are not allowed to do this" (QA, 2026-09-21).
134
+ * `no_secret_key` is the S3/SSH key endpoints on an install with no
135
+ * encryption key: its server message names an environment variable, which a
136
+ * regular user must never be shown.
137
+ */
138
+ const CODE_WORDS: ReadonlyArray<readonly [RegExp, string]> = [
139
+ [/^read_only$|read-only/i, 'err.read_only'],
140
+ [/^no_secret_key$/, 'err.no_secret_key'],
141
+ [/^quota_exceeded$|quota exceeded|quota: exceeded/i, 'err.quota'],
142
+ ];
143
+
144
+ function fieldsOf(body: string): Record<string, unknown> {
145
+ try {
146
+ const parsed = JSON.parse(body) as unknown;
147
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : {};
148
+ } catch {
149
+ return {};
150
+ }
151
+ }
152
+
153
+ /** The `error` field of a JSON refusal body, or ''. */
154
+ export function refusalCode(body: string): string {
155
+ const v = fieldsOf(body).error;
156
+ return typeof v === 'string' ? v : '';
157
+ }
158
+
159
+ /** The words for a refusal: what the server SAID when a person can act on it,
160
+ * the status's words otherwise. */
161
+ export function refusalWords(status: number, body: string, locale: string | undefined): string {
162
+ return refusalWordsWith(status, body, wordsIn(locale));
163
+ }
164
+
165
+ function refusalWordsWith(status: number, body: string, t: T): string {
166
+ const code = refusalCode(body);
167
+ for (const [re, key] of CODE_WORDS) if (code && re.test(code)) return t(key);
168
+ return statusWordsWith(status, t);
169
+ }
170
+
171
+ /** An HTTP refusal as an Error carrying its own sentence. */
172
+ export function requestFailure(status: number, body: string, locale: string | undefined): RequestFailure {
173
+ const err = new Error(refusalWords(status, body, locale)) as RequestFailure;
174
+ err.status = status;
175
+ err.code = refusalCode(body);
176
+ err.detail = body.slice(0, 300);
177
+ err.said = true;
178
+ const hint = fieldsOf(body).admin_hint;
179
+ if (typeof hint === 'string' && hint) err.hint = hint;
180
+ return err;
181
+ }
182
+
183
+ /**
184
+ * What to print for a refusal on a screen that used to print the server's
185
+ * `error` field as it came (the connection panels: keys, tokens, exports).
186
+ *
187
+ * A known code is said in the person's language (`CODE_WORDS`); a server
188
+ * sentence written for a person ("public key is not a valid OpenSSH key") is
189
+ * kept, because the status's words would lose what went wrong; plumbing and
190
+ * bare codes fall back to the status's words. ⚠ This replaced five copies of
191
+ * one `messageOf` that printed the field whatever it held — including an
192
+ * environment variable to a regular user.
193
+ */
194
+ export function serverWords(err: unknown, locale?: string): string {
195
+ // ⚠ `locale` is the reader's: what comes back may be the SERVER's sentence,
196
+ // which never passed through a translator, and an Arabic panel needs its
197
+ // machine runs isolated (lib/direction `foreignText`).
198
+ const say = (text: string): string => foreignText(locale, text);
199
+ const e = err as Partial<RequestFailure> | null | undefined;
200
+ if (!e || e.said !== true) return say(e instanceof Error ? e.message : err ? String(err) : '');
201
+ const code = e.code ?? '';
202
+ if (code && CODE_WORDS.some(([re]) => re.test(code))) return say(e.message ?? '');
203
+ const f = fieldsOf(e.detail ?? '');
204
+ const own = typeof f.message === 'string' && f.message ? f.message : code;
205
+ if (own && !looksTechnical(own) && !/^[a-z0-9_]+$/.test(own)) return say(own);
206
+ return say(e.message ?? '');
207
+ }
208
+
209
+ /** The request never got an answer (offline, DNS, CORS, a dropped socket). */
210
+ export function networkFailure(locale: string | undefined, cause?: unknown): RequestFailure {
211
+ const err = new Error(wordsIn(locale)('err.network')) as RequestFailure;
212
+ err.detail = cause instanceof Error ? cause.message : cause ? String(cause) : '';
213
+ err.said = true;
214
+ return err;
215
+ }
216
+
217
+ /**
218
+ * True for text a person should not be shown as the sentence: a JSON body, a
219
+ * status line, an environment variable, a Go error chain, a stack trace, a
220
+ * file path on the server.
221
+ *
222
+ * ⚠ It guards words that came from somewhere we do not control — an app's
223
+ * own error text above all. A real sentence ("Bu belge zaten imzalanmış")
224
+ * passes; plumbing does not. It is deliberately greedy: a human sentence
225
+ * mistaken for plumbing costs the person the app's wording (they still read
226
+ * a sentence), plumbing mistaken for a sentence is the defect.
227
+ */
228
+ export function looksTechnical(s: string): boolean {
229
+ const v = s.trim();
230
+ if (!v) return false;
231
+ return (
232
+ /^[[{]/.test(v) || // a JSON body
233
+ /\b[1-5]\d\d\b\s+[A-Z][a-z]+/.test(v) || // "500 Internal Server Error"
234
+ /\b[A-Z][A-Z0-9]+_[A-Z0-9_]+\b/.test(v) || // FILEX_SECRET_KEY
235
+ /\b(panic|goroutine|stack trace|Traceback|at [\w$.]+ \()/i.test(v) ||
236
+ /^[\w.-]+: [\w.-]+(: |$)/.test(v) || // "protocolauth: no secret key…"
237
+ /(^|\s)\/(?:[\w.-]+\/){2,}/.test(v) || // /var/lib/filex/…
238
+ /\b[a-z][\w.-]*: [^:()]{1,60}: [a-z][\w.-]*: /i.test(v) || // a Go error chain "a: b: c: d"
239
+ /\b(ECONNREFUSED|ETIMEDOUT|EOF|nil pointer|dial tcp|x509|context deadline exceeded)\b/i.test(v)
240
+ );
241
+ }
242
+
243
+ /** `"<sentence> (<plumbing>)"` → `[sentence, plumbing]`; anything else → `[raw, '']`. */
244
+ export function splitTechnicalTail(raw: string): [string, string] {
245
+ const m = /^(.*\S)\s*\(([^()]*)\)\s*$/.exec(raw);
246
+ if (m && looksTechnical(m[2])) return [m[1], m[2]];
247
+ return [raw, ''];
248
+ }
249
+
250
+ /** A failure, said: the sentence for everybody, the raw words for an admin. */
251
+ export interface SaidFailure {
252
+ text: string;
253
+ /** Present only when the caller may administer the instance. */
254
+ detail?: string;
255
+ }
256
+
257
+ /**
258
+ * Say a caught failure.
259
+ *
260
+ * A `RequestFailure` already carries its sentence (`message`); anything else —
261
+ * a thrown JS error, a library's message — is said as `fallback`, because its
262
+ * text was written for a developer. The raw words go to the console as well,
263
+ * where the person debugging looks for them.
264
+ */
265
+ export function sayFailure(
266
+ err: unknown,
267
+ fallback: string,
268
+ opts: {
269
+ callerAdmin?: boolean;
270
+ /** The caller's own translator. A viewer knows `t` but not the locale
271
+ * code a refusal was built with, so the refusal is said again in its
272
+ * words rather than in whatever language the request was made in. */
273
+ t?: T;
274
+ } = {},
275
+ ): SaidFailure {
276
+ const e = err as Partial<RequestFailure> | null | undefined;
277
+ const said = e?.said === true;
278
+ const raw = said ? (e?.detail ?? '') : e instanceof Error ? e.message : err ? String(err) : '';
279
+ const text = !said
280
+ ? fallback
281
+ : opts.t && typeof e?.status === 'number'
282
+ ? refusalWordsWith(e.status, e.detail ?? '', opts.t)
283
+ : e?.message || fallback;
284
+ if (!said && raw && typeof console !== 'undefined') console.warn('[filex]', raw);
285
+ // ⚠ Both lines are isolated for the reader's direction: `text` may be the
286
+ // fallback the caller wrote (already said by ITS `t`, and isolation is
287
+ // idempotent) and `detail` is the raw server words — a JSON body, a path,
288
+ // an error chain, all of it machine text an Arabic line would scramble.
289
+ const shown = foreignWith(opts.t, text);
290
+ const detail = foreignWith(opts.t, raw);
291
+ return opts.callerAdmin && raw && raw !== text ? { text: shown, detail } : { text: shown };
292
+ }
293
+
294
+ /** What the server says about a failed app job (ops row `error_code`). */
295
+ export type JobErrorCode =
296
+ | 'timeout'
297
+ | 'out_of_memory'
298
+ | 'crashed'
299
+ | 'app_removed'
300
+ | 'action_removed'
301
+ | 'engine_missing'
302
+ | 'cancelled'
303
+ | 'app';
304
+
305
+ /**
306
+ * A failed operation, said — the operations centre and its toast.
307
+ *
308
+ * `error_code` comes from the server (wasmplugin DecorateOps), which knows
309
+ * the failures it produced itself; the raw `error` stays the admin's second
310
+ * line. With no code (a copy / move, or an older server), known refusals are
311
+ * recognised by their words (`CODE_WORDS`) and anything else is `fallback`.
312
+ */
313
+ /** A queue row, as usePendingOps normalises it. */
314
+ export interface FailedOpLike {
315
+ op_type?: string;
316
+ label?: string;
317
+ action?: string;
318
+ error_message?: string | null;
319
+ error_code?: string | null;
320
+ error_engine?: string | null;
321
+ }
322
+
323
+ /**
324
+ * A failed QUEUE row, said — the one call the tray and the explorer's toast
325
+ * share, so the two can never say different things about one failure.
326
+ * ⚠ Before it, the toast passed the row itself and read `op.error` — a field
327
+ * a queue row does not have (it is `error_message`) — and always said the
328
+ * fallback while the tray beside it said the app's words.
329
+ */
330
+ export function opFailure(op: FailedOpLike, t: T, opts: { callerAdmin?: boolean } = {}): SaidFailure {
331
+ const isApp = op.op_type === 'plugin' || op.op_type === 'plugin-action';
332
+ const fallback = isApp
333
+ ? t('plugin.failed', { label: op.label || op.action || t('opc.kind.plugin') })
334
+ : t('toast.failed');
335
+ return jobFailure(
336
+ { error: op.error_message, error_code: op.error_code, error_engine: op.error_engine },
337
+ fallback,
338
+ t,
339
+ opts,
340
+ );
341
+ }
342
+
343
+ export function jobFailure(
344
+ op: { error?: string | null; error_code?: string | null; error_engine?: string | null },
345
+ fallback: string,
346
+ t: T,
347
+ opts: { callerAdmin?: boolean } = {},
348
+ ): SaidFailure {
349
+ const raw = (op.error ?? '').trim();
350
+ const admin = opts.callerAdmin === true;
351
+ /** ⚠ The APP's own words and the raw failure both come from outside the
352
+ * catalogue, so both are isolated for the reader's direction (`T.foreign`,
353
+ * lib/direction `foreignText`): an app's sentence ends in a library error
354
+ * in brackets, and an engine's name, a path or a `a: b: c` chain drawn in
355
+ * an Arabic line loses its neutral characters to the paragraph. */
356
+ const say = (text: string): string => foreignWith(t, text);
357
+ const withDetail = (text: string): SaidFailure =>
358
+ admin && raw && raw !== text ? { text: say(text), detail: say(raw) } : { text: say(text) };
359
+ const engine = op.error_engine || '';
360
+ switch (op.error_code) {
361
+ case 'timeout':
362
+ return withDetail(t('opc.err.timeout'));
363
+ case 'out_of_memory':
364
+ return withDetail(t('opc.err.out_of_memory'));
365
+ case 'crashed':
366
+ return withDetail(t('opc.err.crashed'));
367
+ case 'app_removed':
368
+ return withDetail(t('opc.err.app_removed'));
369
+ case 'action_removed':
370
+ return withDetail(t('opc.err.action_removed'));
371
+ case 'cancelled':
372
+ return { text: t('opc.status.aborted') };
373
+ case 'engine_missing':
374
+ return withDetail(t(admin ? 'opc.err.engine_missing_admin' : 'opc.err.engine_missing', { engine }));
375
+ case 'app': {
376
+ // The app's own words, meant for the person (wasmplugin CallError:
377
+ // "safe for a user; the guest's own words") — unless they are plumbing.
378
+ // ⚠ An app often ends its sentence with the library's error in
379
+ // brackets: "hiçbir dosya dönüştürülemedi — broken.docx: dönüşüm
380
+ // başarısız oldu (office: not a zip package: zip: not a valid zip
381
+ // file)" was on a regular user's screen (2026-09-21). The sentence is
382
+ // kept, the bracket goes to the admin's second line.
383
+ const [sentence, tail] = splitTechnicalTail(raw);
384
+ if (sentence && !looksTechnical(sentence)) {
385
+ return tail && admin ? { text: say(sentence), detail: say(raw) } : { text: say(sentence) };
386
+ }
387
+ return withDetail(fallback);
388
+ }
389
+ default:
390
+ break;
391
+ }
392
+ for (const [re, key] of CODE_WORDS) if (raw && re.test(raw)) return withDetail(t(key));
393
+ return withDetail(fallback);
394
+ }
@@ -130,7 +130,11 @@ function domLoadFramed(url: string, timeoutMs: number): Promise<'ready' | 'timeo
130
130
  }
131
131
  const frame = document.createElement('iframe');
132
132
  frame.setAttribute('aria-hidden', 'true');
133
- frame.style.cssText = 'position:absolute;left:-9999px;width:1px;height:1px;border:0';
133
+ // ⚠ RTL: parked off the START edge (`inset-inline-start`), never `left`:
134
+ // in a right-to-left page the left edge is the END, where overflow is
135
+ // scrollable — `left:-9999px` there hangs a 10 000px scrollbar under the
136
+ // page for as long as the probe runs.
137
+ frame.style.cssText = 'position:absolute;inset-inline-start:-9999px;width:1px;height:1px;border:0';
134
138
  let done = false;
135
139
  const finish = (r: 'ready' | 'timeout') => {
136
140
  if (done) return;
@@ -146,12 +146,17 @@ export const EMPTY_FILTERS: DriveFilters = {
146
146
  /** Case- and accent-folded, so "İstanbul" answers to "ist" and "Ödev" to "od".
147
147
  * ⚠ `toLowerCase()` alone maps `İ` to `i` + a combining dot, which then
148
148
  * matches nothing the user typed; stripping the marks is what makes the two
149
- * sides comparable. */
149
+ * sides comparable.
150
+ * ⚠ And the four Latin i's are one letter, as they are to the server's search
151
+ * and to tags (internal/namefold, `tagKey`): `ı` has no mark to strip, so
152
+ * without the last step "ışık" did not answer to "IŞIK" — nor "IŞIK" to
153
+ * "ışık", since `toLowerCase` lowers `I` to `i`. */
150
154
  function fold(s: string): string {
151
155
  return s
152
156
  .normalize('NFD')
153
157
  .replace(/\p{M}+/gu, '')
154
- .toLowerCase();
158
+ .toLowerCase()
159
+ .replace(/ı/g, 'i');
155
160
  }
156
161
 
157
162
  /** The trimmed needle, or '' when the input is empty/whitespace. */
@@ -362,6 +362,10 @@ const EXT_TYPE_KEYS: Record<string, string> = {
362
362
  // is a decision for the GLYPH, not for the word) — naming them still beats
363
363
  // printing "FIG".
364
364
  fig: 'ftype.figma',
365
+ // ⚠ Named, not "DRAWIO": the uppercased extension was a fourth spelling of
366
+ // draw.io beside "Drawio", "drawio" and "diagrams.net" (QA, 2026-09-21).
367
+ drawio: 'ftype.drawio',
368
+ dio: 'ftype.drawio',
365
369
  sketch: 'ftype.design',
366
370
  xd: 'ftype.design',
367
371
  psd: 'ftype.design',
@@ -0,0 +1,77 @@
1
+ /**
2
+ * internalPaths.ts — the client's ONE definition of the directories filex keeps
3
+ * for itself inside a storage: the trash bin, version history, the legacy
4
+ * thumbnail cache, and the desktop app's "open with filex" working area, plus
5
+ * the empty-folder marker file.
6
+ *
7
+ * ⚠⚠ The server is the real gate. Since this release it never lists, searches
8
+ * or serves these to a person (backend/internal/syspath — the list the whole
9
+ * backend shares), and a notification never names or targets one. This copy
10
+ * exists for what only the client can do: refuse to NAVIGATE into one (a URL
11
+ * hash typed by hand, a tab or persisted path left over from before — the
12
+ * owner's report of 2026-09-21 began with a notification click that landed in
13
+ * `.filex-open`), and keep an embed that talks to an older server from
14
+ * listing them.
15
+ *
16
+ * ⚠ web/tests/lib/internalPaths.test.ts parses backend/internal/syspath/
17
+ * syspath.go and fails when this list and that one differ. Add a name there;
18
+ * the test tells you it belongs here too. This file used to be three
19
+ * hand-written checks in lib/listing.ts, one of them `path.includes('.thumbs')`
20
+ * — which also hid a person's own `my.thumbs.txt`.
21
+ */
22
+
23
+ /** The internal directory names, in syspath.go's order. */
24
+ export const INTERNAL_DIR_NAMES = ['.filex-trash', '.versions', '.thumbs', '.filex-open'] as const;
25
+
26
+ /** The zero-byte file that keeps an empty folder alive on a blob store. */
27
+ export const KEEP_MARKER_NAME = '.keepdir';
28
+
29
+ const DIRS: ReadonlySet<string> = new Set<string>(INTERNAL_DIR_NAMES);
30
+
31
+ /** The storage-relative segments of a path in any of the shapes the explorer
32
+ * carries: `docs://a/b`, `docs/a/b` (the hash form — its first segment is the
33
+ * storage, which is never an internal name), `/a/b/`, `a\b`. */
34
+ function segments(path: string): string[] {
35
+ let p = String(path ?? '').trim().replace(/\\/g, '/');
36
+ const at = p.indexOf('://');
37
+ if (at >= 0) p = p.slice(at + 3);
38
+ return p.split('/').filter((s) => s && s !== '.');
39
+ }
40
+
41
+ /** True for one entry name that is filex's own — a directory or the marker. */
42
+ export function isInternalName(name: string): boolean {
43
+ const n = String(name ?? '').trim();
44
+ return DIRS.has(n) || n === KEEP_MARKER_NAME;
45
+ }
46
+
47
+ /**
48
+ * True when a person must never be shown `path`: it is one of filex's
49
+ * directories or lies inside one (at any depth, which is the server's rule
50
+ * too), or it is a keep marker.
51
+ */
52
+ export function isInternalPath(path: string): boolean {
53
+ const segs = segments(path);
54
+ if (segs.some((s) => DIRS.has(s))) return true;
55
+ return segs.length > 0 && segs[segs.length - 1] === KEEP_MARKER_NAME;
56
+ }
57
+
58
+ /**
59
+ * The address a listing request should go to: `wire` itself, or — when it
60
+ * points into one of filex's directories — that storage's root.
61
+ *
62
+ * "Resolved rather than refused": a person who arrives at `docs://.filex-open`
63
+ * (by a stale notification link, an old tab, a typed hash) is standing in a
64
+ * folder that is not theirs to see, and the useful place to put them is the
65
+ * storage it belongs to — not a "folder not found" page that prints the very
66
+ * path it is refusing to show.
67
+ *
68
+ * ⚠ Only the explorer's listing calls go through here. The desktop app's
69
+ * open-with flow lists its working area by exact path over its own HTTP
70
+ * client (desktop/src/openwith-io.ts), and the server keeps answering that.
71
+ */
72
+ export function listingAddress(wire: string): string {
73
+ const w = String(wire ?? '');
74
+ if (!isInternalPath(w)) return w;
75
+ const at = w.indexOf('://');
76
+ return at >= 0 ? w.slice(0, at + 3) : '';
77
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * listInput — the ONE splitter for "several things typed into one field":
3
+ * recipients, extensions, tags, identities, a multi-select's stored answer.
4
+ *
5
+ * ⚠⚠ The comma is not one character. An Arabic, Persian or Urdu keyboard
6
+ * types "،" (U+060C ARABIC COMMA) where an English one types ",", Chinese and
7
+ * Japanese input methods type "," (U+FF0C FULLWIDTH COMMA) or "、" (U+3001
8
+ * IDEOGRAPHIC COMMA), and the semicolon has its own Arabic (U+061B "؛") and
9
+ * fullwidth (U+FF1B ";") forms. Every list field used to split on the ASCII
10
+ * characters only, so `a@x.com، b@y.com` — exactly what a person writing in
11
+ * Arabic produces — became ONE malformed address and the share mail said
12
+ * "enter a valid email" about a perfectly valid list. Five fields each carried
13
+ * their own `split(/[,…]/)`; they all read through here now, so the next
14
+ * script's comma is added once.
15
+ *
16
+ * Items are trimmed and empty ones dropped; nothing else is decided here
17
+ * (lower-casing, de-duplication, "does it contain an @" stay with the caller,
18
+ * which knows what an item is).
19
+ */
20
+
21
+ /** , ، 、 , — the characters people type between list items. */
22
+ const COMMAS = ',\u060C\u3001\uFF0C';
23
+ /** ; ؛ ; */
24
+ const SEMICOLONS = ';\u061B\uFF1B';
25
+
26
+ export interface SplitListOptions {
27
+ /** Whitespace separates items too (never for tags, which may contain a space). */
28
+ spaces?: boolean;
29
+ /** Semicolons separate items too (a mail client's recipient habit). */
30
+ semicolons?: boolean;
31
+ /** Line breaks separate items too (a pasted column). Implied by `spaces`. */
32
+ newlines?: boolean;
33
+ }
34
+
35
+ const cache = new Map<string, RegExp>();
36
+
37
+ function separatorRe(o: SplitListOptions): RegExp {
38
+ const key = `${o.spaces ? 1 : 0}${o.semicolons ? 1 : 0}${o.newlines ? 1 : 0}`;
39
+ let re = cache.get(key);
40
+ if (!re) {
41
+ const cls = COMMAS + (o.semicolons ? SEMICOLONS : '') + (o.newlines ? '\\n\\r' : '') + (o.spaces ? '\\s' : '');
42
+ re = new RegExp(`[${cls}]+`, 'u');
43
+ cache.set(key, re);
44
+ }
45
+ return re;
46
+ }
47
+
48
+ /** `"a، b ,c"` → `["a", "b", "c"]`. */
49
+ export function splitList(raw: string | null | undefined, opts: SplitListOptions = {}): string[] {
50
+ return String(raw ?? '')
51
+ .split(separatorRe(opts))
52
+ .map((s) => s.trim())
53
+ .filter((s) => s !== '');
54
+ }
55
+
56
+ /**
57
+ * Does this key END a list item in a chip-style field (the typed address or
58
+ * tag becomes a chip)? `,` and each of its siblings above — the same set the
59
+ * splitter uses, so a field that splits a paste and a field that commits on a
60
+ * keypress agree about what a comma is.
61
+ */
62
+ export function isListSeparatorKey(key: string): boolean {
63
+ return key.length === 1 && COMMAS.includes(key);
64
+ }