@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
@@ -17,10 +17,23 @@ import { computed, ref, onBeforeUnmount } from 'vue';
17
17
  import type { ExplorerConfig } from '../types/ExplorerConfig';
18
18
  import type { FileApi } from './useFileApi';
19
19
 
20
+ /**
21
+ * The queue kinds this package knows by name. A server may emit others (an
22
+ * app-plugin job is `plugin-action` on the wire and `plugin` here); anything
23
+ * unknown is carried through verbatim rather than being read as a delete.
24
+ */
25
+ export type PendingOpType = 'copy' | 'move' | 'delete' | 'plugin' | 'trash-empty' | (string & {});
26
+
27
+ /** One produced file of a finished plugin job, as an adapter-qualified path. */
28
+ export interface PendingOpOutput {
29
+ path: string;
30
+ }
31
+
20
32
  export interface PendingOp {
21
33
  id: number;
22
- op_type: 'copy' | 'move' | 'delete';
23
- status: 'pending' | 'running' | 'done' | 'error';
34
+ op_type: PendingOpType;
35
+ /** `cancelled` — somebody stopped it (POST /ops/{id}/cancel). */
36
+ status: 'pending' | 'running' | 'done' | 'error' | 'cancelled';
24
37
  progress_total: number;
25
38
  progress_done: number;
26
39
  /** Running cross-storage transfer's bytes (issue #27); absent otherwise. */
@@ -30,9 +43,24 @@ export interface PendingOp {
30
43
  source_dir: string | null;
31
44
  source_count: number;
32
45
  error_message: string | null;
46
+ /** A failed APP job, classified by the server (lib/errorWords `jobFailure`
47
+ * says it); absent on copy/move/delete rows and on an older server. */
48
+ error_code?: string;
49
+ /** The engine a job needed and the server lacks (`error_code: engine_missing`). */
50
+ error_engine?: string;
33
51
  started_at: string | null;
34
52
  finished_at: string | null;
35
53
  created_at: string | null;
54
+ /* App-plugin jobs (`op_type: "plugin-action"` on the wire) — absent on
55
+ * copy/move/delete rows. See docs/APP-PLUGINS-API.md → Ops rows. */
56
+ plugin?: string;
57
+ action?: string;
58
+ /** The action's label in the caller's locale — the tray's row title. */
59
+ label?: string;
60
+ /** The last `job_progress` message. */
61
+ message?: string;
62
+ /** Files the job committed, once finished. */
63
+ outputs?: PendingOpOutput[];
36
64
  }
37
65
 
38
66
  const POLL_MS = 2000;
@@ -46,7 +74,7 @@ const RETAIN_MS = 8000;
46
74
  * running op (progress_* were undefined) and never treated an 'ok'/'failed'
47
75
  * row as terminal. Tolerant of an already-normalized shape (idempotent).
48
76
  */
49
- function normalizeOp(raw: Record<string, unknown>): PendingOp {
77
+ export function normalizeOp(raw: Record<string, unknown>): PendingOp {
50
78
  const num = (...vals: unknown[]): number => {
51
79
  for (const v of vals) if (typeof v === 'number') return v;
52
80
  return 0;
@@ -56,19 +84,35 @@ function normalizeOp(raw: Record<string, unknown>): PendingOp {
56
84
  return null;
57
85
  };
58
86
  const rawStatus = String(raw.status ?? 'pending');
87
+ // ⚠ `cancelled` is an ending of its own. It used to fall through to
88
+ // 'pending', so a job somebody cancelled sat in the operations centre as
89
+ // "Queued" for as long as the list still carried the row.
59
90
  const status: PendingOp['status'] =
60
91
  rawStatus === 'ok' || rawStatus === 'done'
61
92
  ? 'done'
62
93
  : rawStatus === 'failed' || rawStatus === 'partial' || rawStatus === 'error'
63
94
  ? 'error'
64
- : rawStatus === 'running'
65
- ? 'running'
66
- : 'pending';
95
+ : rawStatus === 'cancelled'
96
+ ? 'cancelled'
97
+ : rawStatus === 'running'
98
+ ? 'running'
99
+ : 'pending';
67
100
  const sources = Array.isArray(raw.sources) ? raw.sources : [];
101
+ // ⚠ Unknown kinds pass through. This used to fold everything that was not
102
+ // copy/move into `delete`, which drew a bin beside a plugin job and read
103
+ // "Deleted (1)" when it finished. `plugin-action` (the wire name) becomes
104
+ // `plugin`; a row with no kind at all is the one case still read as delete
105
+ // (the legacy delete endpoint's rows carried none).
68
106
  const opType = str(raw.op_type, raw.kind) ?? 'delete';
107
+ const outputs = Array.isArray(raw.outputs)
108
+ ? (raw.outputs as unknown[])
109
+ .map((o) => (o && typeof o === 'object' ? str((o as { path?: unknown }).path) : null))
110
+ .filter((p): p is string => p !== null)
111
+ .map((path) => ({ path }))
112
+ : undefined;
69
113
  return {
70
114
  id: num(raw.id),
71
- op_type: (opType === 'copy' || opType === 'move' ? opType : 'delete') as PendingOp['op_type'],
115
+ op_type: opType === 'plugin-action' ? 'plugin' : opType,
72
116
  status,
73
117
  progress_total: num(raw.progress_total, raw.total, sources.length),
74
118
  progress_done: num(raw.progress_done, raw.done),
@@ -78,9 +122,16 @@ function normalizeOp(raw: Record<string, unknown>): PendingOp {
78
122
  source_dir: str(raw.source_dir),
79
123
  source_count: num(raw.source_count, sources.length),
80
124
  error_message: str(raw.error_message, raw.error),
125
+ error_code: str(raw.error_code) ?? undefined,
126
+ error_engine: str(raw.error_engine) ?? undefined,
81
127
  started_at: str(raw.started_at),
82
128
  finished_at: str(raw.finished_at),
83
129
  created_at: str(raw.created_at),
130
+ plugin: str(raw.plugin) ?? undefined,
131
+ action: str(raw.action) ?? undefined,
132
+ label: str(raw.label) ?? undefined,
133
+ message: str(raw.message) ?? undefined,
134
+ outputs,
84
135
  };
85
136
  }
86
137
 
@@ -133,7 +184,7 @@ export function usePendingOps(
133
184
  const incoming = (res.ops || []).map(normalizeOp);
134
185
 
135
186
  for (const op of incoming) {
136
- if ((op.status === 'done' || op.status === 'error') && !announced.has(op.id)) {
187
+ if ((op.status === 'done' || op.status === 'error' || op.status === 'cancelled') && !announced.has(op.id)) {
137
188
  announced.add(op.id);
138
189
  settledAt.set(op.id, Date.now());
139
190
  if (firstPollDone) {
@@ -0,0 +1,121 @@
1
+ /**
2
+ * usePluginActions — the file-menu rows app plugins add, and how to run one.
3
+ *
4
+ * `GET /api/files/plugins/actions` is fetched once per endpoint URL and
5
+ * shared by every explorer instance on the page (the admin explorer, a split
6
+ * pane, an embedded web component all ask the same server), and re-asked
7
+ * after five minutes — the contract's client cache. Nothing is requested
8
+ * until `enabled()` says so: with `capabilities.app_plugins.enabled` false the
9
+ * explorer makes zero plugin calls.
10
+ *
11
+ * The composable holds no menu logic of its own: which rows a selection gets
12
+ * is `lib/pluginMenu`, the rule is `lib/pluginApplies`, both pure.
13
+ */
14
+ import { computed, ref, watch, type Ref } from 'vue';
15
+ import type { FileApi } from './useFileApi';
16
+ import type { FileNode } from '../types/FileNode';
17
+ import type { PluginActionRow, PluginActionsResponse, PluginRunResult, PluginViewRow } from '../types/Plugins';
18
+ import { pluginActionsFor, pluginActionKey } from '../lib/pluginMenu';
19
+
20
+ export const PLUGIN_ACTIONS_TTL_MS = 5 * 60 * 1000;
21
+
22
+ interface CacheEntry {
23
+ at: number;
24
+ data: Ref<PluginActionsResponse>;
25
+ inflight: Promise<PluginActionsResponse> | null;
26
+ }
27
+
28
+ /** One entry per actions URL, shared across every instance on the page. */
29
+ const cache = new Map<string, CacheEntry>();
30
+
31
+ /** Forget every cached answer — tests, and a host that just installed a plugin. */
32
+ export function invalidatePluginActions(): void {
33
+ cache.clear();
34
+ }
35
+
36
+ function entryFor(url: string): CacheEntry {
37
+ let e = cache.get(url);
38
+ if (!e) {
39
+ e = { at: 0, data: ref<PluginActionsResponse>({ actions: [], views: [] }), inflight: null };
40
+ cache.set(url, e);
41
+ }
42
+ return e;
43
+ }
44
+
45
+ export function usePluginActions(api: FileApi, enabled: () => boolean) {
46
+ const url = api.endpoints.pluginActions ?? '';
47
+ const entry = entryFor(url || '(none)');
48
+ const loaded = ref(false);
49
+ const error = ref<string | null>(null);
50
+
51
+ const isEnabled = computed(() => enabled() && url !== '');
52
+
53
+ const actions = computed<PluginActionRow[]>(() => (isEnabled.value ? entry.data.value.actions : []));
54
+ const views = computed<PluginViewRow[]>(() => (isEnabled.value ? entry.data.value.views : []));
55
+
56
+ /** Fetch (or reuse the shared answer) — a no-op while the feature is off. */
57
+ async function refresh(force = false): Promise<PluginActionsResponse> {
58
+ if (!isEnabled.value) return { actions: [], views: [] };
59
+ const fresh = entry.at > 0 && Date.now() - entry.at < PLUGIN_ACTIONS_TTL_MS;
60
+ if (fresh && !force) {
61
+ loaded.value = true;
62
+ return entry.data.value;
63
+ }
64
+ if (entry.inflight) return entry.inflight;
65
+ entry.inflight = api
66
+ .pluginActions()
67
+ .then((res) => {
68
+ entry.data.value = { actions: res.actions ?? [], views: res.views ?? [] };
69
+ entry.at = Date.now();
70
+ error.value = null;
71
+ return entry.data.value;
72
+ })
73
+ .catch((e: unknown) => {
74
+ error.value = String((e as Error)?.message ?? e);
75
+ return entry.data.value;
76
+ })
77
+ .finally(() => {
78
+ entry.inflight = null;
79
+ loaded.value = true;
80
+ });
81
+ return entry.inflight;
82
+ }
83
+
84
+ // The first time the feature turns on (capabilities answered), ask once.
85
+ watch(
86
+ isEnabled,
87
+ (on) => {
88
+ if (on) void refresh();
89
+ },
90
+ { immediate: true },
91
+ );
92
+
93
+ /** Action rows whose rule accepts this selection. */
94
+ function actionsFor(selection: FileNode[]): PluginActionRow[] {
95
+ return pluginActionsFor(actions.value, selection);
96
+ }
97
+
98
+ /** The action row behind a menu key, if any. */
99
+ function byKey(key: string): PluginActionRow | undefined {
100
+ return actions.value.find((a) => pluginActionKey(a) === key);
101
+ }
102
+
103
+ /**
104
+ * Run an action on wire paths. `{op}` when the server queued a job, or
105
+ * `{surface}` when the action opens a view first.
106
+ */
107
+ function run(
108
+ action: PluginActionRow,
109
+ targets: FileNode[],
110
+ params?: Record<string, unknown>,
111
+ ): Promise<PluginRunResult> {
112
+ return api.pluginActionRun(action.plugin, action.id, {
113
+ paths: targets.map((n) => n.path),
114
+ params,
115
+ });
116
+ }
117
+
118
+ return { enabled: isEnabled, actions, views, loaded, error, refresh, actionsFor, byKey, run };
119
+ }
120
+
121
+ export type PluginActionsStore = ReturnType<typeof usePluginActions>;
@@ -0,0 +1,223 @@
1
+ /**
2
+ * usePluginSurface — one plugin view's conversation with the server.
3
+ *
4
+ * A `modal` view (PluginViewModal) and an `inspector` section
5
+ * (PluginInspectorSection) draw the same kind of screen and post the same
6
+ * events; what differs is only the frame around it. So the conversation
7
+ * lives here, once: the current surface, the values its nodes hold, the
8
+ * debounced `change`, the footer press, the list row action, and what an
9
+ * answer does — a new screen, a toast, `done`, or `{op}` (the server queued
10
+ * a job; the caller registers the row and closes).
11
+ *
12
+ * Values: seeded from every fresh surface (`lib/surfaceValues.initialValues`),
13
+ * replaced as the components edit them, and sent whole on every event as
14
+ * `data.values`. After a `change` answer the person's current entries WIN
15
+ * over the echo — they may have typed more while the request was out.
16
+ */
17
+ import { computed, getCurrentInstance, onBeforeUnmount, ref } from 'vue';
18
+ import type { FileApi } from './useFileApi';
19
+ import type {
20
+ PluginSurface,
21
+ PluginViewEventBody,
22
+ PluginViewEventData,
23
+ SurfaceAction,
24
+ SurfaceOpenRequest,
25
+ } from '../types/Plugins';
26
+ import { foreignText } from '../lib/direction';
27
+ import { isOpenRequest } from '../lib/surfaceOpen';
28
+ import { labelOf } from '../lib/pluginLabel';
29
+ import { initialValues } from '../lib/surfaceValues';
30
+ import { missingRequired, stripHiddenValues } from '../lib/surfaceConditions';
31
+
32
+ /** The contract's debounce for `form` edits → `event: "change"`. */
33
+ export const SURFACE_CHANGE_DEBOUNCE_MS = 300;
34
+
35
+ export interface PluginSurfaceHost {
36
+ api: Pick<FileApi, 'pluginViewEvent'>;
37
+ plugin: string;
38
+ view: string;
39
+ /** Adapter-qualified path the view was opened on — echoed on every event. */
40
+ path?: () => string | undefined;
41
+ locale: () => string;
42
+ /** The words for an answer without a surface. */
43
+ errorText: () => string;
44
+ }
45
+
46
+ export interface PluginSurfaceEvents {
47
+ /** The server enqueued a job: the raw ops row. */
48
+ onOp: (op: Record<string, unknown>) => void;
49
+ /** The conversation is over (`done`, or after `{op}`). */
50
+ onDone: () => void;
51
+ onToast: (message: string) => void;
52
+ /**
53
+ * v3 §3.0 — the answer says "go to this file" (`lib/surfaceOpen`).
54
+ *
55
+ * ⚠ Raised AFTER `onDone` when the answer also carries `done: true`: the
56
+ * screen that asked for the navigation is closed first, or the person
57
+ * arrives at the new one with the old one still over it.
58
+ *
59
+ * ⚠ A frame with nowhere to navigate (a public page has no explorer
60
+ * behind it) simply does not pass this in, and the request is dropped.
61
+ */
62
+ onOpen?: (req: SurfaceOpenRequest) => void;
63
+ }
64
+
65
+ export function usePluginSurface(host: PluginSurfaceHost, events: PluginSurfaceEvents) {
66
+ const current = ref<PluginSurface | null>(null);
67
+ const values = ref<Record<string, unknown>>({});
68
+ const busy = ref(false);
69
+ const failure = ref('');
70
+ /**
71
+ * v3 §2.1 — the visible, required fields a blocked submit is waiting for.
72
+ *
73
+ * ⚠ Pointed at, not merely refused. A submit that does nothing and says
74
+ * nothing is the worst of the three possible behaviours; this list is what
75
+ * puts the star and the "required" line on the fields themselves.
76
+ */
77
+ const invalidKeys = ref<string[]>([]);
78
+
79
+ const footer = computed<SurfaceAction[]>(() => current.value?.actions ?? []);
80
+ const errors = computed(() => current.value?.errors);
81
+
82
+ /** A new screen. `keepValues` overlays what the person already entered. */
83
+ function setSurface(s: PluginSurface | null, keepValues = false): void {
84
+ current.value = s;
85
+ failure.value = '';
86
+ invalidKeys.value = [];
87
+ const seeded = initialValues(s?.nodes);
88
+ values.value = keepValues ? { ...seeded, ...values.value } : seeded;
89
+ }
90
+
91
+ function updateValues(v: Record<string, unknown>): void {
92
+ values.value = v;
93
+ // Answering one of them clears its mark as it is typed, rather than on
94
+ // the next press: a field that has been filled must not stay red.
95
+ if (invalidKeys.value.length) {
96
+ const still = missingRequired(current.value?.nodes, v).map((f) => f.key);
97
+ if (still.length !== invalidKeys.value.length) invalidKeys.value = still;
98
+ }
99
+ }
100
+
101
+ let changeTimer: ReturnType<typeof setTimeout> | null = null;
102
+ function cancelChange(): void {
103
+ if (changeTimer !== null) clearTimeout(changeTimer);
104
+ changeTimer = null;
105
+ }
106
+ /** A form edit: one `change` event 300 ms after the last keystroke. */
107
+ function scheduleChange(): void {
108
+ cancelChange();
109
+ changeTimer = setTimeout(() => {
110
+ changeTimer = null;
111
+ void post('change');
112
+ }, SURFACE_CHANGE_DEBOUNCE_MS);
113
+ }
114
+
115
+ let seq = 0;
116
+
117
+ async function post(
118
+ event: PluginViewEventBody['event'],
119
+ actionId?: string,
120
+ extra: Omit<PluginViewEventData, 'values'> = {},
121
+ ): Promise<void> {
122
+ if (!current.value) return;
123
+ const blocking = event !== 'change';
124
+ if (blocking) {
125
+ cancelChange();
126
+ if (busy.value) return;
127
+ busy.value = true;
128
+ }
129
+ const mine = ++seq;
130
+ failure.value = '';
131
+ try {
132
+ const res = await host.api.pluginViewEvent(host.plugin, host.view, {
133
+ path: host.path?.(),
134
+ state: current.value?.state ?? {},
135
+ event,
136
+ action_id: actionId,
137
+ // ⚠⚠ The values a HIDDEN field holds are dropped here, not blanked
138
+ // (v3 §2.1: "a value belonging to a hidden field is dropped before
139
+ // the job runs — it cannot arrive as a surprise"). The plugin is
140
+ // re-asked the same question by the host at submit; this is the
141
+ // client half of the same promise, and it also keeps a `change`
142
+ // event from echoing back a field the step no longer shows.
143
+ data: { values: stripHiddenValues(current.value?.nodes, values.value), ...extra },
144
+ });
145
+ // A `change` answer that arrives after a later event started is stale:
146
+ // the later event's answer is the screen that counts.
147
+ if (!blocking && mine !== seq) return;
148
+ if (res.op) {
149
+ events.onOp(res.op);
150
+ events.onDone();
151
+ return;
152
+ }
153
+ const next = res.surface;
154
+ if (!next) {
155
+ failure.value = host.errorText();
156
+ return;
157
+ }
158
+ const toast = labelOf(next.toast, host.locale());
159
+ if (toast) events.onToast(toast);
160
+ // ⚠ The order is the contract's: `done` closes the screen, and only
161
+ // then does the navigation happen. Reversed, the person lands on the
162
+ // new screen with the finished one still drawn over it.
163
+ const go = isOpenRequest(next.open) ? next.open : null;
164
+ if (next.done) {
165
+ events.onDone();
166
+ if (go) events.onOpen?.(go);
167
+ return;
168
+ }
169
+ setSurface(next, !blocking);
170
+ if (go) events.onOpen?.(go);
171
+ } catch (e) {
172
+ if (!blocking && mine !== seq) return;
173
+ // ⚠ An app's own error text never met the catalogue — isolate it.
174
+ failure.value = foreignText(host.locale(), String((e as Error)?.message ?? e)) || host.errorText();
175
+ } finally {
176
+ if (blocking) busy.value = false;
177
+ }
178
+ }
179
+
180
+ /**
181
+ * A footer button: `submit` when it is the primary one, `action` otherwise.
182
+ *
183
+ * ⚠ A submit is refused locally while a visible, required field is empty.
184
+ * The server refuses it too — that is the boundary — but a round trip to
185
+ * be told "this is required" about a box that is on the screen is a round
186
+ * trip the person should not have to make.
187
+ */
188
+ function press(a: SurfaceAction): Promise<void> {
189
+ if (busy.value || a.disabled) return Promise.resolve();
190
+ if (a.primary) {
191
+ const missing = missingRequired(current.value?.nodes, values.value);
192
+ invalidKeys.value = missing.map((f) => f.key);
193
+ if (missing.length) return Promise.resolve();
194
+ }
195
+ return post(a.primary ? 'submit' : 'action', a.id);
196
+ }
197
+
198
+ /** A `list` row's button. */
199
+ function rowAction(p: { action_id: string; row_id: string }): Promise<void> {
200
+ return post('action', p.action_id, { row_id: p.row_id });
201
+ }
202
+
203
+ if (getCurrentInstance()) onBeforeUnmount(cancelChange);
204
+
205
+ return {
206
+ current,
207
+ values,
208
+ busy,
209
+ failure,
210
+ footer,
211
+ errors,
212
+ invalidKeys,
213
+ setSurface,
214
+ updateValues,
215
+ scheduleChange,
216
+ post,
217
+ press,
218
+ rowAction,
219
+ dispose: cancelChange,
220
+ };
221
+ }
222
+
223
+ export type PluginSurfaceStore = ReturnType<typeof usePluginSurface>;
@@ -0,0 +1,172 @@
1
+ /**
2
+ * usePublicBranding — whose product the visitor thinks they are looking at.
3
+ *
4
+ * ⚠⚠ v3 §1.2: whitelabel means whitelabel. A share, a file request and a
5
+ * signature request are the surfaces an OUTSIDER sees, and until now they
6
+ * were the only ones that ignored the instance's own name, logo and colour
7
+ * — so an operator who had renamed filex everywhere still sent out links
8
+ * that said filex. The branding record is the same one the admin
9
+ * "Corporate identity" page writes; this composable is how the public shell
10
+ * wears it.
11
+ *
12
+ * ⚠ It is a PUBLIC fetch, and the page must be usable before it lands: the
13
+ * shell paints with the stock palette and re-paints when the answer arrives.
14
+ * Nothing waits on it and a failure is not an error — an older server has no
15
+ * such route, and the product's own name is a perfectly good answer.
16
+ *
17
+ * ⚠ The accent is written as `--fe-*` custom properties on the SHELL's root
18
+ * element, never on `:root`. The same shell is mounted inside other pages
19
+ * (an embed, a test harness), and a public link must not repaint the host
20
+ * around it.
21
+ */
22
+ import { computed, ref } from 'vue';
23
+ import type { PublicBranding } from '../types/Public';
24
+ import { setLocalesFromBranding } from '../lib/uiLocales';
25
+
26
+ /** The product's own name, used until (and unless) an operator renames it. */
27
+ export const DEFAULT_BRAND_NAME = 'filex';
28
+
29
+ export interface PublicBrandingOptions {
30
+ /** API origin; empty = same origin. */
31
+ base?: string;
32
+ fetchImpl?: typeof fetch;
33
+ }
34
+
35
+ const HEX = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
36
+
37
+ /** `#abc` → `#aabbcc`; anything that is not a hex colour → ''. */
38
+ export function normalizeAccent(raw: string | undefined | null): string {
39
+ const v = String(raw ?? '').trim();
40
+ if (!HEX.test(v)) return '';
41
+ if (v.length === 4) return `#${v[1]}${v[1]}${v[2]}${v[2]}${v[3]}${v[3]}`.toLowerCase();
42
+ return v.toLowerCase();
43
+ }
44
+
45
+ /** The same colour, `k` of the way to black (0.85 = the hover state). */
46
+ export function shade(hex: string, k: number): string {
47
+ const v = normalizeAccent(hex);
48
+ if (!v) return '';
49
+ const n = parseInt(v.slice(1), 16);
50
+ const part = (shift: number) => Math.max(0, Math.min(255, Math.round(((n >> shift) & 0xff) * k)));
51
+ const hx = (x: number) => x.toString(16).padStart(2, '0');
52
+ return `#${hx(part(16))}${hx(part(8))}${hx(part(0))}`;
53
+ }
54
+
55
+ /**
56
+ * Black or white on this colour, by luminance.
57
+ *
58
+ * ⚠ Not a constant. An operator who picks a pale accent gets white text on
59
+ * pale yellow if the ink is hardcoded — which is what "unreadable button"
60
+ * looks like to the person reporting it, and it is only ever seen on
61
+ * somebody else's instance.
62
+ */
63
+ export function inkOn(hex: string): string {
64
+ const v = normalizeAccent(hex);
65
+ if (!v) return '';
66
+ const n = parseInt(v.slice(1), 16);
67
+ const lin = (c: number) => {
68
+ const x = c / 255;
69
+ return x <= 0.04045 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4;
70
+ };
71
+ const L = 0.2126 * lin((n >> 16) & 0xff) + 0.7152 * lin((n >> 8) & 0xff) + 0.0722 * lin(n & 0xff);
72
+ return L > 0.42 ? '#0b0f14' : '#ffffff';
73
+ }
74
+
75
+ /**
76
+ * The `--fe-*` properties an accent colour becomes on the public shell — the
77
+ * ONE derivation, used by the real page (usePublicBranding) and by the admin
78
+ * page's live preview (PublicLinkPreview), so the preview cannot paint a
79
+ * button the page itself would not. `{}` for no (or no valid) accent.
80
+ */
81
+ export function accentStyleOf(raw: string | undefined | null): Record<string, string> {
82
+ const accent = normalizeAccent(raw);
83
+ if (!accent) return {} as Record<string, string>;
84
+ const n = parseInt(accent.slice(1), 16);
85
+ const rgb = `${(n >> 16) & 0xff}, ${(n >> 8) & 0xff}, ${n & 0xff}`;
86
+ return {
87
+ '--fe-primary': accent,
88
+ '--fe-primary-hover': shade(accent, 0.85),
89
+ '--fe-text-on-primary': inkOn(accent),
90
+ // ⚠ The TINT and the ink on it, or the accent only half arrives: the
91
+ // button turned orange while the round badge behind the padlock, the
92
+ // focus ring on the PIN box and the row hover stayed the stock blue
93
+ // (measured on the first after-shot of the restored gate). The old
94
+ // Go-rendered page derived exactly these from the same colour
95
+ // (`--px-accent-soft: rgba(r,g,b,0.14)`, `color: var(--px-accent)`), and
96
+ // a translucent tint is right rather than a solid: it composites onto
97
+ // the card in light and in dark without a second value.
98
+ '--fe-primary-soft': `rgba(${rgb}, 0.14)`,
99
+ '--fe-primary-ink': accent,
100
+ };
101
+ }
102
+
103
+ export function usePublicBranding(opts: PublicBrandingOptions = {}) {
104
+ const branding = ref<PublicBranding | null>(null);
105
+ const loaded = ref(false);
106
+
107
+ const name = computed(() => branding.value?.name?.trim() || DEFAULT_BRAND_NAME);
108
+ /** True when the operator has named the instance something of their own. */
109
+ const whitelabel = computed(() => !!branding.value?.name?.trim() && name.value !== DEFAULT_BRAND_NAME);
110
+ const logo = computed(() => branding.value?.logo_url?.trim() || '');
111
+ const footerText = computed(() => branding.value?.footer_text?.trim() || '');
112
+ const hidePoweredBy = computed(() => branding.value?.hide_powered_by === true);
113
+
114
+ /**
115
+ * The instance's light/dark default for a visitor who has chosen nothing.
116
+ *
117
+ * ⚠ The server's word is `system`; this package's is `auto`. They mean
118
+ * the same thing and the translation happens HERE, once, rather than in
119
+ * every component that compares the string.
120
+ */
121
+ const themeDefault = computed<'light' | 'dark' | 'auto'>(() => {
122
+ const t = branding.value?.theme;
123
+ return t === 'light' || t === 'dark' ? t : 'auto';
124
+ });
125
+
126
+ /** The languages this instance offers, as the branding answer lists them. */
127
+ const locales = computed<string[]>(() => branding.value?.locales ?? []);
128
+
129
+ /** The accent, as the `--fe-*` properties the shell's root carries. */
130
+ const accentStyle = computed<Record<string, string>>(() => accentStyleOf(branding.value?.accent));
131
+
132
+ /**
133
+ * Ask the server.
134
+ *
135
+ * ⚠ `/api/public/branding` first, `/api/branding` second. The second is
136
+ * the route this product has served since wiring:e1 and answers a
137
+ * compatible record; asking only for the new address and giving up on a
138
+ * 404 would mean a whitelabelled instance goes back to saying "filex" the
139
+ * moment it is updated in the wrong order.
140
+ *
141
+ * ⚠ The languages ride along: `locales` / `ui_locales` are on this payload,
142
+ * so the picker is filled by the fetch the shell already makes
143
+ * (`lib/uiLocales.setLocalesFromBranding`). Only the language somebody
144
+ * actually reads has its strings fetched, once.
145
+ */
146
+ async function load(): Promise<PublicBranding | null> {
147
+ const doFetch = opts.fetchImpl ?? (typeof fetch === 'function' ? fetch : null);
148
+ if (!doFetch) return null;
149
+ const origin = (opts.base ?? '').replace(/\/+$/, '');
150
+ for (const path of ['/api/public/branding', '/api/branding']) {
151
+ try {
152
+ const res = await doFetch(`${origin}${path}`, {
153
+ method: 'GET',
154
+ headers: { Accept: 'application/json' },
155
+ credentials: 'same-origin',
156
+ });
157
+ if (!res.ok) continue;
158
+ branding.value = (await res.json()) as PublicBranding;
159
+ loaded.value = true;
160
+ // ⚠ With the same origin and fetch: a language's STRINGS are fetched
161
+ // later from `/api/public/ui-locales/{code}` on this same server.
162
+ setLocalesFromBranding(branding.value, { base: opts.base, fetchImpl: opts.fetchImpl });
163
+ return branding.value;
164
+ } catch {
165
+ /* try the next address; the default name is already on screen */
166
+ }
167
+ }
168
+ return null;
169
+ }
170
+
171
+ return { branding, loaded, name, whitelabel, logo, footerText, hidePoweredBy, themeDefault, locales, accentStyle, load };
172
+ }