@brftech/filex-core 0.42.1 → 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.
- package/README.md +14 -2
- package/dist/ArchiveViewer-BqU761Wk.js +149 -0
- package/dist/ArchiveViewer-BqU761Wk.js.map +1 -0
- package/dist/CsvViewer-ByHVK5ga.js +172 -0
- package/dist/CsvViewer-ByHVK5ga.js.map +1 -0
- package/dist/{DrawioViewer-BuhZOAx6.js → DrawioViewer-Bgrj8-zG.js} +45 -44
- package/dist/DrawioViewer-Bgrj8-zG.js.map +1 -0
- package/dist/{EpubViewer-DS3mb3k8.js → EpubViewer-DJwUKlHW.js} +50 -50
- package/dist/EpubViewer-DJwUKlHW.js.map +1 -0
- package/dist/IpynbViewer-Bz0eJ4NS.js +184 -0
- package/dist/IpynbViewer-Bz0eJ4NS.js.map +1 -0
- package/dist/MermaidViewer-CEDTD_pp.js +136 -0
- package/dist/MermaidViewer-CEDTD_pp.js.map +1 -0
- package/dist/PsdViewer-CZcxUpZO.js +121 -0
- package/dist/PsdViewer-CZcxUpZO.js.map +1 -0
- package/dist/TiffViewer-BwI62CbQ.js +141 -0
- package/dist/TiffViewer-BwI62CbQ.js.map +1 -0
- package/dist/{Viewer3D-CIrOn63I.js → Viewer3D-DoeZcuMm.js} +16 -16
- package/dist/Viewer3D-DoeZcuMm.js.map +1 -0
- package/dist/filex-core.js +475 -219
- package/dist/filex-core.umd.cjs +165 -108
- package/dist/filex-core.umd.cjs.map +1 -1
- package/dist/fonts/caveat-latin-ext.woff2 +0 -0
- package/dist/fonts/caveat-latin.woff2 +0 -0
- package/dist/fonts/dancing-script-latin-ext.woff2 +0 -0
- package/dist/fonts/dancing-script-latin.woff2 +0 -0
- package/dist/fonts/homemade-apple-latin.woff2 +0 -0
- package/dist/fonts/source-serif-4-latin-ext.woff2 +0 -0
- package/dist/fonts/source-serif-4-latin.woff2 +0 -0
- package/dist/index-DuWnGuv5.js +35901 -0
- package/dist/index-DuWnGuv5.js.map +1 -0
- package/dist/index.d.ts +5241 -553
- package/dist/pdf-CaPYsOUo.js +21302 -0
- package/dist/pdf-CaPYsOUo.js.map +1 -0
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/FileExplorer.vue +1207 -343
- package/src/assets/fonts/LICENSE-Apache-2.0.txt +202 -0
- package/src/assets/fonts/LICENSE-OFL-1.1.txt +93 -0
- package/src/assets/fonts/README.md +58 -0
- package/src/assets/fonts/caveat-latin-ext.woff2 +0 -0
- package/src/assets/fonts/caveat-latin.woff2 +0 -0
- package/src/assets/fonts/dancing-script-latin-ext.woff2 +0 -0
- package/src/assets/fonts/dancing-script-latin.woff2 +0 -0
- package/src/assets/fonts/homemade-apple-latin.woff2 +0 -0
- package/src/assets/fonts/source-serif-4-latin-ext.woff2 +0 -0
- package/src/assets/fonts/source-serif-4-latin.woff2 +0 -0
- package/src/components/AdvancedSearch.vue +49 -0
- package/src/components/Breadcrumb.vue +5 -5
- package/src/components/ChoiceButtons.vue +152 -0
- package/src/components/CommandPalette.vue +5 -5
- package/src/components/ConnectionGuideView.vue +1 -1
- package/src/components/ConnectionNotice.vue +59 -0
- package/src/components/ConnectionsPanel.vue +30 -568
- package/src/components/ContextMenu.vue +31 -5
- package/src/components/DataTable.vue +1331 -0
- package/src/components/FilePane.vue +3 -0
- package/src/components/FilterBar.vue +12 -5
- package/src/components/GalleryView.vue +50 -2
- package/src/components/GridView.vue +51 -3
- package/src/components/HomeView.vue +18 -6
- package/src/components/InspectorPanel.vue +134 -17
- package/src/components/ListView.vue +366 -900
- package/src/components/LogoMark.vue +57 -0
- package/src/components/NFSExportsPanel.vue +76 -28
- package/src/components/OnboardingTour.vue +19 -7
- package/src/components/OperationsCenter.vue +77 -1
- package/src/components/PendingOpsTray.vue +59 -5
- package/src/components/PresenceBar.vue +7 -7
- package/src/components/ProductVersion.vue +31 -0
- package/src/components/QuickLook.vue +10 -14
- package/src/components/RecentlyOpened.vue +47 -17
- package/src/components/RecoveryKeyModal.vue +1 -1
- package/src/components/RowActions.vue +134 -0
- package/src/components/S3KeysPanel.vue +98 -56
- package/src/components/SSHKeysPanel.vue +79 -26
- package/src/components/SideNav.vue +260 -96
- package/src/components/StarButton.vue +1 -0
- package/src/components/StorageFields.vue +74 -6
- package/src/components/StorageTags.vue +62 -0
- package/src/components/TabBar.vue +1 -1
- package/src/components/TagKindIcon.vue +56 -0
- package/src/components/TagPicker.vue +226 -40
- package/src/components/ThemePalette.vue +4 -4
- package/src/components/TimeZonePicker.vue +29 -10
- package/src/components/TokensPanel.vue +82 -27
- package/src/components/Toolbar.vue +28 -6
- package/src/components/plugin/PluginConfirmModal.vue +54 -0
- package/src/components/plugin/PluginInspectorSection.vue +149 -0
- package/src/components/plugin/PluginPageView.vue +339 -0
- package/src/components/plugin/PluginViewModal.vue +159 -0
- package/src/components/plugin/SurfaceConversation.vue +76 -0
- package/src/components/plugin/SurfaceFooterButtons.vue +73 -0
- package/src/components/plugin/SurfaceRenderer.vue +299 -0
- package/src/components/plugin/SurfaceSections.vue +91 -0
- package/src/components/plugin/nodes/PdfFieldEditor.vue +509 -0
- package/src/components/plugin/nodes/SurfaceFileChooser.vue +94 -0
- package/src/components/plugin/nodes/SurfaceForm.vue +82 -0
- package/src/components/plugin/nodes/SurfaceList.vue +127 -0
- package/src/components/plugin/nodes/SurfacePdfFields.vue +1285 -0
- package/src/components/plugin/nodes/SurfacePeoplePicker.vue +213 -0
- package/src/components/plugin/nodes/SurfacePinInput.vue +57 -0
- package/src/components/plugin/nodes/SurfacePreview.vue +119 -0
- package/src/components/plugin/nodes/SurfaceProgress.vue +48 -0
- package/src/components/plugin/nodes/SurfaceSignaturePad.vue +472 -0
- package/src/components/plugin/nodes/SurfaceSteps.vue +53 -0
- package/src/components/public/PublicLanguagePicker.vue +58 -0
- package/src/components/public/PublicLinkPage.vue +286 -0
- package/src/components/public/PublicLinkPreview.vue +85 -0
- package/src/components/public/PublicPinGate.vue +108 -0
- package/src/components/public/PublicRequestBody.vue +204 -0
- package/src/components/public/PublicShareBody.vue +188 -0
- package/src/components/public/PublicShell.vue +332 -0
- package/src/composables/useConnections.ts +8 -2
- package/src/composables/useFileApi.ts +183 -44
- package/src/composables/useLocale.ts +277 -41
- package/src/composables/useNFSExports.ts +8 -10
- package/src/composables/useOperations.ts +44 -3
- package/src/composables/usePendingOps.ts +59 -8
- package/src/composables/usePluginActions.ts +121 -0
- package/src/composables/usePluginSurface.ts +223 -0
- package/src/composables/usePublicBranding.ts +172 -0
- package/src/composables/usePublicLink.ts +573 -0
- package/src/composables/usePublicPage.ts +61 -0
- package/src/composables/useS3Keys.ts +17 -12
- package/src/composables/useSSHKeys.ts +8 -10
- package/src/composables/useTokens.ts +8 -12
- package/src/composables/useUploadChunked.ts +18 -8
- package/src/composables/useViewerFetch.ts +6 -3
- package/src/index.ts +404 -8
- package/src/lib/actionIcons.ts +46 -1
- package/src/lib/advSearch.ts +14 -7
- package/src/lib/appLock.ts +130 -0
- package/src/lib/connection.ts +110 -0
- package/src/lib/destinationTree.ts +19 -3
- package/src/lib/direction.ts +353 -0
- package/src/lib/dragGhost.ts +6 -0
- package/src/lib/dropLimits.ts +69 -0
- package/src/lib/errorWords.ts +394 -0
- package/src/lib/externalReach.ts +5 -1
- package/src/lib/fileFilters.ts +7 -2
- package/src/lib/fileIcons.ts +4 -0
- package/src/lib/internalPaths.ts +77 -0
- package/src/lib/listInput.ts +64 -0
- package/src/lib/listing.ts +58 -23
- package/src/lib/modalStack.ts +26 -0
- package/src/lib/nodeRow.ts +121 -0
- package/src/lib/pdfFieldRules.ts +126 -0
- package/src/lib/pdfFields.ts +589 -0
- package/src/lib/pdfFieldsGeom.ts +203 -0
- package/src/lib/pdfjsLoader.ts +51 -0
- package/src/lib/personName.ts +47 -0
- package/src/lib/pluginApplies.ts +123 -0
- package/src/lib/pluginLabel.ts +72 -0
- package/src/lib/pluginMenu.ts +253 -0
- package/src/lib/pluginPage.ts +58 -0
- package/src/lib/plural.ts +181 -0
- package/src/lib/popupLayer.ts +103 -0
- package/src/lib/prefs.ts +563 -0
- package/src/lib/productVersion.ts +35 -0
- package/src/lib/publicLayout.ts +39 -0
- package/src/lib/serviceGate.ts +104 -0
- package/src/lib/shareTtl.ts +25 -11
- package/src/lib/signFonts.ts +85 -0
- package/src/lib/signaturePad.ts +111 -0
- package/src/lib/sortOrder.ts +100 -38
- package/src/lib/star.ts +7 -1
- package/src/lib/surfaceCell.ts +41 -0
- package/src/lib/surfaceConditions.ts +156 -0
- package/src/lib/surfaceOpen.ts +106 -0
- package/src/lib/surfaceSteps.ts +68 -0
- package/src/lib/surfaceValues.ts +191 -0
- package/src/lib/symlink.ts +150 -0
- package/src/lib/tableColumns.ts +493 -0
- package/src/lib/tableEnv.ts +34 -0
- package/src/lib/tablePrefs.ts +84 -0
- package/src/lib/tags.ts +121 -12
- package/src/lib/themes.ts +286 -18
- package/src/lib/tour.ts +152 -0
- package/src/lib/trashEmpty.ts +111 -0
- package/src/lib/trashTimeLeft.ts +23 -0
- package/src/lib/uiLocales.ts +460 -0
- package/src/lib/viewPrefs.ts +696 -660
- package/src/lib/viewPrefsHttp.ts +78 -0
- package/src/locales/en.ts +522 -119
- package/src/locales/resolve.ts +17 -8
- package/src/locales/tr.ts +539 -155
- package/src/modals/ConvertModal.vue +62 -11
- package/src/modals/DestinationPickerModal.vue +59 -16
- package/src/modals/Modal.vue +113 -23
- package/src/modals/NewDocumentModal.vue +16 -6
- package/src/modals/NewFolderModal.vue +8 -0
- package/src/modals/PermissionsModal.vue +272 -163
- package/src/modals/PreviewModal.vue +119 -20
- package/src/modals/RenameModal.vue +37 -1
- package/src/styles/base.css +2895 -241
- package/src/styles/sign-fonts.css +118 -0
- package/src/styles/variables.css +60 -0
- package/src/types/Connections.ts +17 -0
- package/src/types/ExplorerConfig.ts +117 -1
- package/src/types/FileNode.ts +58 -0
- package/src/types/Plugins.ts +494 -0
- package/src/types/Public.ts +212 -0
- package/src/viewers/ArchiveViewer.vue +93 -48
- package/src/viewers/CsvViewer.vue +99 -53
- package/src/viewers/DrawioViewer.vue +35 -9
- package/src/viewers/EpubViewer.vue +7 -8
- package/src/viewers/IpynbViewer.vue +3 -1
- package/src/viewers/MermaidViewer.vue +4 -5
- package/src/viewers/PdfViewer.vue +8 -17
- package/src/viewers/PsdViewer.vue +2 -4
- package/src/viewers/TiffViewer.vue +4 -5
- package/src/viewers/Viewer3D.vue +1 -1
- package/LICENSE +0 -21
- package/dist/ArchiveViewer-CLNevfPE.js +0 -124
- package/dist/ArchiveViewer-CLNevfPE.js.map +0 -1
- package/dist/CsvViewer-K8XA3z7N.js +0 -141
- package/dist/CsvViewer-K8XA3z7N.js.map +0 -1
- package/dist/DrawioViewer-BuhZOAx6.js.map +0 -1
- package/dist/EpubViewer-DS3mb3k8.js.map +0 -1
- package/dist/IpynbViewer-BTk7p4Dn.js +0 -184
- package/dist/IpynbViewer-BTk7p4Dn.js.map +0 -1
- package/dist/MermaidViewer-DqucH_io.js +0 -137
- package/dist/MermaidViewer-DqucH_io.js.map +0 -1
- package/dist/PsdViewer-CwiVENmq.js +0 -122
- package/dist/PsdViewer-CwiVENmq.js.map +0 -1
- package/dist/TiffViewer-CpewSVmQ.js +0 -142
- package/dist/TiffViewer-CpewSVmQ.js.map +0 -1
- package/dist/Viewer3D-CIrOn63I.js.map +0 -1
- package/dist/index-Dx0IbBcI.js +0 -26831
- package/dist/index-Dx0IbBcI.js.map +0 -1
- package/dist/useViewerFetch-czqbd2Lj.js +0 -25
- package/dist/useViewerFetch-czqbd2Lj.js.map +0 -1
|
@@ -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
|
+
}
|
package/src/lib/externalReach.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/src/lib/fileFilters.ts
CHANGED
|
@@ -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. */
|
package/src/lib/fileIcons.ts
CHANGED
|
@@ -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
|
+
}
|