@brftech/filex-core 0.42.2 → 0.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (234) hide show
  1. package/README.md +14 -2
  2. package/dist/ArchiveViewer-BqU761Wk.js +149 -0
  3. package/dist/ArchiveViewer-BqU761Wk.js.map +1 -0
  4. package/dist/CsvViewer-ByHVK5ga.js +172 -0
  5. package/dist/CsvViewer-ByHVK5ga.js.map +1 -0
  6. package/dist/{DrawioViewer-CC6rJXeP.js → DrawioViewer-Bgrj8-zG.js} +45 -44
  7. package/dist/DrawioViewer-Bgrj8-zG.js.map +1 -0
  8. package/dist/{EpubViewer-CAoTSXTO.js → EpubViewer-DJwUKlHW.js} +50 -50
  9. package/dist/EpubViewer-DJwUKlHW.js.map +1 -0
  10. package/dist/IpynbViewer-Bz0eJ4NS.js +184 -0
  11. package/dist/IpynbViewer-Bz0eJ4NS.js.map +1 -0
  12. package/dist/MermaidViewer-CEDTD_pp.js +136 -0
  13. package/dist/MermaidViewer-CEDTD_pp.js.map +1 -0
  14. package/dist/PsdViewer-CZcxUpZO.js +121 -0
  15. package/dist/PsdViewer-CZcxUpZO.js.map +1 -0
  16. package/dist/TiffViewer-BwI62CbQ.js +141 -0
  17. package/dist/TiffViewer-BwI62CbQ.js.map +1 -0
  18. package/dist/{Viewer3D-CdZeK4j4.js → Viewer3D-DoeZcuMm.js} +16 -16
  19. package/dist/Viewer3D-DoeZcuMm.js.map +1 -0
  20. package/dist/filex-core.js +475 -219
  21. package/dist/filex-core.umd.cjs +165 -108
  22. package/dist/filex-core.umd.cjs.map +1 -1
  23. package/dist/fonts/caveat-latin-ext.woff2 +0 -0
  24. package/dist/fonts/caveat-latin.woff2 +0 -0
  25. package/dist/fonts/dancing-script-latin-ext.woff2 +0 -0
  26. package/dist/fonts/dancing-script-latin.woff2 +0 -0
  27. package/dist/fonts/homemade-apple-latin.woff2 +0 -0
  28. package/dist/fonts/source-serif-4-latin-ext.woff2 +0 -0
  29. package/dist/fonts/source-serif-4-latin.woff2 +0 -0
  30. package/dist/index-DuWnGuv5.js +35901 -0
  31. package/dist/index-DuWnGuv5.js.map +1 -0
  32. package/dist/index.d.ts +5227 -553
  33. package/dist/pdf-CaPYsOUo.js +21302 -0
  34. package/dist/pdf-CaPYsOUo.js.map +1 -0
  35. package/dist/style.css +1 -1
  36. package/package.json +1 -1
  37. package/src/FileExplorer.vue +1055 -253
  38. package/src/assets/fonts/LICENSE-Apache-2.0.txt +202 -0
  39. package/src/assets/fonts/LICENSE-OFL-1.1.txt +93 -0
  40. package/src/assets/fonts/README.md +58 -0
  41. package/src/assets/fonts/caveat-latin-ext.woff2 +0 -0
  42. package/src/assets/fonts/caveat-latin.woff2 +0 -0
  43. package/src/assets/fonts/dancing-script-latin-ext.woff2 +0 -0
  44. package/src/assets/fonts/dancing-script-latin.woff2 +0 -0
  45. package/src/assets/fonts/homemade-apple-latin.woff2 +0 -0
  46. package/src/assets/fonts/source-serif-4-latin-ext.woff2 +0 -0
  47. package/src/assets/fonts/source-serif-4-latin.woff2 +0 -0
  48. package/src/components/AdvancedSearch.vue +49 -0
  49. package/src/components/Breadcrumb.vue +5 -5
  50. package/src/components/ChoiceButtons.vue +152 -0
  51. package/src/components/CommandPalette.vue +5 -5
  52. package/src/components/ConnectionGuideView.vue +1 -1
  53. package/src/components/ConnectionNotice.vue +59 -0
  54. package/src/components/ConnectionsPanel.vue +30 -568
  55. package/src/components/ContextMenu.vue +31 -5
  56. package/src/components/DataTable.vue +1331 -0
  57. package/src/components/FilePane.vue +3 -0
  58. package/src/components/FilterBar.vue +12 -5
  59. package/src/components/GalleryView.vue +50 -2
  60. package/src/components/GridView.vue +51 -3
  61. package/src/components/HomeView.vue +18 -6
  62. package/src/components/InspectorPanel.vue +134 -17
  63. package/src/components/ListView.vue +366 -900
  64. package/src/components/LogoMark.vue +57 -0
  65. package/src/components/NFSExportsPanel.vue +76 -28
  66. package/src/components/OnboardingTour.vue +19 -7
  67. package/src/components/OperationsCenter.vue +77 -1
  68. package/src/components/PendingOpsTray.vue +59 -5
  69. package/src/components/PresenceBar.vue +7 -7
  70. package/src/components/ProductVersion.vue +31 -0
  71. package/src/components/QuickLook.vue +10 -14
  72. package/src/components/RecentlyOpened.vue +31 -15
  73. package/src/components/RecoveryKeyModal.vue +1 -1
  74. package/src/components/RowActions.vue +134 -0
  75. package/src/components/S3KeysPanel.vue +98 -62
  76. package/src/components/SSHKeysPanel.vue +79 -30
  77. package/src/components/SideNav.vue +260 -96
  78. package/src/components/StarButton.vue +1 -0
  79. package/src/components/StorageFields.vue +74 -6
  80. package/src/components/StorageTags.vue +62 -0
  81. package/src/components/TabBar.vue +1 -1
  82. package/src/components/TagKindIcon.vue +56 -0
  83. package/src/components/TagPicker.vue +226 -40
  84. package/src/components/ThemePalette.vue +4 -4
  85. package/src/components/TimeZonePicker.vue +29 -10
  86. package/src/components/TokensPanel.vue +82 -31
  87. package/src/components/Toolbar.vue +28 -6
  88. package/src/components/plugin/PluginConfirmModal.vue +54 -0
  89. package/src/components/plugin/PluginInspectorSection.vue +149 -0
  90. package/src/components/plugin/PluginPageView.vue +339 -0
  91. package/src/components/plugin/PluginViewModal.vue +159 -0
  92. package/src/components/plugin/SurfaceConversation.vue +76 -0
  93. package/src/components/plugin/SurfaceFooterButtons.vue +73 -0
  94. package/src/components/plugin/SurfaceRenderer.vue +299 -0
  95. package/src/components/plugin/SurfaceSections.vue +91 -0
  96. package/src/components/plugin/nodes/PdfFieldEditor.vue +509 -0
  97. package/src/components/plugin/nodes/SurfaceFileChooser.vue +94 -0
  98. package/src/components/plugin/nodes/SurfaceForm.vue +82 -0
  99. package/src/components/plugin/nodes/SurfaceList.vue +127 -0
  100. package/src/components/plugin/nodes/SurfacePdfFields.vue +1285 -0
  101. package/src/components/plugin/nodes/SurfacePeoplePicker.vue +213 -0
  102. package/src/components/plugin/nodes/SurfacePinInput.vue +57 -0
  103. package/src/components/plugin/nodes/SurfacePreview.vue +119 -0
  104. package/src/components/plugin/nodes/SurfaceProgress.vue +48 -0
  105. package/src/components/plugin/nodes/SurfaceSignaturePad.vue +472 -0
  106. package/src/components/plugin/nodes/SurfaceSteps.vue +53 -0
  107. package/src/components/public/PublicLanguagePicker.vue +58 -0
  108. package/src/components/public/PublicLinkPage.vue +286 -0
  109. package/src/components/public/PublicLinkPreview.vue +85 -0
  110. package/src/components/public/PublicPinGate.vue +108 -0
  111. package/src/components/public/PublicRequestBody.vue +204 -0
  112. package/src/components/public/PublicShareBody.vue +188 -0
  113. package/src/components/public/PublicShell.vue +332 -0
  114. package/src/composables/useConnections.ts +8 -2
  115. package/src/composables/useFileApi.ts +183 -44
  116. package/src/composables/useLocale.ts +277 -41
  117. package/src/composables/useNFSExports.ts +8 -10
  118. package/src/composables/useOperations.ts +44 -3
  119. package/src/composables/usePendingOps.ts +59 -8
  120. package/src/composables/usePluginActions.ts +121 -0
  121. package/src/composables/usePluginSurface.ts +223 -0
  122. package/src/composables/usePublicBranding.ts +172 -0
  123. package/src/composables/usePublicLink.ts +573 -0
  124. package/src/composables/usePublicPage.ts +61 -0
  125. package/src/composables/useS3Keys.ts +17 -12
  126. package/src/composables/useSSHKeys.ts +8 -10
  127. package/src/composables/useTokens.ts +8 -12
  128. package/src/composables/useUploadChunked.ts +18 -8
  129. package/src/composables/useViewerFetch.ts +6 -3
  130. package/src/index.ts +404 -8
  131. package/src/lib/actionIcons.ts +46 -1
  132. package/src/lib/advSearch.ts +14 -7
  133. package/src/lib/appLock.ts +130 -0
  134. package/src/lib/connection.ts +110 -0
  135. package/src/lib/destinationTree.ts +19 -3
  136. package/src/lib/direction.ts +353 -0
  137. package/src/lib/dragGhost.ts +6 -0
  138. package/src/lib/dropLimits.ts +69 -0
  139. package/src/lib/errorWords.ts +394 -0
  140. package/src/lib/externalReach.ts +5 -1
  141. package/src/lib/fileFilters.ts +7 -2
  142. package/src/lib/fileIcons.ts +4 -0
  143. package/src/lib/internalPaths.ts +77 -0
  144. package/src/lib/listInput.ts +64 -0
  145. package/src/lib/listing.ts +58 -23
  146. package/src/lib/modalStack.ts +26 -0
  147. package/src/lib/nodeRow.ts +18 -3
  148. package/src/lib/pdfFieldRules.ts +126 -0
  149. package/src/lib/pdfFields.ts +589 -0
  150. package/src/lib/pdfFieldsGeom.ts +203 -0
  151. package/src/lib/pdfjsLoader.ts +51 -0
  152. package/src/lib/personName.ts +47 -0
  153. package/src/lib/pluginApplies.ts +123 -0
  154. package/src/lib/pluginLabel.ts +72 -0
  155. package/src/lib/pluginMenu.ts +253 -0
  156. package/src/lib/pluginPage.ts +58 -0
  157. package/src/lib/plural.ts +181 -0
  158. package/src/lib/popupLayer.ts +103 -0
  159. package/src/lib/prefs.ts +563 -0
  160. package/src/lib/productVersion.ts +35 -0
  161. package/src/lib/publicLayout.ts +39 -0
  162. package/src/lib/serviceGate.ts +104 -0
  163. package/src/lib/shareTtl.ts +25 -11
  164. package/src/lib/signFonts.ts +85 -0
  165. package/src/lib/signaturePad.ts +111 -0
  166. package/src/lib/sortOrder.ts +100 -38
  167. package/src/lib/star.ts +7 -1
  168. package/src/lib/surfaceCell.ts +41 -0
  169. package/src/lib/surfaceConditions.ts +156 -0
  170. package/src/lib/surfaceOpen.ts +106 -0
  171. package/src/lib/surfaceSteps.ts +68 -0
  172. package/src/lib/surfaceValues.ts +191 -0
  173. package/src/lib/symlink.ts +150 -0
  174. package/src/lib/tableColumns.ts +493 -0
  175. package/src/lib/tableEnv.ts +34 -0
  176. package/src/lib/tablePrefs.ts +84 -0
  177. package/src/lib/tags.ts +121 -12
  178. package/src/lib/themes.ts +286 -18
  179. package/src/lib/tour.ts +152 -0
  180. package/src/lib/trashEmpty.ts +111 -0
  181. package/src/lib/trashTimeLeft.ts +23 -0
  182. package/src/lib/uiLocales.ts +460 -0
  183. package/src/lib/viewPrefs.ts +696 -660
  184. package/src/lib/viewPrefsHttp.ts +78 -0
  185. package/src/locales/en.ts +515 -117
  186. package/src/locales/resolve.ts +17 -8
  187. package/src/locales/tr.ts +536 -152
  188. package/src/modals/ConvertModal.vue +62 -11
  189. package/src/modals/DestinationPickerModal.vue +59 -16
  190. package/src/modals/Modal.vue +113 -23
  191. package/src/modals/NewDocumentModal.vue +16 -6
  192. package/src/modals/NewFolderModal.vue +8 -0
  193. package/src/modals/PermissionsModal.vue +272 -163
  194. package/src/modals/PreviewModal.vue +119 -20
  195. package/src/modals/RenameModal.vue +37 -1
  196. package/src/styles/base.css +2890 -260
  197. package/src/styles/sign-fonts.css +118 -0
  198. package/src/styles/variables.css +60 -0
  199. package/src/types/Connections.ts +17 -0
  200. package/src/types/ExplorerConfig.ts +117 -1
  201. package/src/types/FileNode.ts +50 -0
  202. package/src/types/Plugins.ts +494 -0
  203. package/src/types/Public.ts +212 -0
  204. package/src/viewers/ArchiveViewer.vue +93 -48
  205. package/src/viewers/CsvViewer.vue +99 -53
  206. package/src/viewers/DrawioViewer.vue +35 -9
  207. package/src/viewers/EpubViewer.vue +7 -8
  208. package/src/viewers/IpynbViewer.vue +3 -1
  209. package/src/viewers/MermaidViewer.vue +4 -5
  210. package/src/viewers/PdfViewer.vue +8 -17
  211. package/src/viewers/PsdViewer.vue +2 -4
  212. package/src/viewers/TiffViewer.vue +4 -5
  213. package/src/viewers/Viewer3D.vue +1 -1
  214. package/LICENSE +0 -21
  215. package/dist/ArchiveViewer-BUQWLhwX.js +0 -124
  216. package/dist/ArchiveViewer-BUQWLhwX.js.map +0 -1
  217. package/dist/CsvViewer-Bg2_aqzc.js +0 -141
  218. package/dist/CsvViewer-Bg2_aqzc.js.map +0 -1
  219. package/dist/DrawioViewer-CC6rJXeP.js.map +0 -1
  220. package/dist/EpubViewer-CAoTSXTO.js.map +0 -1
  221. package/dist/IpynbViewer-D0GwGpnE.js +0 -184
  222. package/dist/IpynbViewer-D0GwGpnE.js.map +0 -1
  223. package/dist/MermaidViewer-DGwAvRZg.js +0 -137
  224. package/dist/MermaidViewer-DGwAvRZg.js.map +0 -1
  225. package/dist/PsdViewer-DhGweA0m.js +0 -122
  226. package/dist/PsdViewer-DhGweA0m.js.map +0 -1
  227. package/dist/TiffViewer-D0AurvAh.js +0 -142
  228. package/dist/TiffViewer-D0AurvAh.js.map +0 -1
  229. package/dist/Viewer3D-CdZeK4j4.js.map +0 -1
  230. package/dist/index-BArI-B1M.js +0 -26911
  231. package/dist/index-BArI-B1M.js.map +0 -1
  232. package/dist/useViewerFetch-czqbd2Lj.js +0 -25
  233. package/dist/useViewerFetch-czqbd2Lj.js.map +0 -1
  234. package/src/composables/useScrolledX.ts +0 -21
@@ -0,0 +1,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
+ }