@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.
- 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-CC6rJXeP.js → DrawioViewer-Bgrj8-zG.js} +45 -44
- package/dist/DrawioViewer-Bgrj8-zG.js.map +1 -0
- package/dist/{EpubViewer-CAoTSXTO.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-CdZeK4j4.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 +5227 -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 +1055 -253
- 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 +31 -15
- package/src/components/RecoveryKeyModal.vue +1 -1
- package/src/components/RowActions.vue +134 -0
- package/src/components/S3KeysPanel.vue +98 -62
- package/src/components/SSHKeysPanel.vue +79 -30
- 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 -31
- 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 +18 -3
- 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 +515 -117
- package/src/locales/resolve.ts +17 -8
- package/src/locales/tr.ts +536 -152
- 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 +2890 -260
- 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 +50 -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-BUQWLhwX.js +0 -124
- package/dist/ArchiveViewer-BUQWLhwX.js.map +0 -1
- package/dist/CsvViewer-Bg2_aqzc.js +0 -141
- package/dist/CsvViewer-Bg2_aqzc.js.map +0 -1
- package/dist/DrawioViewer-CC6rJXeP.js.map +0 -1
- package/dist/EpubViewer-CAoTSXTO.js.map +0 -1
- package/dist/IpynbViewer-D0GwGpnE.js +0 -184
- package/dist/IpynbViewer-D0GwGpnE.js.map +0 -1
- package/dist/MermaidViewer-DGwAvRZg.js +0 -137
- package/dist/MermaidViewer-DGwAvRZg.js.map +0 -1
- package/dist/PsdViewer-DhGweA0m.js +0 -122
- package/dist/PsdViewer-DhGweA0m.js.map +0 -1
- package/dist/TiffViewer-D0AurvAh.js +0 -142
- package/dist/TiffViewer-D0AurvAh.js.map +0 -1
- package/dist/Viewer3D-CdZeK4j4.js.map +0 -1
- package/dist/index-BArI-B1M.js +0 -26911
- package/dist/index-BArI-B1M.js.map +0 -1
- package/dist/useViewerFetch-czqbd2Lj.js +0 -25
- package/dist/useViewerFetch-czqbd2Lj.js.map +0 -1
- package/src/composables/useScrolledX.ts +0 -21
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* surfaceConditions — `show_when` / `required_when`, as pure data.
|
|
3
|
+
*
|
|
4
|
+
* v3 §2.1: "a form may not present a contradiction". A field may declare
|
|
5
|
+
* that it exists only while another field holds a particular answer ("the
|
|
6
|
+
* name of the new file" appears only when the output IS a new file, and is
|
|
7
|
+
* then required), and the renderer — not the plugin — is what makes that
|
|
8
|
+
* true. The plugin cannot opt out of it and cannot get it half right.
|
|
9
|
+
*
|
|
10
|
+
* Three promises, all of them enforced here rather than in a component so
|
|
11
|
+
* the modal, the page, the public shell and the tests share one answer:
|
|
12
|
+
*
|
|
13
|
+
* 1. a field whose `show_when` does not hold is NOT drawn;
|
|
14
|
+
* 2. its value is dropped before the event is posted — it cannot arrive
|
|
15
|
+
* at the plugin as a surprise, which is the whole point;
|
|
16
|
+
* 3. a field whose `required_when` holds behaves as `required`, so an
|
|
17
|
+
* empty one blocks the submit instead of failing on the server.
|
|
18
|
+
*
|
|
19
|
+
* ⚠ Conditions CASCADE. A field can be shown by a field that is itself
|
|
20
|
+
* hidden, and a hidden controller answers nothing — so visibility is
|
|
21
|
+
* resolved to a fixed point rather than in one pass. Without it, hiding the
|
|
22
|
+
* middle of a three-field chain leaves the last one on screen asking about
|
|
23
|
+
* an answer nobody can see.
|
|
24
|
+
*
|
|
25
|
+
* ⚠ This is a CONVENIENCE for the person at the screen, not a security
|
|
26
|
+
* boundary — the host re-checks both rules when the job is submitted
|
|
27
|
+
* (v3 §2.1). Same relationship `applies` has with the server's re-check.
|
|
28
|
+
*/
|
|
29
|
+
import type { PluginCondition, PluginField, SurfaceNode } from '../types/Plugins';
|
|
30
|
+
import { walkNodes } from './surfaceValues';
|
|
31
|
+
|
|
32
|
+
/** Is there anything in this value at all? `false` and `0` count as answers. */
|
|
33
|
+
export function hasAnswer(v: unknown): boolean {
|
|
34
|
+
if (v === undefined || v === null) return false;
|
|
35
|
+
if (typeof v === 'string') return v.trim() !== '';
|
|
36
|
+
if (Array.isArray(v)) return v.length > 0;
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Every string a value holds — one for a scalar, several for a multi-select. */
|
|
41
|
+
function valueStrings(v: unknown): string[] {
|
|
42
|
+
if (v === undefined || v === null) return [];
|
|
43
|
+
if (Array.isArray(v)) return v.map((x) => String(x));
|
|
44
|
+
if (typeof v === 'boolean') return [v ? 'true' : 'false'];
|
|
45
|
+
return [String(v)];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Does `cond` hold against the surface's values?
|
|
50
|
+
*
|
|
51
|
+
* ⚠ An absent condition HOLDS. `show_when: null` is "always", which is what
|
|
52
|
+
* every field without one means, so the two paths do not diverge.
|
|
53
|
+
*/
|
|
54
|
+
export function conditionMet(cond: PluginCondition | null | undefined, values: Record<string, unknown>): boolean {
|
|
55
|
+
if (!cond || !cond.key) return true;
|
|
56
|
+
const raw = values?.[cond.key];
|
|
57
|
+
const wanted = cond.equals ?? [];
|
|
58
|
+
// No list of values: the condition is "that field has been answered".
|
|
59
|
+
if (!wanted.length) return hasAnswer(raw);
|
|
60
|
+
if (!hasAnswer(raw)) return false;
|
|
61
|
+
const have = valueStrings(raw);
|
|
62
|
+
return wanted.some((w) => have.includes(String(w)));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The fields of one form that the person may see, conditions cascaded. */
|
|
66
|
+
export function visibleFields(
|
|
67
|
+
fields: PluginField[] | undefined,
|
|
68
|
+
values: Record<string, unknown>,
|
|
69
|
+
): PluginField[] {
|
|
70
|
+
const all = (fields ?? []).filter((f) => f && f.key);
|
|
71
|
+
if (!all.length) return [];
|
|
72
|
+
|
|
73
|
+
// Fixed point: a field hidden in this pass stops answering for the next
|
|
74
|
+
// one, which can hide the field that depended on it, and so on. Bounded
|
|
75
|
+
// by the field count — a cycle simply settles.
|
|
76
|
+
let shown = all;
|
|
77
|
+
for (let pass = 0; pass <= all.length; pass++) {
|
|
78
|
+
const scope = scopedValues(all, shown, values);
|
|
79
|
+
const next = all.filter((f) => conditionMet(f.show_when, scope));
|
|
80
|
+
if (next.length === shown.length) return next;
|
|
81
|
+
shown = next;
|
|
82
|
+
}
|
|
83
|
+
return shown;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The values as the VISIBLE fields answer them — a hidden field answers nothing. */
|
|
87
|
+
function scopedValues(
|
|
88
|
+
all: PluginField[],
|
|
89
|
+
shown: PluginField[],
|
|
90
|
+
values: Record<string, unknown>,
|
|
91
|
+
): Record<string, unknown> {
|
|
92
|
+
const visible = new Set(shown.map((f) => f.key));
|
|
93
|
+
const out: Record<string, unknown> = { ...values };
|
|
94
|
+
for (const f of all) if (!visible.has(f.key)) delete out[f.key];
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Is this field required right now — declared, or by a `required_when` that holds? */
|
|
99
|
+
export function fieldRequired(f: PluginField, values: Record<string, unknown>): boolean {
|
|
100
|
+
if (f.required === true) return true;
|
|
101
|
+
if (!f.required_when) return false;
|
|
102
|
+
return conditionMet(f.required_when, values);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Every `form` node's fields in a surface, flat. */
|
|
106
|
+
export function formFields(nodes: SurfaceNode[] | undefined): PluginField[] {
|
|
107
|
+
const out: PluginField[] = [];
|
|
108
|
+
walkNodes(nodes, (n) => {
|
|
109
|
+
if (n.type !== 'form') return;
|
|
110
|
+
const fields = (n.props as { fields?: PluginField[] } | undefined)?.fields;
|
|
111
|
+
for (const f of fields ?? []) if (f && f.key) out.push(f);
|
|
112
|
+
});
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The keys a surface is currently NOT showing (across every form on it). */
|
|
117
|
+
export function hiddenKeys(nodes: SurfaceNode[] | undefined, values: Record<string, unknown>): string[] {
|
|
118
|
+
const all = formFields(nodes);
|
|
119
|
+
if (!all.length) return [];
|
|
120
|
+
const shown = new Set(visibleFields(all, values).map((f) => f.key));
|
|
121
|
+
return all.filter((f) => !shown.has(f.key)).map((f) => f.key);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The values to POST: everything the surface holds, minus what a hidden
|
|
126
|
+
* field was carrying.
|
|
127
|
+
*
|
|
128
|
+
* ⚠ Dropped, not blanked. `{name: ""}` is an answer ("call it nothing");
|
|
129
|
+
* the contract says the value "is dropped before the job runs", so the key
|
|
130
|
+
* must not be in the map at all.
|
|
131
|
+
*/
|
|
132
|
+
export function stripHiddenValues(
|
|
133
|
+
nodes: SurfaceNode[] | undefined,
|
|
134
|
+
values: Record<string, unknown>,
|
|
135
|
+
): Record<string, unknown> {
|
|
136
|
+
const hidden = hiddenKeys(nodes, values);
|
|
137
|
+
if (!hidden.length) return { ...values };
|
|
138
|
+
const out = { ...values };
|
|
139
|
+
for (const k of hidden) delete out[k];
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The visible-and-required fields a person has not answered. Empty means the
|
|
145
|
+
* submit may go; anything else is what to point at.
|
|
146
|
+
*/
|
|
147
|
+
export function missingRequired(
|
|
148
|
+
nodes: SurfaceNode[] | undefined,
|
|
149
|
+
values: Record<string, unknown>,
|
|
150
|
+
): PluginField[] {
|
|
151
|
+
const all = formFields(nodes);
|
|
152
|
+
if (!all.length) return [];
|
|
153
|
+
const shown = visibleFields(all, values);
|
|
154
|
+
const scope = scopedValues(all, shown, values);
|
|
155
|
+
return shown.filter((f) => fieldRequired(f, scope) && !hasAnswer(scope[f.key]));
|
|
156
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* surfaceOpen — "go to this file, and start that screen on it".
|
|
3
|
+
*
|
|
4
|
+
* ⚠⚠ v3 §3.0. A surface runs on the file it was opened on, which meant an
|
|
5
|
+
* app's HOME screen could list documents and then do nothing when a row was
|
|
6
|
+
* clicked: there was nowhere for a screen to send anybody. `Surface.open`
|
|
7
|
+
* is that missing edge — the answer names a path and, optionally, one of the
|
|
8
|
+
* plugin's own screens, and the client goes there.
|
|
9
|
+
*
|
|
10
|
+
* ⚠ It is NOT a second navigation design. The address is the one a
|
|
11
|
+
* notification deep link already uses (`?select=<path>&app=<plugin>` plus
|
|
12
|
+
* `appAction=` or `appView=`, with the folder in the hash), because the two
|
|
13
|
+
* are the same request — "open this file with that screen on it" — and a
|
|
14
|
+
* second spelling of it would be a second thing to keep working.
|
|
15
|
+
*
|
|
16
|
+
* ⚠⚠ Trusted on arrival, and that is the SERVER's promise, not an omission
|
|
17
|
+
* here: the host checks the screen belongs to the plugin that answered, and
|
|
18
|
+
* the handler re-checks the path against the ASKING person's permissions,
|
|
19
|
+
* dropping the `open` rather than refusing the screen. So a client that
|
|
20
|
+
* re-derived its own permission check would be writing a second, weaker
|
|
21
|
+
* copy of a rule that has already been applied.
|
|
22
|
+
*
|
|
23
|
+
* ⚠ A public page never gets one — there is no explorer behind it — so a
|
|
24
|
+
* frame with no navigator simply ignores it (see `openHrefFor` returning '').
|
|
25
|
+
*/
|
|
26
|
+
import type { SurfaceOpenRequest } from '../types/Plugins';
|
|
27
|
+
import { isPagePlacement, pluginPageUrl } from './pluginPage';
|
|
28
|
+
|
|
29
|
+
/* ⚠ The shape itself lives in `types/Plugins`, with every other wire
|
|
30
|
+
* mirror. Declaring it here as well would be a second copy of a struct that
|
|
31
|
+
* only has one definition in `wire.go`. */
|
|
32
|
+
export type { SurfaceOpenRequest };
|
|
33
|
+
|
|
34
|
+
/** What the answer amounts to, once the frame knows where it is mounted. */
|
|
35
|
+
export interface SurfaceOpenTarget {
|
|
36
|
+
href: string;
|
|
37
|
+
/**
|
|
38
|
+
* A `page` view is a whole screen with the document beside it and opens in
|
|
39
|
+
* its own tab — the same rule a `page` ACTION follows when it is clicked
|
|
40
|
+
* in the menu, so a plugin's screen behaves the same however it was
|
|
41
|
+
* reached.
|
|
42
|
+
*/
|
|
43
|
+
newTab: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Is this a request the client can act on at all? */
|
|
47
|
+
export function isOpenRequest(v: unknown): v is SurfaceOpenRequest {
|
|
48
|
+
if (!v || typeof v !== 'object') return false;
|
|
49
|
+
const o = v as Record<string, unknown>;
|
|
50
|
+
return typeof o.path === 'string' && o.path.trim() !== '';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The folder part of an adapter-qualified path, as the explorer's hash wants
|
|
55
|
+
* it (`storage/dir/sub`), and the file itself for `?select=`.
|
|
56
|
+
*/
|
|
57
|
+
export function openHashFor(qualified: string): string {
|
|
58
|
+
const at = String(qualified ?? '').indexOf('://');
|
|
59
|
+
if (at <= 0) return '';
|
|
60
|
+
const storage = qualified.slice(0, at);
|
|
61
|
+
const rel = qualified.slice(at + 3).replace(/^\/+/, '');
|
|
62
|
+
const cut = rel.lastIndexOf('/');
|
|
63
|
+
const dir = cut > 0 ? rel.slice(0, cut) : '';
|
|
64
|
+
return dir ? `${storage}/${dir}` : storage;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface SurfaceOpenOptions {
|
|
68
|
+
/** The plugin that answered — the screen named is one of ITS own. */
|
|
69
|
+
plugin: string;
|
|
70
|
+
/** The prefix the SPA is served from (`/admin/`, `/drive/`). */
|
|
71
|
+
base?: string | null;
|
|
72
|
+
/**
|
|
73
|
+
* What placement the named view has, when the frame knows. `page` opens in
|
|
74
|
+
* a tab on the view's own address; anything else (and an unknown one) goes
|
|
75
|
+
* through the explorer, which is the frame that can open a dialog.
|
|
76
|
+
*/
|
|
77
|
+
placementOf?: (id: string) => string | undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Where this answer sends the person.
|
|
82
|
+
*
|
|
83
|
+
* `''` when there is nowhere to go: no path, or a frame with no explorer
|
|
84
|
+
* behind it. The caller draws its screen and does nothing, which is what a
|
|
85
|
+
* public page must do.
|
|
86
|
+
*/
|
|
87
|
+
export function openTargetFor(
|
|
88
|
+
req: SurfaceOpenRequest | null | undefined,
|
|
89
|
+
opts: SurfaceOpenOptions,
|
|
90
|
+
): SurfaceOpenTarget {
|
|
91
|
+
if (!isOpenRequest(req) || !opts.plugin) return { href: '', newTab: false };
|
|
92
|
+
const base = String(opts.base ?? '').replace(/\/+$/, '');
|
|
93
|
+
|
|
94
|
+
// A `page` view is its own address, with the document beside it.
|
|
95
|
+
if (req.view && isPagePlacement(opts.placementOf?.(req.view))) {
|
|
96
|
+
return { href: pluginPageUrl(base, { plugin: opts.plugin, view: req.view, path: req.path }), newTab: true };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const q: string[] = [`select=${encodeURIComponent(req.path)}`, `app=${encodeURIComponent(opts.plugin)}`];
|
|
100
|
+
// ⚠ `action` OR `view`, never both — the contract says naming both is not
|
|
101
|
+
// a request, and sending both would leave the explorer to pick one.
|
|
102
|
+
if (req.action) q.push(`appAction=${encodeURIComponent(req.action)}`);
|
|
103
|
+
else if (req.view) q.push(`appView=${encodeURIComponent(req.view)}`);
|
|
104
|
+
const hash = openHashFor(req.path);
|
|
105
|
+
return { href: `${base}/explore?${q.join('&')}${hash ? `#${hash}` : ''}`, newTab: false };
|
|
106
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* surfaceSteps — "one step asks one thing", as pure data.
|
|
3
|
+
*
|
|
4
|
+
* v3 §2 rule 3: a multi-step surface has at most ONE primary button plus
|
|
5
|
+
* Back. The complaint behind it: a wizard step with eight fields and four
|
|
6
|
+
* buttons, where nothing on the screen said which button was the way
|
|
7
|
+
* forward — so the person picked one and found out afterwards.
|
|
8
|
+
*
|
|
9
|
+
* ⚠ Enforced by the RENDERER, not by each plugin. A plugin that marks three
|
|
10
|
+
* of its actions `primary: true` gets one primary (the first one it
|
|
11
|
+
* declared) and two ordinary buttons — it is not refused, and it does not
|
|
12
|
+
* get to draw three ways forward. Nothing is hidden: a button the plugin
|
|
13
|
+
* declared is a button the person can press, because silently dropping one
|
|
14
|
+
* would break a wizard in a way no test of ours would ever see.
|
|
15
|
+
*
|
|
16
|
+
* ⚠ "Back" is recognised by its ACTION ID, which is the only signal on the
|
|
17
|
+
* wire. `back`, `prev`, `previous` and the `step_back` / `go-back` spellings
|
|
18
|
+
* of them, case- and separator-insensitive. A step that calls its own
|
|
19
|
+
* backward action something else simply gets an ordinary button, which is
|
|
20
|
+
* what it would have had anyway.
|
|
21
|
+
*/
|
|
22
|
+
import type { SurfaceAction, SurfaceNode } from '../types/Plugins';
|
|
23
|
+
import { walkNodes } from './surfaceValues';
|
|
24
|
+
|
|
25
|
+
/** Does this surface have a `steps` node — i.e. is it a step of a walk? */
|
|
26
|
+
export function hasSteps(nodes: SurfaceNode[] | undefined): boolean {
|
|
27
|
+
let found = false;
|
|
28
|
+
walkNodes(nodes, (n) => {
|
|
29
|
+
if (n.type === 'steps') found = true;
|
|
30
|
+
});
|
|
31
|
+
return found;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const BACK_IDS = new Set(['back', 'prev', 'previous', 'stepback', 'goback', 'prevstep', 'previousstep']);
|
|
35
|
+
|
|
36
|
+
/** Is this the step's backward action? */
|
|
37
|
+
export function isBackAction(a: Pick<SurfaceAction, 'id'>): boolean {
|
|
38
|
+
const id = String(a?.id ?? '')
|
|
39
|
+
.toLowerCase()
|
|
40
|
+
.replace(/[^a-z]/g, '');
|
|
41
|
+
return BACK_IDS.has(id);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A footer, arranged: Back on the left, the ordinary buttons, one primary last. */
|
|
45
|
+
export interface StepFooter {
|
|
46
|
+
back: SurfaceAction | null;
|
|
47
|
+
/** In declaration order, minus Back and minus the one primary. */
|
|
48
|
+
others: SurfaceAction[];
|
|
49
|
+
primary: SurfaceAction | null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Arrange a surface's actions for a step.
|
|
54
|
+
*
|
|
55
|
+
* `stepped` is what `hasSteps` said. On a surface that is NOT a step the
|
|
56
|
+
* plugin's own arrangement is left alone — a dialog with two equally weighted
|
|
57
|
+
* actions is a legitimate screen, and the rule is about walks.
|
|
58
|
+
*/
|
|
59
|
+
export function stepFooter(actions: SurfaceAction[] | undefined, stepped: boolean): StepFooter {
|
|
60
|
+
const list = (actions ?? []).filter((a) => a && a.id);
|
|
61
|
+
if (!stepped) {
|
|
62
|
+
return { back: null, others: list, primary: null };
|
|
63
|
+
}
|
|
64
|
+
const back = list.find(isBackAction) ?? null;
|
|
65
|
+
const rest = list.filter((a) => a !== back);
|
|
66
|
+
const primary = rest.find((a) => a.primary === true) ?? null;
|
|
67
|
+
return { back, others: rest.filter((a) => a !== primary), primary };
|
|
68
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* surfaceValues — what a surface's nodes hold, as pure data.
|
|
3
|
+
*
|
|
4
|
+
* A plugin view is a conversation: every event the browser posts carries
|
|
5
|
+
* `data.values`, one entry per node that holds a value (contract: "a node
|
|
6
|
+
* with `id` that holds a value contributes `data.values[id]`"; a `form`'s
|
|
7
|
+
* fields are FLAT, keyed by field key, because that is what the plugin
|
|
8
|
+
* declared them as). The modal and the inspector section both post events,
|
|
9
|
+
* so the walk that seeds the map from a fresh surface lives here, once, and
|
|
10
|
+
* the two cannot seed differently.
|
|
11
|
+
*
|
|
12
|
+
* DOM-free on purpose: tests exercise the seeding and the field mapping
|
|
13
|
+
* without mounting anything.
|
|
14
|
+
*/
|
|
15
|
+
import type { StorageField } from '../types/Connections';
|
|
16
|
+
import type {
|
|
17
|
+
FileChooserNodeProps,
|
|
18
|
+
FormNodeProps,
|
|
19
|
+
PdfFieldsNodeProps,
|
|
20
|
+
PeoplePickerNodeProps,
|
|
21
|
+
PluginField,
|
|
22
|
+
SurfaceNode,
|
|
23
|
+
} from '../types/Plugins';
|
|
24
|
+
import { fillValues, normalizeFields } from './pdfFields';
|
|
25
|
+
import { labelOf } from './pluginLabel';
|
|
26
|
+
|
|
27
|
+
/** Every `SurfaceNode` in the tree, depth first (rows are transparent). */
|
|
28
|
+
export function walkNodes(nodes: SurfaceNode[] | undefined, visit: (n: SurfaceNode) => void): void {
|
|
29
|
+
for (const n of nodes ?? []) {
|
|
30
|
+
visit(n);
|
|
31
|
+
if (n.children?.length) walkNodes(n.children, visit);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The values a surface arrives with: `form.props.values` (flat, by field key,
|
|
37
|
+
* else each field's `default`), `people-picker.props.value`,
|
|
38
|
+
* `file-chooser.props.value`, a `pdf-fields` node's fields and an empty
|
|
39
|
+
* `signature-pad` under the node's id. Nodes without an id hold nothing the
|
|
40
|
+
* server can address, so they contribute nothing.
|
|
41
|
+
*/
|
|
42
|
+
export function initialValues(nodes: SurfaceNode[] | undefined): Record<string, unknown> {
|
|
43
|
+
const out: Record<string, unknown> = {};
|
|
44
|
+
walkNodes(nodes, (n) => {
|
|
45
|
+
const p = (n.props ?? {}) as Record<string, unknown>;
|
|
46
|
+
if (n.type === 'form') {
|
|
47
|
+
const fp = p as unknown as FormNodeProps;
|
|
48
|
+
for (const f of fp.fields ?? []) {
|
|
49
|
+
if (!f?.key) continue;
|
|
50
|
+
const given = fp.values?.[f.key];
|
|
51
|
+
if (given !== undefined) out[f.key] = multiSafe(f, given);
|
|
52
|
+
else if (f.default !== undefined) out[f.key] = multiSafe(f, f.default);
|
|
53
|
+
else if (f.multi === true && f.type === 'select') out[f.key] = [];
|
|
54
|
+
}
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (!n.id) return;
|
|
58
|
+
if (n.type === 'people-picker') {
|
|
59
|
+
const v = (p as PeoplePickerNodeProps).value;
|
|
60
|
+
out[n.id] = Array.isArray(v) ? v.filter((x) => x && typeof x.email === 'string') : [];
|
|
61
|
+
} else if (n.type === 'file-chooser') {
|
|
62
|
+
const v = (p as FileChooserNodeProps).value;
|
|
63
|
+
out[n.id] = typeof v === 'string' ? v : '';
|
|
64
|
+
} else if (n.type === 'pin-input') {
|
|
65
|
+
out[n.id] = '';
|
|
66
|
+
} else if (n.type === 'signature-pad') {
|
|
67
|
+
out[n.id] = null;
|
|
68
|
+
} else if (n.type === 'pdf-fields') {
|
|
69
|
+
// Edit mode sends the whole array back; fill mode only the signer's
|
|
70
|
+
// own filled fields (lib/pdfFields.fillValues) — seeded from what the
|
|
71
|
+
// surface already carries, so a re-opened screen keeps its values.
|
|
72
|
+
const pp = p as unknown as PdfFieldsNodeProps;
|
|
73
|
+
const fields = normalizeFields(pp.fields);
|
|
74
|
+
out[n.id] = pp.mode === 'fill' ? fillValues(fields, pp.signer) : fields;
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* A `multi` select always holds a LIST, whatever the plugin seeded it with.
|
|
82
|
+
*
|
|
83
|
+
* ⚠ A plugin that declares `multi: true` and sends `"pdf"` as the default is
|
|
84
|
+
* not wrong on the wire — the contract says the value is "a string (single)
|
|
85
|
+
* or a list of strings (multi)" — but a control that receives a string and
|
|
86
|
+
* emits an array would send a different SHAPE back than it was given, and
|
|
87
|
+
* the plugin would have to handle both. Normalised once, on the way in.
|
|
88
|
+
*/
|
|
89
|
+
function multiSafe(f: PluginField, v: unknown): unknown {
|
|
90
|
+
if (f.multi !== true || f.type !== 'select') return v;
|
|
91
|
+
if (Array.isArray(v)) return v.map((x) => String(x));
|
|
92
|
+
return v === undefined || v === null || v === '' ? [] : [String(v)];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const FIELD_TYPES = new Set(['string', 'int', 'bool', 'password', 'select']);
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Is this `bool` a DECISION the person must read, or an on/off switch?
|
|
99
|
+
*
|
|
100
|
+
* The wire HAS a word for it now — `wire.Field.Style` (`switch` | `choice`)
|
|
101
|
+
* — so the plugin's own answer comes first and the guess only runs when the
|
|
102
|
+
* plugin said nothing:
|
|
103
|
+
*
|
|
104
|
+
* - `style: "choice"` → two buttons, `style: "switch"` → the toggle. Said
|
|
105
|
+
* outright, it beats every heuristic below, including `options`: a
|
|
106
|
+
* plugin that names its two labels and still wants a switch gets one.
|
|
107
|
+
* - the plugin NAMED the two answers (`options`), which nobody does for a
|
|
108
|
+
* switch: "Keep the original / Replace it" is a decision;
|
|
109
|
+
* - the field is `required`, i.e. the form will not go until it is
|
|
110
|
+
* answered — and an unticked checkbox is indistinguishable from a
|
|
111
|
+
* question nobody has reached yet.
|
|
112
|
+
*
|
|
113
|
+
* Everything else stays the checkbox a settings toggle wants. ⚠ That last
|
|
114
|
+
* line is a GUESS, and a wrong one for a question like "should this
|
|
115
|
+
* installation add time stamps?" — which is why the contract asks a plugin
|
|
116
|
+
* to say `style` when the answer matters, rather than this file getting
|
|
117
|
+
* cleverer about bools it was told nothing about.
|
|
118
|
+
*
|
|
119
|
+
* ⚠ `select` never asks: it is buttons whatever `style` says (`storageFieldOf`
|
|
120
|
+
* below), because a surface has no dropdown to fall back to.
|
|
121
|
+
*/
|
|
122
|
+
function boolIsDecision(f: PluginField): boolean {
|
|
123
|
+
const style = String(f.style ?? '').toLowerCase();
|
|
124
|
+
if (style === 'choice') return true;
|
|
125
|
+
if (style === 'switch') return false;
|
|
126
|
+
if ((f.options ?? []).length > 0) return true;
|
|
127
|
+
return f.required === true;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* A plugin's field as the shared field renderer (`components/StorageFields`)
|
|
132
|
+
* reads it. `i18n_key` is empty on purpose: the renderer asks the catalogue
|
|
133
|
+
* first and falls back to `label` when the key is unknown, and `''` is
|
|
134
|
+
* unknown — so a plugin's own words (already read in the viewer's language)
|
|
135
|
+
* are what show. `secret` fields are drawn masked whatever their type says.
|
|
136
|
+
*
|
|
137
|
+
* ⚠⚠ v3: `choice` is set HERE, for every plugin `select`, and `advanced` is
|
|
138
|
+
* not carried at all. Those two lines are the whole of "no dropdowns and no
|
|
139
|
+
* hidden sections in a surface" — applied to the declaration on its way in,
|
|
140
|
+
* so the renderer stays one renderer and no plugin can opt out of the rule
|
|
141
|
+
* by drawing its own control.
|
|
142
|
+
*
|
|
143
|
+
* ⚠⚠ This is the ONLY mapper. Every screen that turns a plugin's `Field`
|
|
144
|
+
* into something drawable goes through it: the surface form
|
|
145
|
+
* (`components/plugin/nodes/SurfaceForm.vue`) AND the admin's own app
|
|
146
|
+
* settings screen (web `components/plugins/AppPluginDetail.vue`). There used
|
|
147
|
+
* to be a second one in the admin (`api/appPlugins.fieldToStorageField`); it
|
|
148
|
+
* never set `choice`, dropped `multi` and carried an `advanced` the contract
|
|
149
|
+
* had already deleted, so one manifest field meant two different things on
|
|
150
|
+
* two screens. A new surface adds a CALLER here, never a mapper of its own.
|
|
151
|
+
*/
|
|
152
|
+
export function storageFieldOf(f: PluginField, locale: string): StorageField {
|
|
153
|
+
const type = f.secret ? 'password' : FIELD_TYPES.has(String(f.type)) ? f.type : 'string';
|
|
154
|
+
return {
|
|
155
|
+
key: f.key,
|
|
156
|
+
type: type as StorageField['type'],
|
|
157
|
+
label: labelOf(f.label, locale) || f.key,
|
|
158
|
+
help: labelOf(f.help, locale) || undefined,
|
|
159
|
+
i18n_key: '',
|
|
160
|
+
required: f.required === true,
|
|
161
|
+
secret: f.secret === true,
|
|
162
|
+
default: f.default,
|
|
163
|
+
placeholder: labelOf(f.placeholder, locale) || undefined,
|
|
164
|
+
options: (f.options ?? []).map((o) => ({ value: String(o.value), label: labelOf(o.label, locale) || String(o.value) })),
|
|
165
|
+
min: f.min,
|
|
166
|
+
max: f.max,
|
|
167
|
+
monospace: f.monospace === true,
|
|
168
|
+
// ⚠⚠ `text` IS the contract's long text (wire.Field: "string | password
|
|
169
|
+
// | int | bool | select | text"), and the field renderer draws a textarea
|
|
170
|
+
// only on `multiline`. Nothing mapped the two, so every long field a
|
|
171
|
+
// plugin asked for arrived as a one-line box — including the signing
|
|
172
|
+
// app's list of signers, under a help line that says "one per line".
|
|
173
|
+
multiline: f.multiline === true || String(f.type) === 'text',
|
|
174
|
+
choice: type === 'select' || (type === 'bool' && boolIsDecision(f)),
|
|
175
|
+
multi: f.multi === true,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** A `pin-input`'s length: 4..8, default 6 — the contract's bounds. */
|
|
180
|
+
export function pinLength(raw: unknown): number {
|
|
181
|
+
const n = Number(raw);
|
|
182
|
+
if (!Number.isFinite(n)) return 6;
|
|
183
|
+
return Math.min(8, Math.max(4, Math.trunc(n)));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Good enough for "this looks like an address" — the server validates. */
|
|
187
|
+
export function looksLikeEmail(s: string): boolean {
|
|
188
|
+
const v = s.trim();
|
|
189
|
+
const at = v.indexOf('@');
|
|
190
|
+
return at > 0 && at < v.length - 1 && !/\s/.test(v) && v.indexOf('@', at + 1) === -1;
|
|
191
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* symlink — a link the server will NOT follow, said in words.
|
|
3
|
+
*
|
|
4
|
+
* Issue #34: a symlink to a directory inside a `local` storage root listed as
|
|
5
|
+
* a 0-byte file that would not open. The backend half of the answer landed
|
|
6
|
+
* first: a link whose target is INSIDE the root is now followed, so it comes
|
|
7
|
+
* back as the directory it is and opens normally — nothing here is involved in
|
|
8
|
+
* that case, and nothing here should be. What is left is the other half, which
|
|
9
|
+
* is the half the reporter actually complained about: a link the server
|
|
10
|
+
* refuses to follow still arrives looking like an ordinary file, and clicking
|
|
11
|
+
* it does nothing anybody can explain.
|
|
12
|
+
*
|
|
13
|
+
* ⚠⚠ THE WIRE, AS MEASURED — read this before changing anything below.
|
|
14
|
+
* The driver decides a link's fate in `local.describeLink` and records it as
|
|
15
|
+
* `storage.MetaLinkState` (`followed` / `outside_root` / `broken` /
|
|
16
|
+
* `unresolved`). What reaches the browser is LESS than that, and it is less in
|
|
17
|
+
* two different ways:
|
|
18
|
+
*
|
|
19
|
+
* 1. `followed` never arrives. `handlers/manager.go` attaches the keys only
|
|
20
|
+
* when `o.Kind == storage.KindSymlink`, and a followed link has already
|
|
21
|
+
* become `KindDirectory`/`KindFile` by then. That is correct — a followed
|
|
22
|
+
* link IS its target and needs no explaining — so `symlink: true` on the
|
|
23
|
+
* wire always means "this one will not open".
|
|
24
|
+
* 2. `link_state` is the EXCEPTION, not the rule. Two projectors build
|
|
25
|
+
* listing rows:
|
|
26
|
+
* • `projectFileNodes` (manager.go:1412) — the DB cache, i.e. the
|
|
27
|
+
* normal, steady-state listing. It emits `symlink: true` and NOTHING
|
|
28
|
+
* ELSE, because `model.Node` has no column for the state: the reason
|
|
29
|
+
* is a property of the link right now, and the catalogue stores what
|
|
30
|
+
* was seen at scan time.
|
|
31
|
+
* • `projectDriverObjects` (manager.go:826) — the cold-cache / pre-sync
|
|
32
|
+
* fallback, which reads the driver directly and therefore DOES carry
|
|
33
|
+
* `link_state`.
|
|
34
|
+
* So `symlink: true` with no `link_state` is the COMMON case, not an edge
|
|
35
|
+
* one, and `'unknown'` below is a first-class state with wording of its
|
|
36
|
+
* own. A design that only handled the three named states would leave the
|
|
37
|
+
* reporter's own screen — a warm cache — exactly as broken as before.
|
|
38
|
+
*
|
|
39
|
+
* `link_target` (the resolved path a followed directory link carries for the
|
|
40
|
+
* walk's cycle guard) is never serialised at all. It is a server-side identity
|
|
41
|
+
* and no surface should grow a dependency on it.
|
|
42
|
+
*
|
|
43
|
+
* ⚠ The wire `type` stays inside the closed `'file' | 'dir'` union: widening
|
|
44
|
+
* it would be a breaking change for every embedder of `@brftech/filex`, so an
|
|
45
|
+
* unfollowable link is reported as a file and FLAGGED. Everything here reads
|
|
46
|
+
* the flag; nothing here looks at `type`.
|
|
47
|
+
*
|
|
48
|
+
* ⚠ ONE reader, for the same reason `lib/appLock` is one reader: the badge on
|
|
49
|
+
* the row, the line in the details panel and the toast after a refused open
|
|
50
|
+
* all take their words from here. A second phrasing is how a badge saying
|
|
51
|
+
* "Outside" ends up beside a toast saying "Error".
|
|
52
|
+
*/
|
|
53
|
+
import type { FileNode } from '../types/FileNode';
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Why a link will not open.
|
|
57
|
+
*
|
|
58
|
+
* The first three are `storage.LinkOutsideRoot` / `LinkBroken` /
|
|
59
|
+
* `LinkUnresolved`. `unknown` is ours: the row says it is a link and the
|
|
60
|
+
* server did not say why — the warm-cache listing (see the note above), or a
|
|
61
|
+
* server newer than this client that named a state we have never heard of.
|
|
62
|
+
* Both deserve the honest general sentence rather than silence.
|
|
63
|
+
*/
|
|
64
|
+
export type LinkState = 'outside_root' | 'broken' | 'unresolved' | 'unknown';
|
|
65
|
+
|
|
66
|
+
const NAMED: readonly string[] = ['outside_root', 'broken', 'unresolved'];
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The state of a row the server will not follow, or `null` for every ordinary
|
|
70
|
+
* row — including a link it DID follow, which arrives as its target and is not
|
|
71
|
+
* a link as far as anyone using it is concerned.
|
|
72
|
+
*/
|
|
73
|
+
export function linkStateOf(node: Pick<FileNode, 'symlink' | 'link_state'> | null | undefined): LinkState | null {
|
|
74
|
+
if (!node || node.symlink !== true) return null;
|
|
75
|
+
const s = node.link_state;
|
|
76
|
+
return typeof s === 'string' && NAMED.includes(s) ? (s as LinkState) : 'unknown';
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** What `linkWords` needs from the caller's locale. */
|
|
80
|
+
export interface LinkWordsHost {
|
|
81
|
+
t: (key: string, vars?: Record<string, string | number>) => string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The short word on the row, and the sentence behind it. */
|
|
85
|
+
export interface LinkWords {
|
|
86
|
+
state: LinkState;
|
|
87
|
+
/** The badge's visible text — two words at most; the row is narrow. */
|
|
88
|
+
badge: string;
|
|
89
|
+
/**
|
|
90
|
+
* What it is, why it will not open, and who can change that — in a sentence,
|
|
91
|
+
* because "Outside" on its own is a second riddle rather than an answer.
|
|
92
|
+
*/
|
|
93
|
+
why: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The words for a state.
|
|
98
|
+
*
|
|
99
|
+
* ⚠ BROKEN IS NOT OUTSIDE-OF-ROOT and must never read as if it were. Telling
|
|
100
|
+
* somebody an administrator can allow a link whose target has been deleted
|
|
101
|
+
* sends them to a settings screen that will not help, and leaves the real
|
|
102
|
+
* repair — fix or remove the link on the server — unsaid.
|
|
103
|
+
*
|
|
104
|
+
* ⚠ Literal `t('…')` keys on purpose: `web/tests/i18n/coreKeysUsed.test.ts`
|
|
105
|
+
* scans the source for them and skips anything computed, so a table built with
|
|
106
|
+
* `t('symlink.badge.' + state)` would take these eight strings outside the only
|
|
107
|
+
* check that notices a key nobody ever added to the catalogues.
|
|
108
|
+
*/
|
|
109
|
+
export function linkWords(state: LinkState | null, host: LinkWordsHost): LinkWords | null {
|
|
110
|
+
switch (state) {
|
|
111
|
+
case 'outside_root':
|
|
112
|
+
return { state, badge: host.t('symlink.badge.outside_root'), why: host.t('symlink.why.outside_root') };
|
|
113
|
+
case 'broken':
|
|
114
|
+
return { state, badge: host.t('symlink.badge.broken'), why: host.t('symlink.why.broken') };
|
|
115
|
+
case 'unresolved':
|
|
116
|
+
return { state, badge: host.t('symlink.badge.unresolved'), why: host.t('symlink.why.unresolved') };
|
|
117
|
+
case 'unknown':
|
|
118
|
+
return { state, badge: host.t('symlink.badge.unknown'), why: host.t('symlink.why.unknown') };
|
|
119
|
+
default:
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The one call a view makes: the words for this row, or `null`. */
|
|
125
|
+
export function linkWordsFor(
|
|
126
|
+
node: Pick<FileNode, 'symlink' | 'link_state'> | null | undefined,
|
|
127
|
+
host: LinkWordsHost,
|
|
128
|
+
): LinkWords | null {
|
|
129
|
+
return linkWords(linkStateOf(node), host);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Is this row one no surface may try to open?
|
|
134
|
+
*
|
|
135
|
+
* ⚠⚠ THE DECISION, WRITTEN DOWN. An unfollowable link is refused by the
|
|
136
|
+
* CLIENT, before any request, and the refusal is spoken (a toast carrying
|
|
137
|
+
* `why`). The two alternatives were both live bugs:
|
|
138
|
+
*
|
|
139
|
+
* • doing nothing is issue #34 itself — the row that "just does not open";
|
|
140
|
+
* • letting the request go is barely better, because the driver answers with
|
|
141
|
+
* a containment error that surfaces as a generic failure and reads like a
|
|
142
|
+
* server fault rather than a deliberate, configurable boundary.
|
|
143
|
+
*
|
|
144
|
+
* Refusing here costs nothing the person could have had: the server would
|
|
145
|
+
* refuse it too, and only this side knows the sentence that says why and names
|
|
146
|
+
* the setting that changes it.
|
|
147
|
+
*/
|
|
148
|
+
export function isUnopenableLink(node: Pick<FileNode, 'symlink' | 'link_state'> | null | undefined): boolean {
|
|
149
|
+
return linkStateOf(node) !== null;
|
|
150
|
+
}
|