@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
package/src/FileExplorer.vue
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* (PWA / OIDC) / CSRF (panel) / basic / none — `useFileApi` swallows
|
|
15
15
|
* the difference.
|
|
16
16
|
*/
|
|
17
|
-
import { computed,
|
|
17
|
+
import { computed, nextTick, onBeforeUnmount, onMounted, onScopeDispose, ref, watch, watchEffect } from 'vue';
|
|
18
18
|
import type { ExplorerConfig, ThemeMode } from './types/ExplorerConfig';
|
|
19
19
|
import type {
|
|
20
20
|
FileNode,
|
|
@@ -32,8 +32,9 @@ import {
|
|
|
32
32
|
} from './composables/useUploadChunked';
|
|
33
33
|
import { useSelection } from './composables/useSelection';
|
|
34
34
|
import { useKeyboardShortcuts } from './composables/useKeyboardShortcuts';
|
|
35
|
-
import { useLocale } from './composables/useLocale';
|
|
35
|
+
import { useLocale, localeTag } from './composables/useLocale';
|
|
36
36
|
import { usePendingOps, type PendingOp } from './composables/usePendingOps';
|
|
37
|
+
import { usePluginActions } from './composables/usePluginActions'; /* App plugins — docs/APP-PLUGINS-API.md */
|
|
37
38
|
import { useRealtime } from './composables/useRealtime';
|
|
38
39
|
import { useThumbs } from './composables/useThumbs';
|
|
39
40
|
import { preloadEditor } from './composables/useMonacoLoader';
|
|
@@ -46,20 +47,27 @@ import {
|
|
|
46
47
|
activeSortDir,
|
|
47
48
|
activeSortKey,
|
|
48
49
|
applySort,
|
|
50
|
+
defaultSort,
|
|
49
51
|
defaultSortDir,
|
|
50
|
-
globalSort,
|
|
51
52
|
setSortLocale,
|
|
52
53
|
type ListingOrder,
|
|
53
54
|
} from './lib/sortOrder'; /* surucu:d1-sort */
|
|
54
55
|
import {
|
|
55
|
-
|
|
56
|
+
ROOT_FOLDER_KEY,
|
|
57
|
+
defaultFolderView,
|
|
58
|
+
folderIsRemembered,
|
|
56
59
|
folderKey as makeFolderKey,
|
|
57
|
-
|
|
58
|
-
folderPrefs,
|
|
60
|
+
onViewPrefsApplied,
|
|
59
61
|
rememberFolder,
|
|
62
|
+
resolveFolderView,
|
|
63
|
+
setPersonFolderDefault,
|
|
60
64
|
touchFolder,
|
|
61
65
|
viewPrefsReady,
|
|
62
66
|
} from './lib/viewPrefs'; /* tablo:t1 */
|
|
67
|
+
import { attachViewPrefsHttp } from './lib/viewPrefsHttp';
|
|
68
|
+
import { provideTableEnv } from './lib/tableEnv';
|
|
69
|
+
import { gateOnService, isOfficeExt, legacyConvertGate } from './lib/serviceGate';
|
|
70
|
+
import { opFailure } from './lib/errorWords';
|
|
63
71
|
import { resolveUiProfile } from './lib/uiProfile';
|
|
64
72
|
import RecentlyOpened from './components/RecentlyOpened.vue';
|
|
65
73
|
import {
|
|
@@ -112,6 +120,7 @@ import { useOperations } from './composables/useOperations';
|
|
|
112
120
|
/* /wiring:c3 */
|
|
113
121
|
/* wiring:c4 */
|
|
114
122
|
import OnboardingTour from './components/OnboardingTour.vue';
|
|
123
|
+
import { markTourSeen, offerTourOnce } from './lib/tour'; /* the tour is offered to a person once */
|
|
115
124
|
/* /wiring:c4 */
|
|
116
125
|
/* wiring:d1 — tabs + per-tab split */
|
|
117
126
|
import TabBar from './components/TabBar.vue';
|
|
@@ -156,6 +165,7 @@ import {
|
|
|
156
165
|
isVirtualViewPath,
|
|
157
166
|
makeTagSegment,
|
|
158
167
|
tagOfPath,
|
|
168
|
+
tagKindOfPath,
|
|
159
169
|
showHiddenFiles,
|
|
160
170
|
setShowHiddenFiles,
|
|
161
171
|
injectTrashRow,
|
|
@@ -164,8 +174,16 @@ import {
|
|
|
164
174
|
import { nodeRowToFileNode as nodeRowToFileNodePure } from './lib/nodeRow'; /* Recent / Starred / tag / Home rows — one shape, with `perm` + `read_only` */
|
|
165
175
|
import { iconFamilyFor, isStorageRow } from './lib/fileIcons'; /* pane:p1 — the storage-row predicate's one home */
|
|
166
176
|
import { actionIconSvg } from './lib/actionIcons'; /* inceleme:r1 — the drop overlay's mark, off the emoji font */
|
|
177
|
+
import { convertAppOffered, isPluginActionKey, pluginActionKey, pluginMenuRows } from './lib/pluginMenu'; /* App plugins — the menu block, pure */
|
|
178
|
+
import { isPagePlacement, pluginPageUrl } from './lib/pluginPage'; /* App plugins — a `page` view opens in a new tab */
|
|
179
|
+
import { lockOf, lockWords, lockedRefusal } from './lib/appLock'; /* App plugins — an app's hold on a file */
|
|
180
|
+
import { linkWordsFor } from './lib/symlink'; /* issue #34 — a link the server will not follow */
|
|
181
|
+
import { labelOf as pluginLabelOf } from './lib/pluginLabel';
|
|
182
|
+
import type { PluginActionRow, PluginSurface, PluginViewRow } from './types/Plugins';
|
|
183
|
+
import type { NavApp } from './components/SideNav.vue';
|
|
167
184
|
import { setNodeStarred } from './lib/star';
|
|
168
|
-
import {
|
|
185
|
+
import { emptyTrashAndFollow, TrashEmptyBusy, type TrashEmptyStatus } from './lib/trashEmpty';
|
|
186
|
+
import { fetchAllTags, fetchTaggedRows, onTagsChanged, type TagItem, type TagKind } from './lib/tags';
|
|
169
187
|
import { resolveTransfer, type TransferIntent } from './lib/transfer';
|
|
170
188
|
import { downloadArchive } from './lib/downloadSelection'; /* tasi:m1 */
|
|
171
189
|
import { labelOfWire } from './lib/destinationTree'; /* tasi:m1 */
|
|
@@ -189,6 +207,8 @@ import DeleteConfirmModal from './modals/DeleteConfirmModal.vue';
|
|
|
189
207
|
import Modal from './modals/Modal.vue'; /* tablo:t1 — the empty-trash confirmation */
|
|
190
208
|
import PreviewModal from './modals/PreviewModal.vue';
|
|
191
209
|
import ConvertModal from './modals/ConvertModal.vue';
|
|
210
|
+
import PluginViewModal from './components/plugin/PluginViewModal.vue'; /* App plugins */
|
|
211
|
+
import PluginConfirmModal from './components/plugin/PluginConfirmModal.vue';
|
|
192
212
|
import PermissionsModal from './modals/PermissionsModal.vue';
|
|
193
213
|
import DestinationPickerModal from './modals/DestinationPickerModal.vue'; /* tasi:m1 */
|
|
194
214
|
import { resolveLocale } from './locales/resolve';
|
|
@@ -227,6 +247,29 @@ const emit = defineEmits<{
|
|
|
227
247
|
* storage list simply ignores it.
|
|
228
248
|
*/
|
|
229
249
|
(e: 'refresh'): void;
|
|
250
|
+
/**
|
|
251
|
+
* paylas:m1 — the navigation panel's "My shares" row was pressed.
|
|
252
|
+
*
|
|
253
|
+
* ⚠ Passed through, not acted on. "How to connect" and "API keys" are
|
|
254
|
+
* surfaces this component OWNS, so it opens them itself; the list of links a
|
|
255
|
+
* person minted is a page of the host application (the SPA's `my-shares`
|
|
256
|
+
* route), and the explorer has no business deciding what a host's URLs look
|
|
257
|
+
* like.
|
|
258
|
+
*
|
|
259
|
+
* ⚠ Which is why the row is behind `config.mySharesVisible` and that flag is
|
|
260
|
+
* OFF by default: an embedder who does not listen would otherwise be given
|
|
261
|
+
* the one row in the panel that leads nowhere. Listening and setting the
|
|
262
|
+
* flag are the same decision, made once — see the note on the row itself in
|
|
263
|
+
* SideNav.vue.
|
|
264
|
+
*/
|
|
265
|
+
(e: 'open-my-shares'): void;
|
|
266
|
+
/**
|
|
267
|
+
* An "Apps" row was pressed and the host draws app home pages itself
|
|
268
|
+
* (`config.appHomePage`): the plugin and its `home` view. ⚠ Passed
|
|
269
|
+
* through, like `open-my-shares` — the page and its address are the
|
|
270
|
+
* host's.
|
|
271
|
+
*/
|
|
272
|
+
(e: 'open-app-home', p: { plugin: string; view: string }): void;
|
|
230
273
|
}>();
|
|
231
274
|
|
|
232
275
|
// --------------------------------------------------------------------
|
|
@@ -249,7 +292,9 @@ const locale = computed(() => resolveLocale(props.config.locale));
|
|
|
249
292
|
* either would leave the comparator on a stale alphabet exactly when they are
|
|
250
293
|
* not mounted. */
|
|
251
294
|
watch(locale, (l) => setSortLocale(l), { immediate: true });
|
|
252
|
-
const { t, formatSize } = useLocale(locale); /* tablo:t1 — the empty-trash confirmation names the space */
|
|
295
|
+
const { t, formatSize, formatDate, dir } = useLocale(locale); /* tablo:t1 — the empty-trash confirmation names the space; `formatDate` reads an app lock's end on the user's clock */
|
|
296
|
+
// ⚠ RTL — `dir` goes on the root: the explorer's direction is its OWN locale's,
|
|
297
|
+
// never the host page's (lib/direction has the rule and why).
|
|
253
298
|
|
|
254
299
|
// Live collaboration (WebSocket file-change events + presence), bundled into the
|
|
255
300
|
// core so every consumer — the native panel AND the embedded webcomponent —
|
|
@@ -299,10 +344,21 @@ const pendingOps = usePendingOps(props.config, api, {
|
|
|
299
344
|
onSettled: (op: PendingOp) => {
|
|
300
345
|
const undo = opUndo.get(op.id);
|
|
301
346
|
opUndo.delete(op.id);
|
|
347
|
+
/* "Empty the trash" says how it ended itself (emptyTrash below), and a
|
|
348
|
+
* row somebody cancelled is not announced as done. */
|
|
349
|
+
if (op.op_type === 'trash-empty' || op.status === 'cancelled') {
|
|
350
|
+
void load();
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
302
353
|
if (op.status === 'error') {
|
|
303
|
-
|
|
354
|
+
// Said, not printed: the server's error text is English and sometimes
|
|
355
|
+
// plumbing ("engine libreoffice is not installed on this host").
|
|
356
|
+
flashToast(opFailure(op, t).text);
|
|
304
357
|
} else if (undo) {
|
|
305
358
|
undoToast(`${undo.message} (${op.progress_total})`, undo.fn);
|
|
359
|
+
} else if (op.op_type === 'plugin') {
|
|
360
|
+
/* App plugins — the job's own last words, else "<label> finished". */
|
|
361
|
+
flashToast(op.message || t('plugin.done', { label: pluginOpLabel(op) }));
|
|
306
362
|
} else {
|
|
307
363
|
const verb =
|
|
308
364
|
op.op_type === 'copy'
|
|
@@ -352,23 +408,27 @@ function retryLoad() {
|
|
|
352
408
|
void load(loadErrorPath);
|
|
353
409
|
}
|
|
354
410
|
|
|
411
|
+
/**
|
|
412
|
+
* The first-paint cache of the DEFAULT view mode (`lib/viewPrefs` writes it
|
|
413
|
+
* from the person's or the instance's default). ⚠ Read here, never written:
|
|
414
|
+
* see below.
|
|
415
|
+
*/
|
|
355
416
|
const VIEW_MODE_KEY = 'brf-file-explorer:view-mode';
|
|
356
417
|
/**
|
|
357
|
-
*
|
|
358
|
-
* opposed to a person choosing one.
|
|
418
|
+
* The view mode on screen.
|
|
359
419
|
*
|
|
360
|
-
* ⚠⚠
|
|
361
|
-
*
|
|
362
|
-
*
|
|
363
|
-
*
|
|
364
|
-
*
|
|
365
|
-
*
|
|
366
|
-
*
|
|
367
|
-
*
|
|
420
|
+
* ⚠⚠ Setting it writes NOTHING global, and that is the fix for the owner's
|
|
421
|
+
* report of 2026-09-21 — "Explore içindeki değişikliklerimiz o klasör özelinde
|
|
422
|
+
* olmalı; tüm klasörlerde görünüm değişikliği geçerli oluyor." It used to
|
|
423
|
+
* write `brf-file-explorer:view-mode` AND the account's global default on
|
|
424
|
+
* every change, under the rule "your last choice becomes the default for every
|
|
425
|
+
* folder you have never set up": switch folder A to grid and folder B, never
|
|
426
|
+
* touched, came up as grid. A change is now recorded against the folder it was
|
|
427
|
+
* made in (the recorder below) and nowhere else; the default is set on purpose
|
|
428
|
+
* in the person's settings or by the operator.
|
|
368
429
|
*/
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
let value: ViewMode = (() => {
|
|
430
|
+
const viewMode = ref<ViewMode>(
|
|
431
|
+
(() => {
|
|
372
432
|
try {
|
|
373
433
|
const stored = localStorage.getItem(VIEW_MODE_KEY);
|
|
374
434
|
if (stored === 'list' || stored === 'grid' || stored === 'gallery') return stored; /* wiring:d2 */
|
|
@@ -376,30 +436,20 @@ const viewMode = customRef<ViewMode>((track, trigger) => {
|
|
|
376
436
|
/* private mode */
|
|
377
437
|
}
|
|
378
438
|
return props.config.viewMode ?? 'list';
|
|
379
|
-
})()
|
|
380
|
-
|
|
381
|
-
get() {
|
|
382
|
-
track();
|
|
383
|
-
return value;
|
|
384
|
-
},
|
|
385
|
-
set(next) {
|
|
386
|
-
if (next === value) return;
|
|
387
|
-
value = next;
|
|
388
|
-
if (!restoringFolderView) {
|
|
389
|
-
try {
|
|
390
|
-
localStorage.setItem(VIEW_MODE_KEY, next);
|
|
391
|
-
} catch {
|
|
392
|
-
/* quota */
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
|
-
trigger();
|
|
396
|
-
},
|
|
397
|
-
};
|
|
398
|
-
});
|
|
439
|
+
})(),
|
|
440
|
+
);
|
|
399
441
|
/* cila:a density — Toolbar owns the persisted preference (filex.density);
|
|
400
442
|
mirrored here only so the root `.fe` can carry fe--density-compact. */
|
|
401
443
|
const density = ref<'comfortable' | 'compact'>('comfortable');
|
|
402
444
|
const searchQuery = ref('');
|
|
445
|
+
/** The search answer on screen was cut: more rows matched than came back
|
|
446
|
+
* (`truncated` on the response; lib/advSearch `advSearchTruncated`). Reset by
|
|
447
|
+
* every load(), so it can only ever describe the listing that is showing. */
|
|
448
|
+
const searchTruncated = ref(false);
|
|
449
|
+
/** The manager's search action asks the index for this many hits
|
|
450
|
+
* (handlers.Manager, managerSearchPage) — the page an older server's full
|
|
451
|
+
* answer is recognised by. */
|
|
452
|
+
const MANAGER_SEARCH_PAGE = 250;
|
|
403
453
|
// trashMode — true while viewing the filex trash (soft-deleted nodes from the
|
|
404
454
|
// backend trash endpoint), entered by opening the virtual `.trash` row and
|
|
405
455
|
// exited by any normal navigation (load() resets it). Replaces a brittle
|
|
@@ -466,66 +516,82 @@ const folderMemoryOn = computed(() => props.config.rememberFolderView !== false)
|
|
|
466
516
|
const currentFolderKey = computed(() => {
|
|
467
517
|
if (!folderMemoryOn.value) return '';
|
|
468
518
|
const path = String(currentPath.value ?? '').replace(/^\/+|\/+$/g, '');
|
|
469
|
-
|
|
519
|
+
/* The listing of every storage is a place too: a person who sorts their
|
|
520
|
+
drives expects them to stay sorted, and `''` would mean "no folder", whose
|
|
521
|
+
changes land on the person's DEFAULT. */
|
|
522
|
+
if (!path) return ROOT_FOLDER_KEY;
|
|
470
523
|
const [first, ...rest] = path.split('/');
|
|
471
524
|
const st = (props.config.storages ?? []).find((s) => s.name === first);
|
|
472
525
|
return makeFolderKey(st?.uid || first, rest.join('/'));
|
|
473
526
|
});
|
|
474
527
|
|
|
475
|
-
/**
|
|
476
|
-
*
|
|
477
|
-
*
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
528
|
+
/**
|
|
529
|
+
* The DEFAULT view mode — what a folder nobody has configured opens as: the
|
|
530
|
+
* person's default, else the instance's (`lib/viewPrefs.defaultFolderView`),
|
|
531
|
+
* else this host's `config.viewMode`, else list. Until the account's document
|
|
532
|
+
* has landed, this browser's cached copy of that answer stands in for it.
|
|
533
|
+
*/
|
|
534
|
+
function defaultViewMode(): ViewMode {
|
|
535
|
+
const d = defaultFolderView().v;
|
|
536
|
+
if (d) return d;
|
|
537
|
+
if (!viewPrefsReady()) {
|
|
538
|
+
try {
|
|
539
|
+
const stored = localStorage.getItem(VIEW_MODE_KEY);
|
|
540
|
+
if (stored === 'list' || stored === 'grid' || stored === 'gallery') return stored;
|
|
541
|
+
} catch {
|
|
542
|
+
/* private mode */
|
|
543
|
+
}
|
|
484
544
|
}
|
|
485
545
|
return props.config.viewMode ?? 'list';
|
|
486
546
|
}
|
|
487
547
|
|
|
488
|
-
/**
|
|
489
|
-
*
|
|
548
|
+
/**
|
|
549
|
+
* The stored document landed — or another browser's newer one did when this
|
|
550
|
+
* tab came back to the front, or the operator's default arrived with it.
|
|
551
|
+
* Re-apply the folder on screen: its own memory wins, and a folder with none
|
|
552
|
+
* follows whatever the default now is.
|
|
490
553
|
*
|
|
491
|
-
*
|
|
492
|
-
*
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
554
|
+
* ⚠ Unsubscribed with the component: the registry is module-level and two
|
|
555
|
+
* explorers can be mounted at once.
|
|
556
|
+
*/
|
|
557
|
+
onScopeDispose(
|
|
558
|
+
onViewPrefsApplied(() => {
|
|
559
|
+
if (!viewPrefsReady()) return;
|
|
560
|
+
applyFolderView(currentFolderKey.value);
|
|
561
|
+
}),
|
|
562
|
+
);
|
|
500
563
|
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
564
|
+
/**
|
|
565
|
+
* What the folder on screen was last put into — by the applier, or by the
|
|
566
|
+
* recorder after it wrote a change down. The recorder compares against it to
|
|
567
|
+
* tell a person's change from a restore's echo, and to know WHICH field
|
|
568
|
+
* changed.
|
|
569
|
+
*
|
|
570
|
+
* ⚠⚠ A record and not a `restoring = true … false` fence: Vue's watchers are
|
|
571
|
+
* asynchronous, so a fence around an assignment is already back down by the
|
|
572
|
+
* time the recorder runs, and every navigation would record itself as a
|
|
573
|
+
* deliberate choice — walking through a folder would configure it.
|
|
574
|
+
*/
|
|
575
|
+
let applied: { key: string; v: ViewMode; k: string; d: string } | null = null;
|
|
504
576
|
|
|
505
|
-
/** A navigation ended: put this folder back the way
|
|
577
|
+
/** A navigation ended (or the defaults changed): put this folder back the way
|
|
578
|
+
* it was left — its own memory, else the default, field by field. */
|
|
506
579
|
function applyFolderView(key: string) {
|
|
507
|
-
if (!key) return;
|
|
580
|
+
if (!key && folderMemoryOn.value) return;
|
|
508
581
|
touchFolder(key); // LRU clock — only bumps folders already remembered
|
|
509
|
-
const p =
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
const
|
|
519
|
-
|
|
520
|
-
if (p?.k) applySort(p.k, p.d ?? defaultSortDir(p.k));
|
|
521
|
-
else {
|
|
522
|
-
const g = globalSort();
|
|
523
|
-
applySort(g.key, g.dir);
|
|
524
|
-
}
|
|
525
|
-
} finally {
|
|
526
|
-
restoringFolderView = false;
|
|
582
|
+
const p = key ? resolveFolderView(key) : {};
|
|
583
|
+
/* ⚠ The `else` halves matter as much as the `if`s. Without them a folder
|
|
584
|
+
* with no memory of its own would inherit whatever the PREVIOUS folder was
|
|
585
|
+
* put into — walk from a remembered gallery into a plain folder and it
|
|
586
|
+
* comes up as a gallery, which is the leak wearing a different hat. */
|
|
587
|
+
const wantView = p.v ?? defaultViewMode();
|
|
588
|
+
if (wantView !== viewMode.value) viewMode.value = wantView;
|
|
589
|
+
if (p.k) applySort(p.k, p.d ?? defaultSortDir(p.k));
|
|
590
|
+
else {
|
|
591
|
+
const g = defaultSort();
|
|
592
|
+
applySort(g.key, g.dir);
|
|
527
593
|
}
|
|
528
|
-
|
|
594
|
+
applied = { key, v: viewMode.value, k: activeSortKey(), d: activeSortDir() };
|
|
529
595
|
}
|
|
530
596
|
|
|
531
597
|
/**
|
|
@@ -538,45 +604,13 @@ function applyFolderView(key: string) {
|
|
|
538
604
|
* land first in practice, and nothing is applied to a folder until they do.
|
|
539
605
|
*/
|
|
540
606
|
onMounted(() => {
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
});
|
|
549
|
-
/* ⚠ A 401 is not an error here, it is an ANSWER: an app token or a
|
|
550
|
-
* public share link has no person to remember anything for. Returning
|
|
551
|
-
* null degrades to "remember nothing, write nothing", which is what the
|
|
552
|
-
* module does with it — no retry loop, no console noise. */
|
|
553
|
-
if (!res.ok) return null;
|
|
554
|
-
const body = (await res.json()) as { prefs?: unknown };
|
|
555
|
-
return body?.prefs ?? null;
|
|
556
|
-
},
|
|
557
|
-
save(doc) {
|
|
558
|
-
void (async () => {
|
|
559
|
-
try {
|
|
560
|
-
const payload = JSON.stringify({ prefs: doc });
|
|
561
|
-
await fetch(url, {
|
|
562
|
-
method: 'PUT',
|
|
563
|
-
headers: { ...(await buildAuthHeaders()), 'Content-Type': 'application/json' },
|
|
564
|
-
credentials: api.credentialsMode(),
|
|
565
|
-
body: payload,
|
|
566
|
-
/* ⚠ `keepalive` is what lets the save fired on `pagehide` outlive
|
|
567
|
-
* the page — but browsers cap a keepalive body at 64 KB and reject
|
|
568
|
-
* the request outright above it. The document is capped far below
|
|
569
|
-
* that (300 folders ≈ 33 KB), so this only ever guards the
|
|
570
|
-
* pathological case; sending it without keepalive is strictly
|
|
571
|
-
* better than having it rejected. */
|
|
572
|
-
keepalive: payload.length < 60000,
|
|
573
|
-
});
|
|
574
|
-
} catch {
|
|
575
|
-
/* Fire and forget. A view preference is never worth a toast, and the
|
|
576
|
-
next save carries the whole document again. */
|
|
577
|
-
}
|
|
578
|
-
})();
|
|
579
|
-
},
|
|
607
|
+
/* The one transport (`lib/viewPrefsHttp`), shared with the admin app. A
|
|
608
|
+
* no-op when the host already attached it — which the admin app does on
|
|
609
|
+
* sign-in, because its tables keep their columns in the same document. */
|
|
610
|
+
attachViewPrefsHttp({
|
|
611
|
+
apiBase: props.config.apiBase ?? '',
|
|
612
|
+
headers: buildAuthHeaders,
|
|
613
|
+
credentials: api.credentialsMode(),
|
|
580
614
|
});
|
|
581
615
|
});
|
|
582
616
|
|
|
@@ -600,35 +634,58 @@ watch(
|
|
|
600
634
|
{ immediate: true, flush: 'post' },
|
|
601
635
|
);
|
|
602
636
|
|
|
603
|
-
/**
|
|
604
|
-
*
|
|
605
|
-
*
|
|
606
|
-
*
|
|
637
|
+
/**
|
|
638
|
+
* A person changed a view. Record it against THE FOLDER — and only the field
|
|
639
|
+
* that changed: switching to grid records the view mode and leaves the
|
|
640
|
+
* folder's sort following the default, so a default sort chosen later still
|
|
641
|
+
* reaches it.
|
|
642
|
+
*
|
|
643
|
+
* ⚠⚠ Nothing global is written here or anywhere else on a click. This is the
|
|
644
|
+
* fix for "tüm klasörlerde görünüm değişikliği geçerli oluyor".
|
|
645
|
+
*
|
|
646
|
+
* ⚠ A host that switched the per-folder memory OFF (`rememberFolderView:
|
|
647
|
+
* false`) has no folder to record against (`currentFolderKey` is ''), and asked
|
|
648
|
+
* for one arrangement everywhere — so for it, and only for it, a change is the
|
|
649
|
+
* person's default.
|
|
650
|
+
*/
|
|
607
651
|
watch(
|
|
608
652
|
() => [currentFolderKey.value, viewMode.value, activeSortKey(), activeSortDir()] as const,
|
|
609
653
|
([key, v, k, d]) => {
|
|
610
|
-
/* ⚠ Nothing is recorded before the document has landed
|
|
611
|
-
* state on screen is this session's defaults, not the person's choices
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
if (
|
|
617
|
-
|
|
618
|
-
|
|
654
|
+
/* ⚠ Nothing is recorded before the document has landed: until then the
|
|
655
|
+
* state on screen is this session's defaults, not the person's choices. */
|
|
656
|
+
if (!viewPrefsReady()) return;
|
|
657
|
+
if (!applied || applied.key !== key) return; // a navigation — the applier's turn
|
|
658
|
+
const patch: { v?: ViewMode; k?: typeof k; d?: typeof d } = {};
|
|
659
|
+
if (v !== applied.v) patch.v = v;
|
|
660
|
+
if (k !== applied.k || d !== applied.d) {
|
|
661
|
+
patch.k = k;
|
|
662
|
+
patch.d = d;
|
|
663
|
+
}
|
|
664
|
+
applied = { key, v, k, d };
|
|
665
|
+
if (!Object.keys(patch).length) return;
|
|
666
|
+
if (key) rememberFolder(key, patch);
|
|
667
|
+
else if (!folderMemoryOn.value) setPersonFolderDefault(patch);
|
|
619
668
|
},
|
|
620
|
-
/* ⚠⚠ `post`, and registered AFTER the applier
|
|
621
|
-
*
|
|
622
|
-
*
|
|
623
|
-
*
|
|
624
|
-
* and sort against the folder you just arrived in — every walk through the
|
|
625
|
-
* tree quietly configuring the next folder with the last one's setup. Post
|
|
626
|
-
* puts the applier first; by the time this runs, `appliedSig` matches and it
|
|
627
|
-
* correctly does nothing. */
|
|
669
|
+
/* ⚠⚠ `post`, and registered AFTER the applier: a navigation changes
|
|
670
|
+
* `currentFolderKey`, which both watchers depend on. Pre-flush this would
|
|
671
|
+
* run first, while the view state is still the folder you LEFT, and record
|
|
672
|
+
* that folder's setup against the one you arrived in. */
|
|
628
673
|
{ flush: 'post' },
|
|
629
674
|
);
|
|
675
|
+
|
|
676
|
+
/* "Forget this folder's view" (the column menu): put the folder on screen
|
|
677
|
+
* back onto the default straight away, rather than at the next navigation. */
|
|
678
|
+
watch(
|
|
679
|
+
() => folderIsRemembered(currentFolderKey.value),
|
|
680
|
+
(now, was) => {
|
|
681
|
+
if (was && !now && viewPrefsReady()) applyFolderView(currentFolderKey.value);
|
|
682
|
+
},
|
|
683
|
+
);
|
|
630
684
|
/** The tag being browsed while navView === 'tag' ('' otherwise). */
|
|
631
685
|
const navTag = ref<string>('');
|
|
686
|
+
/** …and which KIND of it (etiket:k2, v0.43): 'personal', 'team', or '' for
|
|
687
|
+
* both — what a `#.tag~x` link from before kinds existed still opens. */
|
|
688
|
+
const navTagKind = ref<TagKind | ''>('');
|
|
632
689
|
/** Sentinel parked in `dirname` so the breadcrumb can label the view. The tag
|
|
633
690
|
* view's sentinel is built per tag (`makeTagSegment`) — see lib/listing. */
|
|
634
691
|
const NAV_VIEW_DIRNAME: Record<Exclude<NavView, '' | 'trash' | 'tag'>, string> = {
|
|
@@ -645,7 +702,9 @@ const NAV_VIEW_DIRNAME: Record<Exclude<NavView, '' | 'trash' | 'tag'>, string> =
|
|
|
645
702
|
* for a folder called `.starred` and landing on "not found". (That was already
|
|
646
703
|
* true of the four shipped views; the tag view would have inherited it.)
|
|
647
704
|
*/
|
|
648
|
-
function virtualViewOf(
|
|
705
|
+
function virtualViewOf(
|
|
706
|
+
path: string,
|
|
707
|
+
): { kind: Exclude<NavView, ''>; tag: string; tagKind: TagKind | '' } | null {
|
|
649
708
|
/* ⚠ The "is this a sentinel at all?" half is `lib/listing`'s
|
|
650
709
|
`isVirtualViewPath`, not a second reading of the map here: FilePane has to
|
|
651
710
|
answer the same question before it qualifies a path (a qualified sentinel
|
|
@@ -655,9 +714,9 @@ function virtualViewOf(path: string): { kind: Exclude<NavView, ''>; tag: string
|
|
|
655
714
|
if (!isVirtualViewPath(path)) return null;
|
|
656
715
|
const clean = String(path ?? '').replace(/^\/+|\/+$/g, '');
|
|
657
716
|
const tag = tagOfPath(clean);
|
|
658
|
-
if (tag) return { kind: 'tag', tag };
|
|
717
|
+
if (tag) return { kind: 'tag', tag, tagKind: tagKindOfPath(clean) };
|
|
659
718
|
const kind = clean.slice(1) as Exclude<NavView, '' | 'tag'>;
|
|
660
|
-
return { kind, tag: '' };
|
|
719
|
+
return { kind, tag: '', tagKind: '' };
|
|
661
720
|
}
|
|
662
721
|
|
|
663
722
|
// When the caller can see exactly ONE storage, the multi-storage root is a
|
|
@@ -811,6 +870,62 @@ watch(
|
|
|
811
870
|
const clipboard = ref<ClipboardState>({ mode: null, items: [], sourcePath: null });
|
|
812
871
|
|
|
813
872
|
const capabilitiesData = ref<Capabilities | null>(null);
|
|
873
|
+
|
|
874
|
+
/**
|
|
875
|
+
* `GET /api/capabilities`, asked once — RETRIED, and AWAITABLE.
|
|
876
|
+
*
|
|
877
|
+
* ⚠⚠ This used to be a fire-and-forget `.then(…).catch(() => {})` in
|
|
878
|
+
* `onMounted`, and that one swallowed catch was a silent, page-wide feature
|
|
879
|
+
* kill. Everything gated on capabilities reads a ref that a failed or slow
|
|
880
|
+
* call simply leaves null: app plugins (`pluginsEnabled` below), OnlyOffice,
|
|
881
|
+
* draw.io, the convert service, new-document types. Nothing retried, so ONE
|
|
882
|
+
* unlucky request — a reload during a backend restart, a proxy hiccup — turned
|
|
883
|
+
* every plugin deep link on that page load into a no-op, for the whole life of
|
|
884
|
+
* the page, with nothing in the console to say why.
|
|
885
|
+
*
|
|
886
|
+
* ⚠⚠ And it was a RACE as well as a failure mode. A notification's deep link
|
|
887
|
+
* arrives from the host (Explore.vue → `openAppTarget`) as soon as the row is
|
|
888
|
+
* revealed, which can be before this answer lands; `openAppTarget` then read
|
|
889
|
+
* `pluginsEnabled` as false and returned. Awaiting THIS PROMISE is the fix —
|
|
890
|
+
* not a delay before trying, which would only move the coin toss.
|
|
891
|
+
*
|
|
892
|
+
* ⚠ `settled`, not `loaded`: a caller waits for the question to be ANSWERED,
|
|
893
|
+
* including "the server would not say". Resolving only on success would hang
|
|
894
|
+
* every awaiting caller on an instance that has no such endpoint.
|
|
895
|
+
*/
|
|
896
|
+
const CAPABILITIES_TRIES = 3;
|
|
897
|
+
let capabilitiesSettled: Promise<void> | null = null;
|
|
898
|
+
|
|
899
|
+
function loadCapabilities(): Promise<void> {
|
|
900
|
+
if (capabilitiesSettled) return capabilitiesSettled;
|
|
901
|
+
if (!api.endpoints.capabilities) {
|
|
902
|
+
capabilitiesSettled = Promise.resolve();
|
|
903
|
+
return capabilitiesSettled;
|
|
904
|
+
}
|
|
905
|
+
capabilitiesSettled = (async () => {
|
|
906
|
+
for (let attempt = 1; attempt <= CAPABILITIES_TRIES; attempt++) {
|
|
907
|
+
try {
|
|
908
|
+
capabilitiesData.value = await api.capabilities();
|
|
909
|
+
return;
|
|
910
|
+
} catch (e) {
|
|
911
|
+
if (attempt === CAPABILITIES_TRIES) {
|
|
912
|
+
// ⚠ SAID, not swallowed. The host logs this (Explore.vue →
|
|
913
|
+
// onExplorerError) so "my apps disappeared" has an answer in the
|
|
914
|
+
// console instead of being indistinguishable from "this instance
|
|
915
|
+
// has no apps".
|
|
916
|
+
emit('error', {
|
|
917
|
+
message: `capabilities: ${(e as Error)?.message ?? String(e)}`,
|
|
918
|
+
context: { what: 'capabilities', attempts: attempt },
|
|
919
|
+
});
|
|
920
|
+
return;
|
|
921
|
+
}
|
|
922
|
+
await new Promise((r) => setTimeout(r, 200 * attempt));
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
})();
|
|
926
|
+
return capabilitiesSettled;
|
|
927
|
+
}
|
|
928
|
+
|
|
814
929
|
/* zaman:z3 — the two clock tiers only an explorer instance can know: the zone
|
|
815
930
|
* its host configured, and the account behind its credential when that
|
|
816
931
|
* credential is a person's. Ranked in lib/timezone, never here. */
|
|
@@ -824,6 +939,385 @@ useExplorerTimeZone({
|
|
|
824
939
|
// ceiling). Both share dialogs derive their expiry choices from it.
|
|
825
940
|
const shareMaxTtlDays = computed(() => capabilitiesData.value?.share_max_ttl_days ?? 0);
|
|
826
941
|
|
|
942
|
+
/* === App plugins (docs/APP-PLUGINS-API.md) ==============================
|
|
943
|
+
* Rows a WebAssembly plugin adds to the file menu, run as ops jobs. The
|
|
944
|
+
* feature follows `capabilities.app_plugins.enabled` unless the host decides
|
|
945
|
+
* (`config.plugins`); while it is off the explorer makes no plugin request.
|
|
946
|
+
* The actions list is fetched once and shared across instances
|
|
947
|
+
* (usePluginActions); the menu block is pure (lib/pluginMenu). */
|
|
948
|
+
const pluginsEnabled = computed<boolean>(() => {
|
|
949
|
+
if (typeof props.config.plugins === 'boolean') return props.config.plugins;
|
|
950
|
+
return capabilitiesData.value?.app_plugins?.enabled === true;
|
|
951
|
+
});
|
|
952
|
+
const pluginActions = usePluginActions(api, () => pluginsEnabled.value);
|
|
953
|
+
|
|
954
|
+
/** The open `modal` view: which plugin/view, its first surface, the row it was opened on. */
|
|
955
|
+
const pluginView = ref<{
|
|
956
|
+
plugin: string;
|
|
957
|
+
view: string;
|
|
958
|
+
surface: PluginSurface;
|
|
959
|
+
path?: string;
|
|
960
|
+
/** A `home` view is drawn full-size whatever the surface says. */
|
|
961
|
+
size?: 'xl';
|
|
962
|
+
} | null>(null);
|
|
963
|
+
|
|
964
|
+
/** The `inspector` views, for the details panel; `[]` while the feature is off. */
|
|
965
|
+
const pluginInspectorViews = computed<PluginViewRow[]>(() =>
|
|
966
|
+
pluginsEnabled.value ? pluginActions.views.value.filter((v) => v.placement === 'inspector') : [],
|
|
967
|
+
);
|
|
968
|
+
|
|
969
|
+
/** The `home` views, as the navigation panel's "Apps" rows. */
|
|
970
|
+
const pluginHomeApps = computed<NavApp[]>(() =>
|
|
971
|
+
pluginsEnabled.value
|
|
972
|
+
? pluginActions.views.value
|
|
973
|
+
.filter((v) => v.placement === 'home')
|
|
974
|
+
.map((v) => ({ key: `${v.plugin}/${v.id}`, label: pluginLabelOf(v.label, locale.value) || v.id, icon: v.icon }))
|
|
975
|
+
: [],
|
|
976
|
+
);
|
|
977
|
+
|
|
978
|
+
/**
|
|
979
|
+
* An "Apps" row: the host's own page for it when it has one (same tab, its
|
|
980
|
+
* own address — `config.appHomePage`), else `GET …/views/{p}/{v}` with no
|
|
981
|
+
* path, drawn full-size in a dialog.
|
|
982
|
+
*/
|
|
983
|
+
async function openPluginHome(key: string) {
|
|
984
|
+
const view = pluginActions.views.value.find((v) => `${v.plugin}/${v.id}` === key);
|
|
985
|
+
if (!view) return;
|
|
986
|
+
closeNavDrawer();
|
|
987
|
+
if (props.config.appHomePage === true) {
|
|
988
|
+
emit('open-app-home', { plugin: view.plugin, view: view.id });
|
|
989
|
+
return;
|
|
990
|
+
}
|
|
991
|
+
try {
|
|
992
|
+
const res = await api.pluginView(view.plugin, view.id);
|
|
993
|
+
if (!res?.surface) {
|
|
994
|
+
flashToast(t('plugin.view.error'));
|
|
995
|
+
return;
|
|
996
|
+
}
|
|
997
|
+
pluginView.value = { plugin: view.plugin, view: view.id, surface: res.surface, size: 'xl' };
|
|
998
|
+
} catch (e) {
|
|
999
|
+
flashToast((e as Error)?.message || t('plugin.view.error'));
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
/** An action whose manifest asks for confirmation, waiting for the answer. */
|
|
1003
|
+
const pluginConfirm = ref<{ action: PluginActionRow; targets: FileNode[] } | null>(null);
|
|
1004
|
+
|
|
1005
|
+
/** Menu rows for a selection — hidden in the trash, inside an encrypted
|
|
1006
|
+
* folder and (through `selectionActionList`'s early return) on storage rows.
|
|
1007
|
+
* On a read-only storage the actions that write their result are left out
|
|
1008
|
+
* (`pluginActionWrites`): the folder's own flag for rows listed in it, each
|
|
1009
|
+
* row's storage for Recent / Starred / a tag / Home, where no folder is. */
|
|
1010
|
+
function pluginActionRows(sel: FileNode[]): ContextAction[] {
|
|
1011
|
+
if (!pluginsEnabled.value) return [];
|
|
1012
|
+
return pluginMenuRows(pluginActions.actions.value, sel, {
|
|
1013
|
+
locale: locale.value,
|
|
1014
|
+
trash: trashActive.value,
|
|
1015
|
+
e2e: e2eActive.value,
|
|
1016
|
+
readOnly: dirReadOnly.value || selReadOnly(sel),
|
|
1017
|
+
// The level each row answers with — the same one the built-in verbs
|
|
1018
|
+
// read — so an app's row a viewer could only be refused is not offered.
|
|
1019
|
+
permOf: (n) => rowPerm(n as FileNode),
|
|
1020
|
+
hasIcon: (name) => actionIconSvg(name) !== '',
|
|
1021
|
+
// Greyed rows with the reason are an ADMINISTRATOR's (the server sends
|
|
1022
|
+
// `gated` to them only; asked here too, so no other caller draws one).
|
|
1023
|
+
needWords: callerAdmin.value
|
|
1024
|
+
? (need) => (need.kind === 'engine' ? t('plugin.needs_engine', { name: need.name }) : t('plugin.needs_other', { name: need.name }))
|
|
1025
|
+
: undefined,
|
|
1026
|
+
});
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
/** What a queued plugin job is called in a toast: its label, or the action id. */
|
|
1030
|
+
function pluginOpLabel(op: PendingOp): string {
|
|
1031
|
+
return op.label || op.action || t('opc.kind.plugin');
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
/**
|
|
1035
|
+
* Where an app job's result lands — the tray's little "new file" / "new
|
|
1036
|
+
* version" chip.
|
|
1037
|
+
*
|
|
1038
|
+
* ⚠ Read from the CACHED ACTIONS LIST, because the ops row does not carry it:
|
|
1039
|
+
* the queue knows the plugin and the action, and the action's manifest output
|
|
1040
|
+
* is what the list already holds. Two consequences worth knowing rather than
|
|
1041
|
+
* discovering: a hidden action (never listed) and a surface that overrode the
|
|
1042
|
+
* output for that one job (`job.output`, which the browser never sees) both
|
|
1043
|
+
* answer `undefined`, and the chip is simply not drawn. A wrong chip would be
|
|
1044
|
+
* worse than none — it is the difference between "your file was replaced" and
|
|
1045
|
+
* "a copy appeared".
|
|
1046
|
+
*/
|
|
1047
|
+
function pluginOutputModeOf(op: PendingOp): string | undefined {
|
|
1048
|
+
if (!op.plugin || !op.action) return undefined;
|
|
1049
|
+
return pluginActions.actions.value.find((a) => a.plugin === op.plugin && a.id === op.action)?.output_mode;
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
/** The menu row was chosen: confirm if the manifest asks, then run. */
|
|
1053
|
+
async function onPluginAction(key: string, targets: FileNode[]) {
|
|
1054
|
+
const action = pluginActions.byKey(key);
|
|
1055
|
+
if (!action || targets.length === 0) return;
|
|
1056
|
+
const confirm = pluginLabelOf(action.confirm, locale.value);
|
|
1057
|
+
if (confirm) {
|
|
1058
|
+
pluginConfirm.value = { action, targets };
|
|
1059
|
+
return;
|
|
1060
|
+
}
|
|
1061
|
+
await runPluginAction(action, targets);
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
async function onPluginConfirmed() {
|
|
1065
|
+
const pending = pluginConfirm.value;
|
|
1066
|
+
pluginConfirm.value = null;
|
|
1067
|
+
if (pending) await runPluginAction(pending.action, pending.targets);
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
/**
|
|
1071
|
+
* A `page` action: open the view as a whole page in a new tab and DO NOT run
|
|
1072
|
+
* anything here. The tab asks for the surface itself (`GET …/views/…?path=`)
|
|
1073
|
+
* and the job is born from its own submit — the same `{surface}` conversation
|
|
1074
|
+
* a modal has, in a frame that has room for a document.
|
|
1075
|
+
*
|
|
1076
|
+
* ⚠ Returns false when the host has not said where its pages live
|
|
1077
|
+
* (`config.pluginPageBase`) or when the browser refused the tab; the caller
|
|
1078
|
+
* then falls back to the dialog. A `page` action that silently does nothing
|
|
1079
|
+
* because a pop-up blocker spoke is indistinguishable from a broken app.
|
|
1080
|
+
*/
|
|
1081
|
+
function openPluginPage(action: PluginActionRow, targets: FileNode[]): boolean {
|
|
1082
|
+
const view = action.view;
|
|
1083
|
+
const base = props.config.pluginPageBase;
|
|
1084
|
+
if (!view || base === undefined) return false;
|
|
1085
|
+
const target = { plugin: action.plugin, view, path: targets[0]?.path };
|
|
1086
|
+
const url = pluginPageUrl(base, target);
|
|
1087
|
+
if (props.config.openPluginPage?.({ ...target, url }) === true) return true;
|
|
1088
|
+
// ⚠⚠ `noopener` is NOT in the feature string, and that is not an oversight.
|
|
1089
|
+
// Per the HTML spec a `window.open` that sets `noopener` returns **null**
|
|
1090
|
+
// even when the tab opened perfectly — so a caller that reads the return as
|
|
1091
|
+
// "was it blocked?" gets a false every single time. Measured: the wizard
|
|
1092
|
+
// opened in its own tab AND a dialog appeared behind it, because this
|
|
1093
|
+
// function answered false and the caller fell back to `…/run`. The opener
|
|
1094
|
+
// is severed on the handle instead, which keeps the guarantee and leaves
|
|
1095
|
+
// `null` meaning the one thing worth knowing: a pop-up blocker spoke.
|
|
1096
|
+
const win = window.open(url, '_blank');
|
|
1097
|
+
if (!win) {
|
|
1098
|
+
flashToast(t('plugin.page_view.blocked'));
|
|
1099
|
+
return false;
|
|
1100
|
+
}
|
|
1101
|
+
try {
|
|
1102
|
+
win.opener = null;
|
|
1103
|
+
} catch {
|
|
1104
|
+
/* a browser that will not let us sever it still opened the page */
|
|
1105
|
+
}
|
|
1106
|
+
return true;
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
/**
|
|
1110
|
+
* POST run. `{op}` → the ops tray (same path a copy takes); `{surface}` → the
|
|
1111
|
+
* view dialog, whose submit will bring the `{op}` later.
|
|
1112
|
+
*/
|
|
1113
|
+
/**
|
|
1114
|
+
* A mutation was refused. ⚠⚠ `423` is an APP LOCK, not a permission problem:
|
|
1115
|
+
* a named app is holding the file until a named date, and the person is
|
|
1116
|
+
* otherwise perfectly allowed to do what they just tried. Dropping it into
|
|
1117
|
+
* the generic path told them "Error (423)" and, once a caller mapped it onto
|
|
1118
|
+
* the permission text, "you are not allowed to do this" — a sentence they
|
|
1119
|
+
* can do nothing with. Every rename / move / delete catch goes through here
|
|
1120
|
+
* so there is exactly one place that decides.
|
|
1121
|
+
*/
|
|
1122
|
+
function reportMutationError(err: unknown, context: Record<string, unknown>): void {
|
|
1123
|
+
const held = lockedRefusal(err);
|
|
1124
|
+
if (held) flashToast(lockWords(held, { t, formatDate, locale: locale.value }));
|
|
1125
|
+
emit('error', { message: (err as Error)?.message ?? String(err), context });
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
async function runPluginAction(action: PluginActionRow, targets: FileNode[]) {
|
|
1129
|
+
const label = pluginLabelOf(action.label, locale.value);
|
|
1130
|
+
if (isPagePlacement(action.view_placement) && openPluginPage(action, targets)) return;
|
|
1131
|
+
try {
|
|
1132
|
+
const res = await pluginActions.run(action, targets);
|
|
1133
|
+
if (res.surface) {
|
|
1134
|
+
pluginView.value = {
|
|
1135
|
+
plugin: action.plugin,
|
|
1136
|
+
view: action.view || action.id,
|
|
1137
|
+
surface: res.surface,
|
|
1138
|
+
path: targets[0]?.path,
|
|
1139
|
+
};
|
|
1140
|
+
return;
|
|
1141
|
+
}
|
|
1142
|
+
if (res.op) onPluginOpQueued(res.op, label);
|
|
1143
|
+
} catch (e) {
|
|
1144
|
+
const err = e as Error & { status?: number; detail?: string };
|
|
1145
|
+
const detail = String(err?.detail ?? '');
|
|
1146
|
+
const held = lockedRefusal(err);
|
|
1147
|
+
if (held) flashToast(lockWords(held, { t, formatDate, locale: locale.value }));
|
|
1148
|
+
else if (err?.status === 422 && detail.includes('not_applicable')) flashToast(t('plugin.not_applicable'));
|
|
1149
|
+
else if (err?.status === 409 && detail.includes('read_only')) flashToast(t('plugin.read_only'));
|
|
1150
|
+
else flashToast(err?.message || t('plugin.failed', { label }));
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
/** A job was enqueued (by `run` or by a view's submit): track it, say so. */
|
|
1155
|
+
function onPluginOpQueued(op: Record<string, unknown>, label?: string) {
|
|
1156
|
+
pendingOps.register(op);
|
|
1157
|
+
const name = label || (typeof op.label === 'string' ? op.label : '') || t('opc.kind.plugin');
|
|
1158
|
+
flashToast(t('plugin.queued', { label: name }));
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
/**
|
|
1162
|
+
* A notification's deep link (`target.open`): show the file and open what the
|
|
1163
|
+
* app asked for on it — its action, or its view.
|
|
1164
|
+
*
|
|
1165
|
+
* ⚠⚠ Why this is exposed rather than a config prop. A bell click does not
|
|
1166
|
+
* remount anything: the person is already standing in the explorer, the hash
|
|
1167
|
+
* changes, the listing reloads. An `initialPluginAction` prop would fire once,
|
|
1168
|
+
* at mount, and every click after the first would land on the file and stop
|
|
1169
|
+
* there — which is the bug this whole field exists to fix ("please sign" that
|
|
1170
|
+
* drops you in a folder instead of in the signing screen).
|
|
1171
|
+
*
|
|
1172
|
+
* ⚠ The row may not be in the listing yet (a fresh tab, a folder still
|
|
1173
|
+
* loading), so a path that is not on screen is carried as a bare node. The
|
|
1174
|
+
* server re-checks `applies` and the ACL on every run, so the worst case is
|
|
1175
|
+
* an honest 422 rather than a wrong screen.
|
|
1176
|
+
*
|
|
1177
|
+
* ⚠⚠ It AWAITS the capability answer before reading `pluginsEnabled`. That
|
|
1178
|
+
* flag comes from `GET /api/capabilities`, which the explorer asks for in its
|
|
1179
|
+
* own `onMounted` — so whether this function found it filled was a matter of
|
|
1180
|
+
* which of two unrelated requests came back first. Measured as "roughly one
|
|
1181
|
+
* opening in eight the signing screen does not come up"; it was never the
|
|
1182
|
+
* plugin LIST (that was already awaited two lines down), it was the flag
|
|
1183
|
+
* gating it. Awaiting the promise removes the timing question instead of
|
|
1184
|
+
* betting on it.
|
|
1185
|
+
*
|
|
1186
|
+
* ⚠ Every refusal below now SAYS something. Four `return false`s in this path
|
|
1187
|
+
* were silent, and a deep link that silently does nothing is the hardest
|
|
1188
|
+
* possible bug to report: the person clicked a notification, the folder
|
|
1189
|
+
* opened, and the thing they were asked to do never appeared.
|
|
1190
|
+
*/
|
|
1191
|
+
async function openAppTarget(p: {
|
|
1192
|
+
plugin: string;
|
|
1193
|
+
action?: string;
|
|
1194
|
+
view?: string;
|
|
1195
|
+
path: string;
|
|
1196
|
+
}): Promise<boolean> {
|
|
1197
|
+
if (!p.plugin || !p.path) return false;
|
|
1198
|
+
await loadCapabilities();
|
|
1199
|
+
if (!pluginsEnabled.value) {
|
|
1200
|
+
emit('error', {
|
|
1201
|
+
message: 'app plugins are off on this instance — deep link ignored',
|
|
1202
|
+
context: { what: 'openAppTarget', plugin: p.plugin, path: p.path },
|
|
1203
|
+
});
|
|
1204
|
+
return false;
|
|
1205
|
+
}
|
|
1206
|
+
await pluginActions.refresh();
|
|
1207
|
+
const node: FileNode =
|
|
1208
|
+
files.value.find((f) => f.path === p.path) ??
|
|
1209
|
+
({ path: p.path, basename: p.path.split('/').pop() ?? p.path, type: 'file' } as FileNode);
|
|
1210
|
+
if (p.action) {
|
|
1211
|
+
const action = pluginActions.byKey(`plugin:${p.plugin}/${p.action}`);
|
|
1212
|
+
if (!action) {
|
|
1213
|
+
// The server validated this pair when it stored the target, so getting
|
|
1214
|
+
// here means the app was removed, disabled, or is not visible to this
|
|
1215
|
+
// account — all of which are worth a sentence.
|
|
1216
|
+
flashToast(t('plugin.not_applicable'));
|
|
1217
|
+
emit('error', {
|
|
1218
|
+
message: `no such plugin action: ${p.plugin}/${p.action}`,
|
|
1219
|
+
context: { what: 'openAppTarget', path: p.path },
|
|
1220
|
+
});
|
|
1221
|
+
return false;
|
|
1222
|
+
}
|
|
1223
|
+
await onPluginAction(pluginActionKey(action), [node]);
|
|
1224
|
+
return true;
|
|
1225
|
+
}
|
|
1226
|
+
if (!p.view) return false;
|
|
1227
|
+
// A `page` view is never listed in `views[]` (only inspector/home are), so
|
|
1228
|
+
// its placement is learned from an action that opens it. Nothing else
|
|
1229
|
+
// knows, and guessing "page" for everything would take a dialog-sized
|
|
1230
|
+
// screen into a tab of its own.
|
|
1231
|
+
const owner = pluginActions.actions.value.find((a) => a.plugin === p.plugin && a.view === p.view);
|
|
1232
|
+
if (owner && isPagePlacement(owner.view_placement)) {
|
|
1233
|
+
return openPluginPage(owner, [node]);
|
|
1234
|
+
}
|
|
1235
|
+
try {
|
|
1236
|
+
const res = await api.pluginView(p.plugin, p.view, p.path);
|
|
1237
|
+
if (!res?.surface) {
|
|
1238
|
+
flashToast(t('plugin.view.error'));
|
|
1239
|
+
return false;
|
|
1240
|
+
}
|
|
1241
|
+
pluginView.value = { plugin: p.plugin, view: p.view, surface: res.surface, path: p.path };
|
|
1242
|
+
return true;
|
|
1243
|
+
} catch (e) {
|
|
1244
|
+
flashToast((e as Error)?.message || t('plugin.view.error'));
|
|
1245
|
+
return false;
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
/**
|
|
1250
|
+
* v3 §3.0 — a surface answered "go to this file, and start that screen".
|
|
1251
|
+
*
|
|
1252
|
+
* ⚠⚠ In-app, through `openAppTarget`, rather than by navigating the window
|
|
1253
|
+
* to the deep link: the person is already standing in the explorer, and a
|
|
1254
|
+
* reload would throw away the folder, the selection and the tab strip to
|
|
1255
|
+
* arrive at a screen the explorer can simply open. The deep-link ADDRESS is
|
|
1256
|
+
* the right answer for a frame with no explorer under it (a plugin `page`
|
|
1257
|
+
* view navigates); it is the wrong one here.
|
|
1258
|
+
*
|
|
1259
|
+
* ⚠ The screen that asked is closed first — an answer carrying `done` has
|
|
1260
|
+
* already closed it, and one that does not would otherwise sit over the
|
|
1261
|
+
* screen the person was sent to.
|
|
1262
|
+
*
|
|
1263
|
+
* ⚠ No permission check here, and that is deliberate: the host verified the
|
|
1264
|
+
* screen belongs to the plugin that answered, and the server re-checked the
|
|
1265
|
+
* path against THIS person's rights before the answer left, dropping the
|
|
1266
|
+
* request rather than refusing the screen. A second, weaker copy of that
|
|
1267
|
+
* rule in the browser would be the kind of check that drifts.
|
|
1268
|
+
*/
|
|
1269
|
+
async function onSurfaceOpen(plugin: string, req: { path: string; action?: string; view?: string }): Promise<void> {
|
|
1270
|
+
if (!plugin || !req?.path) return;
|
|
1271
|
+
pluginView.value = null;
|
|
1272
|
+
// The folder first, so the person can SEE where they were taken — the same
|
|
1273
|
+
// walk `onOpenOpOutput` makes for a job's output.
|
|
1274
|
+
const sep = req.path.indexOf('://');
|
|
1275
|
+
const storageName = sep === -1 ? '' : req.path.slice(0, sep);
|
|
1276
|
+
const rel = stripAdapter(req.path).replace(/^\/+|\/+$/g, '');
|
|
1277
|
+
const slash = rel.lastIndexOf('/');
|
|
1278
|
+
const dirRel = slash === -1 ? '' : rel.slice(0, slash);
|
|
1279
|
+
const target = multiStorageRoot.value && storageName ? (dirRel ? `${storageName}/${dirRel}` : storageName) : dirRel;
|
|
1280
|
+
try {
|
|
1281
|
+
await load(target);
|
|
1282
|
+
} catch {
|
|
1283
|
+
/* the folder may be gone; the screen below still tries the file */
|
|
1284
|
+
}
|
|
1285
|
+
await openAppTarget({ plugin, action: req.action, view: req.view, path: req.path });
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
/** Cancel from the operations center — the server stops the job. */
|
|
1289
|
+
async function onCancelPendingOp(id: number) {
|
|
1290
|
+
try {
|
|
1291
|
+
await api.opsCancel(id);
|
|
1292
|
+
await pendingOps.poll();
|
|
1293
|
+
} catch {
|
|
1294
|
+
flashToast(t('plugin.cancel_failed'));
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
/**
|
|
1299
|
+
* "Open" on a finished job: go to the output's folder and select it. The
|
|
1300
|
+
* output is an adapter-qualified path (`docs://reports/nda-signed.pdf`); the
|
|
1301
|
+
* folder is loaded the way a search hit's is, then the row is picked out of
|
|
1302
|
+
* the fresh listing by its exact path.
|
|
1303
|
+
*/
|
|
1304
|
+
async function onOpenOpOutput(_id: number, path: string) {
|
|
1305
|
+
const sep = path.indexOf('://');
|
|
1306
|
+
const storageName = sep === -1 ? '' : path.slice(0, sep);
|
|
1307
|
+
const rel = stripAdapter(path).replace(/^\/+|\/+$/g, '');
|
|
1308
|
+
const slash = rel.lastIndexOf('/');
|
|
1309
|
+
const dirRel = slash === -1 ? '' : rel.slice(0, slash);
|
|
1310
|
+
const target = multiStorageRoot.value && storageName ? (dirRel ? `${storageName}/${dirRel}` : storageName) : dirRel;
|
|
1311
|
+
await load(target);
|
|
1312
|
+
const node = files.value.find((f) => f.path === path);
|
|
1313
|
+
if (node) {
|
|
1314
|
+
selection.click(node.path);
|
|
1315
|
+
return;
|
|
1316
|
+
}
|
|
1317
|
+
flashToast(t('plugin.output_missing'));
|
|
1318
|
+
}
|
|
1319
|
+
/* === /App plugins === */
|
|
1320
|
+
|
|
827
1321
|
// Creative UI state: starred / tags / recently-opened. The component
|
|
828
1322
|
// helpers (StarButton, TagPicker, RecentlyOpened) handle their own
|
|
829
1323
|
// API calls — the explorer just tracks the cross-row state needed to
|
|
@@ -988,16 +1482,24 @@ function openTagPickerFor(n: FileNode) {
|
|
|
988
1482
|
* once the chip has navigated, leaving it up means a dialog about one file
|
|
989
1483
|
* covering the view of all the others that share its tag.
|
|
990
1484
|
*/
|
|
991
|
-
function openTagView(tag: string) {
|
|
1485
|
+
function openTagView(tag: string, kind: TagKind | '' = '') {
|
|
992
1486
|
showTagPicker.value = false;
|
|
993
|
-
void loadTagView(tag);
|
|
1487
|
+
void loadTagView(tag, kind);
|
|
994
1488
|
}
|
|
995
1489
|
|
|
996
1490
|
function onNodeTagsChanged() {
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1491
|
+
// The cache is already dropped (lib/tags `announceTagsChanged`), so this
|
|
1492
|
+
// re-ask is real — and unforced, so every explorer on the page shares one.
|
|
1493
|
+
void loadNavTags();
|
|
1494
|
+
if (navView.value === 'tag' && navTag.value) void loadTagView(navTag.value, navTagKind.value);
|
|
1000
1495
|
}
|
|
1496
|
+
/* etiket:k2 — the ONE path a tag write reaches the panel by: the module-level
|
|
1497
|
+
* announcement, which survives the picker being closed before the save
|
|
1498
|
+
* answered (a component emit does not) and reaches every explorer on the page.
|
|
1499
|
+
* The picker's own `change` / the details panel's `tags-changed` are no longer
|
|
1500
|
+
* wired here, or each save would reload the list twice. */
|
|
1501
|
+
const offTagsChanged = onTagsChanged(onNodeTagsChanged);
|
|
1502
|
+
onBeforeUnmount(offTagsChanged);
|
|
1001
1503
|
|
|
1002
1504
|
function onRecentOpen(entry: { id: number; storage_id?: number; path: string; name: string }) {
|
|
1003
1505
|
// RecentlyOpened emits the bare row. The same converter Recent / Starred /
|
|
@@ -1049,6 +1551,16 @@ const effectiveOnlyOfficeBase = computed<string | null>(() => {
|
|
|
1049
1551
|
return capabilitiesData.value?.onlyoffice_url || null;
|
|
1050
1552
|
});
|
|
1051
1553
|
|
|
1554
|
+
/**
|
|
1555
|
+
* Could this caller SET UP a missing optional service? The server's answer
|
|
1556
|
+
* (`caller_admin` — an administrator who can reach the instance settings;
|
|
1557
|
+
* a tenant admin and an API token cannot), never a role guessed here. It picks
|
|
1558
|
+
* between the owner's two answers for an action that needs a service that is
|
|
1559
|
+
* not there: "disabled with a reason for administrators, hidden for everybody
|
|
1560
|
+
* else" (lib/serviceGate).
|
|
1561
|
+
*/
|
|
1562
|
+
const callerAdmin = computed(() => capabilitiesData.value?.caller_admin === true);
|
|
1563
|
+
|
|
1052
1564
|
const effectiveOnlyOfficeConfigEndpoint = computed<string | null>(() => {
|
|
1053
1565
|
if (!effectiveOnlyOfficeBase.value) return null;
|
|
1054
1566
|
return api.endpoints.onlyOfficeConfig || null;
|
|
@@ -1065,8 +1577,35 @@ const effectiveDrawioUrl = computed<string | null>(() => {
|
|
|
1065
1577
|
// Universal converter (p2r3/convert fork). convert_url is only populated by
|
|
1066
1578
|
// the backend when the "convert" external service is enabled, so a simple
|
|
1067
1579
|
// presence check is enough gating.
|
|
1068
|
-
const effectiveConvertUrl = computed<string | null>(
|
|
1069
|
-
()
|
|
1580
|
+
const effectiveConvertUrl = computed<string | null>(() => {
|
|
1581
|
+
if (props.config.convertBase) return props.config.convertBase;
|
|
1582
|
+
/* ⚠ Health too, like the two above: the server fills `convert_url` whenever
|
|
1583
|
+
* the service is ENABLED, so a converter that is enabled but unreachable
|
|
1584
|
+
* used to be offered and then sat on "Loading the converter…" for ever. */
|
|
1585
|
+
const ext = capabilitiesData.value?.external?.convert;
|
|
1586
|
+
if (ext && !isExternalUsable(ext)) return null;
|
|
1587
|
+
return capabilitiesData.value?.convert_url || null;
|
|
1588
|
+
});
|
|
1589
|
+
|
|
1590
|
+
/* The legacy converter against the Convert app: one of them, never both
|
|
1591
|
+
* (lib/serviceGate `legacyConvertGate`). `legacyConvertUrl` is what every
|
|
1592
|
+
* door to the iframe dialog reads — the menu, the toolbar, the dialog's own
|
|
1593
|
+
* v-if — so none of them can offer what the rule withheld. */
|
|
1594
|
+
const legacyConvert = computed(() =>
|
|
1595
|
+
legacyConvertGate({
|
|
1596
|
+
appOffered: pluginsEnabled.value && convertAppOffered(pluginActions.actions.value),
|
|
1597
|
+
configured:
|
|
1598
|
+
!!props.config.convertBase ||
|
|
1599
|
+
capabilitiesData.value?.external?.convert?.enabled === true ||
|
|
1600
|
+
!!capabilitiesData.value?.convert_url,
|
|
1601
|
+
healthy: !!effectiveConvertUrl.value,
|
|
1602
|
+
callerAdmin: callerAdmin.value,
|
|
1603
|
+
unhealthyReason: t('ctx.needs_convert'),
|
|
1604
|
+
adminNote: t('convert.legacy_admin'),
|
|
1605
|
+
}),
|
|
1606
|
+
);
|
|
1607
|
+
const legacyConvertUrl = computed<string | null>(() =>
|
|
1608
|
+
legacyConvert.value.hidden || legacyConvert.value.disabled ? null : effectiveConvertUrl.value,
|
|
1070
1609
|
);
|
|
1071
1610
|
|
|
1072
1611
|
/* belge:n1 — what the SERVER can create, crossed with what WE could open.
|
|
@@ -1095,6 +1634,14 @@ const showRename = ref(false);
|
|
|
1095
1634
|
const showDelete = ref(false);
|
|
1096
1635
|
const showPreview = ref(false);
|
|
1097
1636
|
const renameTarget = ref<FileNode | null>(null);
|
|
1637
|
+
/* Why the last rename did not happen, shown in the dialog. A failure used to be
|
|
1638
|
+
* only EMITTED, which the stock web app logs to the console: the dialog stayed
|
|
1639
|
+
* open and silent, the one answer a person can act on — the name is taken —
|
|
1640
|
+
* unseen. */
|
|
1641
|
+
const renameError = ref<string | null>(null);
|
|
1642
|
+
watch(showRename, (open) => {
|
|
1643
|
+
if (open) renameError.value = null;
|
|
1644
|
+
});
|
|
1098
1645
|
/* ui-fix — does the open rename/delete/new-folder modal belong to the side
|
|
1099
1646
|
* pane? (the menu is identical to the main pane's; this routes the mutation
|
|
1100
1647
|
* to the right one.) */
|
|
@@ -1285,12 +1832,23 @@ const sharedStorageNames = ref<string[]>([]);
|
|
|
1285
1832
|
* embedder's users had no path to a protocol guide or to the API token those
|
|
1286
1833
|
* guides tell them to use.
|
|
1287
1834
|
*
|
|
1288
|
-
* ⚠ Not gated on role. The backend decides — ConnectionsPanel
|
|
1289
|
-
*
|
|
1290
|
-
*
|
|
1835
|
+
* ⚠ Not gated on role. The backend decides — ConnectionsPanel is the protocol
|
|
1836
|
+
* guides and the credentials they need, which every caller may read, and
|
|
1837
|
+
* /api/tokens caps every scope against the caller's own role.
|
|
1291
1838
|
*/
|
|
1292
1839
|
const connectionsEnabled = computed(() => props.config.connections ?? !simpleUi.value);
|
|
1293
1840
|
|
|
1841
|
+
/**
|
|
1842
|
+
* paylas:m1 — may the panel draw its "My shares" row?
|
|
1843
|
+
*
|
|
1844
|
+
* ⚠ `=== true`, not `?? <default>`: the row's screen is the HOST's, this
|
|
1845
|
+
* component only announces the intent, and an embedder who has not said yes
|
|
1846
|
+
* has not built that screen. Silence therefore has to mean no — the inverse of
|
|
1847
|
+
* `connectionsEnabled` above, whose panel this component draws itself and can
|
|
1848
|
+
* always deliver on.
|
|
1849
|
+
*/
|
|
1850
|
+
const mySharesEnabled = computed(() => props.config.mySharesVisible === true);
|
|
1851
|
+
|
|
1294
1852
|
/**
|
|
1295
1853
|
* Is this caller an integration rather than a person (backend migration 00030)?
|
|
1296
1854
|
*
|
|
@@ -1474,6 +2032,19 @@ watch(
|
|
|
1474
2032
|
* 2. **The live count**, which is a real query. See `advSearchCount`.
|
|
1475
2033
|
*/
|
|
1476
2034
|
const showAdvSearch = ref(false);
|
|
2035
|
+
/* etiket:k2 — the dialog offers the person's tags, each with its kind, as
|
|
2036
|
+
picks under the Tags box. Asked for when it opens, through the same
|
|
2037
|
+
module-level cache as the panel's list, so it costs nothing when the panel
|
|
2038
|
+
already has them — and works in an embed that shows no panel at all. */
|
|
2039
|
+
const advKnownTags = ref<TagItem[]>([]);
|
|
2040
|
+
watch(showAdvSearch, async (open) => {
|
|
2041
|
+
if (!open) return;
|
|
2042
|
+
advKnownTags.value = await fetchAllTags({
|
|
2043
|
+
apiBase: props.config.apiBase ?? '',
|
|
2044
|
+
authHeaders: () => buildAuthHeaders(),
|
|
2045
|
+
authCredentials: api.credentialsMode(),
|
|
2046
|
+
});
|
|
2047
|
+
});
|
|
1477
2048
|
/** The folder the dialog was opened from, frozen for its lifetime. */
|
|
1478
2049
|
const advPathBase = ref('');
|
|
1479
2050
|
|
|
@@ -1536,15 +2107,28 @@ function advHitToNode(h: GlobalSearchHit, storageName: string): FileNode {
|
|
|
1536
2107
|
};
|
|
1537
2108
|
}
|
|
1538
2109
|
|
|
1539
|
-
/** Run one advanced search and hand back the rows, unfiltered
|
|
1540
|
-
|
|
2110
|
+
/** Run one advanced search and hand back the rows, unfiltered — and whether
|
|
2111
|
+
* the answer was cut. The name scope has the server's own word for that
|
|
2112
|
+
* (`truncated`); /api/files/search's flag does not survive `globalSearch`,
|
|
2113
|
+
* which returns hits only, so the content scope keeps the full-page guess. */
|
|
2114
|
+
async function advFetchRows(
|
|
2115
|
+
scope: AdvScope,
|
|
2116
|
+
query: string,
|
|
2117
|
+
target: string,
|
|
2118
|
+
): Promise<{ rows: FileNode[]; truncated: boolean }> {
|
|
1541
2119
|
if (scope === 'name') {
|
|
1542
2120
|
const resp = await api.search(target, query);
|
|
1543
|
-
return
|
|
2121
|
+
return {
|
|
2122
|
+
rows: filterListing(resp.files),
|
|
2123
|
+
truncated: advSearchTruncated(resp.files.length, MANAGER_SEARCH_PAGE, resp.truncated),
|
|
2124
|
+
};
|
|
1544
2125
|
}
|
|
1545
2126
|
const hits = await api.globalSearch(query, { limit: ADV_CONTENT_LIMIT, scope });
|
|
1546
2127
|
const storageName = adapter.value || (props.config.storages ?? [])[0]?.name || '';
|
|
1547
|
-
return
|
|
2128
|
+
return {
|
|
2129
|
+
rows: filterListing(hits.map((h) => advHitToNode(h, storageName))),
|
|
2130
|
+
truncated: advSearchTruncated(hits.length, ADV_CONTENT_LIMIT),
|
|
2131
|
+
};
|
|
1548
2132
|
}
|
|
1549
2133
|
|
|
1550
2134
|
/** The target `load()` would use for the current position. */
|
|
@@ -1567,11 +2151,10 @@ function advTarget(): string {
|
|
|
1567
2151
|
*/
|
|
1568
2152
|
async function advSearchCount(req: AdvSearchRequest): Promise<AdvCountResult> {
|
|
1569
2153
|
const query = advQueryString(req);
|
|
1570
|
-
const rows = await advFetchRows(req.scope, query, advTarget());
|
|
1571
|
-
const limit = req.scope === 'name' ? 250 : ADV_CONTENT_LIMIT;
|
|
2154
|
+
const { rows, truncated } = await advFetchRows(req.scope, query, advTarget());
|
|
1572
2155
|
return {
|
|
1573
2156
|
count: applyFilters(rows, req.filters).length,
|
|
1574
|
-
capped:
|
|
2157
|
+
capped: truncated,
|
|
1575
2158
|
};
|
|
1576
2159
|
}
|
|
1577
2160
|
|
|
@@ -1811,6 +2394,7 @@ async function loadNavView(kind: Exclude<NavView, ''>) {
|
|
|
1811
2394
|
if (!navView.value) navViewOrigin.value = currentPath.value ?? '';
|
|
1812
2395
|
navView.value = 'home';
|
|
1813
2396
|
navTag.value = '';
|
|
2397
|
+
navTagKind.value = '';
|
|
1814
2398
|
trashMode.value = false;
|
|
1815
2399
|
e2eRoot.value = '';
|
|
1816
2400
|
forgetFolderPerm();
|
|
@@ -1824,7 +2408,7 @@ async function loadNavView(kind: Exclude<NavView, ''>) {
|
|
|
1824
2408
|
}
|
|
1825
2409
|
if (kind === 'tag') {
|
|
1826
2410
|
// The tag view needs a name; the panel calls loadTagView directly.
|
|
1827
|
-
if (navTag.value) await loadTagView(navTag.value);
|
|
2411
|
+
if (navTag.value) await loadTagView(navTag.value, navTagKind.value);
|
|
1828
2412
|
return;
|
|
1829
2413
|
}
|
|
1830
2414
|
if (kind === 'trash') {
|
|
@@ -1836,6 +2420,7 @@ async function loadNavView(kind: Exclude<NavView, ''>) {
|
|
|
1836
2420
|
// never lights up.
|
|
1837
2421
|
navView.value = 'trash';
|
|
1838
2422
|
navTag.value = '';
|
|
2423
|
+
navTagKind.value = '';
|
|
1839
2424
|
return;
|
|
1840
2425
|
}
|
|
1841
2426
|
loading.value = true;
|
|
@@ -1845,6 +2430,7 @@ async function loadNavView(kind: Exclude<NavView, ''>) {
|
|
|
1845
2430
|
if (!navView.value) navViewOrigin.value = currentPath.value ?? '';
|
|
1846
2431
|
navView.value = kind;
|
|
1847
2432
|
navTag.value = '';
|
|
2433
|
+
navTagKind.value = '';
|
|
1848
2434
|
trashMode.value = false;
|
|
1849
2435
|
e2eRoot.value = '';
|
|
1850
2436
|
forgetFolderPerm();
|
|
@@ -1878,7 +2464,7 @@ async function loadNavView(kind: Exclude<NavView, ''>) {
|
|
|
1878
2464
|
* hash — goes through `virtualSegmentLabel`, so none of them can print the
|
|
1879
2465
|
* sentinel the way the strip once printed `.shared`.
|
|
1880
2466
|
*/
|
|
1881
|
-
async function loadTagView(tag: string) {
|
|
2467
|
+
async function loadTagView(tag: string, kind: TagKind | '' = '') {
|
|
1882
2468
|
closeNavDrawer();
|
|
1883
2469
|
const name = String(tag ?? '').trim();
|
|
1884
2470
|
if (!name) return;
|
|
@@ -1886,18 +2472,26 @@ async function loadTagView(tag: string) {
|
|
|
1886
2472
|
if (!navView.value) navViewOrigin.value = currentPath.value ?? '';
|
|
1887
2473
|
navView.value = 'tag';
|
|
1888
2474
|
navTag.value = name;
|
|
2475
|
+
navTagKind.value = kind;
|
|
1889
2476
|
trashMode.value = false;
|
|
1890
2477
|
e2eRoot.value = '';
|
|
1891
2478
|
forgetFolderPerm();
|
|
1892
2479
|
selection.clear();
|
|
1893
2480
|
try {
|
|
1894
|
-
const rows = await fetchTaggedRows(
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
2481
|
+
const rows = await fetchTaggedRows(
|
|
2482
|
+
name,
|
|
2483
|
+
{
|
|
2484
|
+
apiBase: props.config.apiBase ?? '',
|
|
2485
|
+
authHeaders: () => buildAuthHeaders(),
|
|
2486
|
+
authCredentials: api.credentialsMode(),
|
|
2487
|
+
},
|
|
2488
|
+
200,
|
|
2489
|
+
kind,
|
|
2490
|
+
);
|
|
1899
2491
|
files.value = rows.map(nodeRowToFileNode).filter((n): n is FileNode => n !== null);
|
|
1900
|
-
|
|
2492
|
+
// etiket:k2 — the kind is part of the address (`.mytag~x` / `.teamtag~x`),
|
|
2493
|
+
// so a reload or a copied link opens the same one of two same-named tags.
|
|
2494
|
+
const seg = makeTagSegment(name, kind);
|
|
1901
2495
|
dirname.value = seg;
|
|
1902
2496
|
currentPath.value = seg;
|
|
1903
2497
|
// Spans every storage, like Starred/Recent/Shared — so no storage crumb.
|
|
@@ -1905,7 +2499,7 @@ async function loadTagView(tag: string) {
|
|
|
1905
2499
|
} catch (err) {
|
|
1906
2500
|
const msg = err instanceof Error ? err.message : String(err);
|
|
1907
2501
|
files.value = [];
|
|
1908
|
-
emit('error', { message: msg, context: { op: `nav-view:tag:${name}` } });
|
|
2502
|
+
emit('error', { message: msg, context: { op: `nav-view:tag:${kind ? `${kind}:` : ''}${name}` } });
|
|
1909
2503
|
flashToast(msg);
|
|
1910
2504
|
} finally {
|
|
1911
2505
|
loading.value = false;
|
|
@@ -1924,7 +2518,7 @@ async function loadTagView(tag: string) {
|
|
|
1924
2518
|
* none. The cache is dropped the instant the user edits tags, which is the
|
|
1925
2519
|
* only staleness anybody can notice.
|
|
1926
2520
|
*/
|
|
1927
|
-
const navTags = ref<
|
|
2521
|
+
const navTags = ref<TagItem[]>([]);
|
|
1928
2522
|
const navTagsLoaded = ref(false);
|
|
1929
2523
|
|
|
1930
2524
|
async function loadNavTags(force = false) {
|
|
@@ -2291,6 +2885,9 @@ function refreshAll() {
|
|
|
2291
2885
|
}
|
|
2292
2886
|
|
|
2293
2887
|
async function load(path?: string) {
|
|
2888
|
+
// Whatever an earlier search said about ITS answer, this listing has not
|
|
2889
|
+
// answered yet (the "more results than shown" strip reads this).
|
|
2890
|
+
searchTruncated.value = false;
|
|
2294
2891
|
/* === etiket:t1 — a sentinel is a VIEW, not a folder ===================
|
|
2295
2892
|
* A restored tab, a reload on `#.trash` / `#.starred` / `#.tag~invoices`,
|
|
2296
2893
|
* or the breadcrumb crumb for the view you are standing in all arrive here
|
|
@@ -2329,7 +2926,7 @@ async function load(path?: string) {
|
|
|
2329
2926
|
writePersistedPath('');
|
|
2330
2927
|
return await load('');
|
|
2331
2928
|
}
|
|
2332
|
-
if (asView.kind === 'tag') await loadTagView(asView.tag);
|
|
2929
|
+
if (asView.kind === 'tag') await loadTagView(asView.tag, asView.tagKind);
|
|
2333
2930
|
else await loadNavView(asView.kind);
|
|
2334
2931
|
return;
|
|
2335
2932
|
}
|
|
@@ -2365,6 +2962,7 @@ async function load(path?: string) {
|
|
|
2365
2962
|
folder listing. */
|
|
2366
2963
|
navView.value = '';
|
|
2367
2964
|
navTag.value = '';
|
|
2965
|
+
navTagKind.value = '';
|
|
2368
2966
|
let requested = path ?? currentPath.value ?? '';
|
|
2369
2967
|
try {
|
|
2370
2968
|
notFoundPath.value = '';
|
|
@@ -2403,17 +3001,24 @@ async function load(path?: string) {
|
|
|
2403
3001
|
here. Everything downstream — the views, the selection, the inspector —
|
|
2404
3002
|
sees ordinary rows, which is the point: one results surface. */
|
|
2405
3003
|
const advContent = !!searchQuery.value && advScope.value !== 'name';
|
|
2406
|
-
const
|
|
3004
|
+
const adv = advContent ? await advFetchRows(advScope.value, searchQuery.value, target) : null;
|
|
3005
|
+
const resp: ManagerResponse = adv
|
|
2407
3006
|
? {
|
|
2408
3007
|
adapter: adapter.value,
|
|
2409
3008
|
storages: (props.config.storages ?? []).map((s) => s.name),
|
|
2410
3009
|
dirname: dirname.value,
|
|
2411
3010
|
read_only: false,
|
|
2412
|
-
files:
|
|
3011
|
+
files: adv.rows,
|
|
3012
|
+
truncated: adv.truncated,
|
|
2413
3013
|
}
|
|
2414
3014
|
: searchQuery.value
|
|
2415
3015
|
? await api.search(target, searchQuery.value)
|
|
2416
3016
|
: await api.index(target);
|
|
3017
|
+
// A search that matched more than it returned says so (banner strip). The
|
|
3018
|
+
// server's `truncated` is the answer; an older server that does not send
|
|
3019
|
+
// it leaves the full-page guess the advanced search count always made.
|
|
3020
|
+
searchTruncated.value =
|
|
3021
|
+
!!searchQuery.value && advSearchTruncated(resp.files.length, MANAGER_SEARCH_PAGE, resp.truncated);
|
|
2417
3022
|
adapter.value = resp.adapter;
|
|
2418
3023
|
dirname.value = resp.dirname;
|
|
2419
3024
|
dirPerm.value = (resp.perm as string) || '';
|
|
@@ -2523,6 +3128,14 @@ const trashRetentionDays = ref<number | null>(null);
|
|
|
2523
3128
|
const trashCanEmpty = ref(false);
|
|
2524
3129
|
const trashEmptying = ref(false);
|
|
2525
3130
|
const showTrashConfirm = ref(false);
|
|
3131
|
+
/** The purge being followed, while the server reports it as running. */
|
|
3132
|
+
const trashEmptyRun = ref<TrashEmptyStatus | null>(null);
|
|
3133
|
+
/* Set when the explorer is taken down: it stops the following — the purge
|
|
3134
|
+
* is the server's and carries on. */
|
|
3135
|
+
let trashEmptyUnwatched = false;
|
|
3136
|
+
onBeforeUnmount(() => {
|
|
3137
|
+
trashEmptyUnwatched = true;
|
|
3138
|
+
});
|
|
2526
3139
|
|
|
2527
3140
|
async function probeTrashPolicy() {
|
|
2528
3141
|
trashRetentionDays.value = null;
|
|
@@ -2571,24 +3184,73 @@ const trashConfirmKey = computed(() =>
|
|
|
2571
3184
|
trashSizeKnown.value ? 'trash.empty_confirm_body' : 'trash.empty_confirm_body_nosize',
|
|
2572
3185
|
);
|
|
2573
3186
|
|
|
3187
|
+
/** "Emptying the trash… 120 of 61,844" while a purge is followed. */
|
|
3188
|
+
const trashEmptyProgress = computed(() => {
|
|
3189
|
+
const run = trashEmptyRun.value;
|
|
3190
|
+
if (!run?.running) return '';
|
|
3191
|
+
if (run.queued) return t('trash.emptying_queued');
|
|
3192
|
+
const nf = new Intl.NumberFormat(localeTag(locale.value));
|
|
3193
|
+
return t('trash.emptying', { done: nf.format(run.scanned ?? 0), total: nf.format(run.total ?? 0) });
|
|
3194
|
+
});
|
|
3195
|
+
|
|
3196
|
+
/* ⚠⚠ A 2xx is not "emptied" any more. The endpoint used to purge inside the
|
|
3197
|
+
* request, so a large trash never answered — nginx's 504 at sixty seconds
|
|
3198
|
+
* became a toast reading "504". It now answers within seconds: the final
|
|
3199
|
+
* counts, or 202 while the purge goes on in the background, which
|
|
3200
|
+
* lib/trashEmpty follows on GET until the run ends. */
|
|
2574
3201
|
async function emptyTrash() {
|
|
2575
3202
|
showTrashConfirm.value = false;
|
|
2576
3203
|
if (!trashCanEmpty.value || trashEmptying.value) return;
|
|
2577
3204
|
trashEmptying.value = true;
|
|
3205
|
+
const url = `${props.config.apiBase ?? ''}/api/admin/trash/empty`;
|
|
2578
3206
|
try {
|
|
2579
|
-
const
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
credentials: api.credentialsMode(),
|
|
3207
|
+
const end = await emptyTrashAndFollow({
|
|
3208
|
+
start: async () =>
|
|
3209
|
+
fetch(url, { method: 'POST', headers: await buildAuthHeaders(), credentials: api.credentialsMode() }),
|
|
3210
|
+
status: async () => fetch(url, { headers: await buildAuthHeaders(), credentials: api.credentialsMode() }),
|
|
3211
|
+
/* The run is an ops row: it goes into the operations centre (progress,
|
|
3212
|
+
* Cancel) the moment the server names it — a run done within the wait
|
|
3213
|
+
* as well, like any other operation the person started. */
|
|
3214
|
+
onStart: (run) => {
|
|
3215
|
+
if (!run.op_id) return;
|
|
3216
|
+
pendingOps.register({
|
|
3217
|
+
id: run.op_id,
|
|
3218
|
+
kind: 'trash-empty',
|
|
3219
|
+
status: run.running ? (run.queued ? 'pending' : 'running') : run.cancelled ? 'cancelled' : 'ok',
|
|
3220
|
+
total: run.total ?? 0,
|
|
3221
|
+
done: run.scanned ?? 0,
|
|
3222
|
+
});
|
|
3223
|
+
},
|
|
3224
|
+
onProgress: (run) => {
|
|
3225
|
+
trashEmptyRun.value = run;
|
|
3226
|
+
},
|
|
3227
|
+
onBusy: () => flashToast(t('trash.empty_busy')),
|
|
3228
|
+
stopped: () => trashEmptyUnwatched,
|
|
2583
3229
|
});
|
|
2584
|
-
if (
|
|
2585
|
-
|
|
2586
|
-
|
|
3230
|
+
if (end === null) return;
|
|
3231
|
+
if (end.error) throw new Error(t('trash.empty_stopped', { error: end.error }));
|
|
3232
|
+
/* Only the view that is still showing the trash is redrawn: somebody who
|
|
3233
|
+
* went on to a folder while it ran is not pulled back into the trash. */
|
|
3234
|
+
if (trashMode.value) await loadTrash();
|
|
3235
|
+
/* `{running: false}` with no start is a run the server no longer knows —
|
|
3236
|
+
* it restarted under it. The listing just reloaded says what is left, and
|
|
3237
|
+
* nothing is claimed about the rest. */
|
|
3238
|
+
if (!end.started_at) return;
|
|
3239
|
+
if (end.cancelled) {
|
|
3240
|
+
flashToast(t('trash.empty_cancelled', { count: end.purged ?? 0 }));
|
|
3241
|
+
return;
|
|
3242
|
+
}
|
|
3243
|
+
flashToast(end.failed ? t('trash.emptied_partly', { count: end.failed }) : t('trash.emptied'));
|
|
2587
3244
|
} catch (err) {
|
|
3245
|
+
if (err instanceof TrashEmptyBusy) {
|
|
3246
|
+
flashToast(t('trash.empty_busy'));
|
|
3247
|
+
return;
|
|
3248
|
+
}
|
|
2588
3249
|
const msg = err instanceof Error ? err.message : String(err);
|
|
2589
3250
|
emit('error', { message: msg, context: { op: 'trash:empty' } });
|
|
2590
3251
|
flashToast(msg);
|
|
2591
3252
|
} finally {
|
|
3253
|
+
trashEmptyRun.value = null;
|
|
2592
3254
|
trashEmptying.value = false;
|
|
2593
3255
|
}
|
|
2594
3256
|
}
|
|
@@ -2606,7 +3268,10 @@ async function loadTrash() {
|
|
|
2606
3268
|
({
|
|
2607
3269
|
type: 'file',
|
|
2608
3270
|
id: e.id,
|
|
2609
|
-
|
|
3271
|
+
/* ⚠ The ORIGINAL path, which the server sends with its leading
|
|
3272
|
+
slash: `depo:///x.txt` put an empty segment in the Location
|
|
3273
|
+
column ("depo/"), so it is dropped here. */
|
|
3274
|
+
path: e.storage_name ? `${e.storage_name}://${e.path.replace(/^\/+/, '')}` : e.path,
|
|
2610
3275
|
basename: e.name,
|
|
2611
3276
|
extension: e.name.includes('.') ? e.name.split('.').pop() || '' : '',
|
|
2612
3277
|
storage: e.storage_name || '',
|
|
@@ -2619,6 +3284,10 @@ async function loadTrash() {
|
|
|
2619
3284
|
size: e.size,
|
|
2620
3285
|
file_size: e.size,
|
|
2621
3286
|
mime_type: e.mime || '',
|
|
3287
|
+
/* The date the Trash's date column prints and sorts by — WHEN it was
|
|
3288
|
+
deleted. A trashed row has no modification date to show, and the
|
|
3289
|
+
column read "—" for every row. */
|
|
3290
|
+
last_modified: Date.parse(e.deleted_at) || undefined,
|
|
2622
3291
|
extra_metadata: { deleted_at: e.deleted_at, ttl_days: e.ttl_days ?? null },
|
|
2623
3292
|
}) as unknown as FileNode,
|
|
2624
3293
|
);
|
|
@@ -2858,7 +3527,12 @@ watch(currentPath, (p) => {
|
|
|
2858
3527
|
// Let a host force a soft re-fetch of the current folder (reusing the existing
|
|
2859
3528
|
// list-fetch) — used by the realtime layer to refresh on live change events
|
|
2860
3529
|
// without a full component remount.
|
|
2861
|
-
defineExpose({
|
|
3530
|
+
defineExpose({
|
|
3531
|
+
reload: () => load(),
|
|
3532
|
+
/* App plugins — a notification's `target.open` lands here (see the
|
|
3533
|
+
function's own note for why this is a method and not a prop). */
|
|
3534
|
+
openAppTarget,
|
|
3535
|
+
});
|
|
2862
3536
|
|
|
2863
3537
|
onMounted(async () => {
|
|
2864
3538
|
// Eagerly start fetching Monaco — the user doesn't pay for it
|
|
@@ -2904,16 +3578,10 @@ onMounted(async () => {
|
|
|
2904
3578
|
if (api.endpoints.opsList) {
|
|
2905
3579
|
pendingOps.startPolling();
|
|
2906
3580
|
}
|
|
2907
|
-
|
|
2908
|
-
|
|
2909
|
-
|
|
2910
|
-
|
|
2911
|
-
capabilitiesData.value = cap;
|
|
2912
|
-
})
|
|
2913
|
-
.catch(() => {
|
|
2914
|
-
/* swallow — `onlyoffice_url` falls back to null */
|
|
2915
|
-
});
|
|
2916
|
-
}
|
|
3581
|
+
// ⚠ Not awaited here — the listing must not wait on it — but tracked, so a
|
|
3582
|
+
// caller that genuinely needs the answer (openAppTarget) can await the same
|
|
3583
|
+
// promise instead of reading a ref that may not be filled yet.
|
|
3584
|
+
void loadCapabilities();
|
|
2917
3585
|
});
|
|
2918
3586
|
|
|
2919
3587
|
// --------------------------------------------------------------------
|
|
@@ -3136,6 +3804,29 @@ function openNode(n: FileNode) {
|
|
|
3136
3804
|
void loadTrash();
|
|
3137
3805
|
return;
|
|
3138
3806
|
}
|
|
3807
|
+
/* ⚠⚠ issue #34 — A SYMLINK THE SERVER WILL NOT FOLLOW IS REFUSED HERE,
|
|
3808
|
+
OUT LOUD, and this is the ONE funnel every way of opening a row passes
|
|
3809
|
+
through (double-click, Enter, the menu's Open, the command palette, a
|
|
3810
|
+
deep link's auto-open), so the answer is the same everywhere.
|
|
3811
|
+
|
|
3812
|
+
The reported bug was the silence: an out-of-root link arrived looking like
|
|
3813
|
+
an ordinary 0-byte file, and clicking it did nothing at all. Letting the
|
|
3814
|
+
request go instead is barely an improvement — the driver's containment
|
|
3815
|
+
refusal surfaces as a generic failure, which reads as "filex is broken"
|
|
3816
|
+
rather than "this boundary is a setting somebody chose". So: no request,
|
|
3817
|
+
and a toast carrying the sentence from lib/symlink, which is the same
|
|
3818
|
+
sentence the row's badge and the details panel already show. Nothing is
|
|
3819
|
+
lost by not asking; the server would refuse it too, and only this side
|
|
3820
|
+
knows the wording that names the setting. */
|
|
3821
|
+
const link = linkWordsFor(n, { t });
|
|
3822
|
+
if (link) {
|
|
3823
|
+
/* ⚠ 8 seconds, not flashToast's 2.5: this is a two-clause explanation
|
|
3824
|
+
that names a setting, and a sentence nobody finishes reading is the
|
|
3825
|
+
silence again with extra steps. Same budget undoToast uses for the
|
|
3826
|
+
other toast people are expected to act on. */
|
|
3827
|
+
showToast({ message: link.why }, 8000);
|
|
3828
|
+
return;
|
|
3829
|
+
}
|
|
3139
3830
|
if (n.type === 'dir') {
|
|
3140
3831
|
// Multi-storage virtual rows have a bare path (`s3-test`); pass
|
|
3141
3832
|
// them straight to load() which will treat them as the wire form
|
|
@@ -3710,7 +4401,7 @@ function selectionActionList(sel: FileNode[]): ContextAction[] {
|
|
|
3710
4401
|
];
|
|
3711
4402
|
}
|
|
3712
4403
|
const isFile = single && sel[0]?.type === 'file';
|
|
3713
|
-
const tagsLabel =
|
|
4404
|
+
const tagsLabel = t('ctx.tags_menu');
|
|
3714
4405
|
const singleHasId = single && typeof sel[0]?.id === 'number';
|
|
3715
4406
|
/* yildiz:s1 */
|
|
3716
4407
|
const canStar = starableNodes(sel).length > 0;
|
|
@@ -3727,9 +4418,27 @@ function selectionActionList(sel: FileNode[]): ContextAction[] {
|
|
|
3727
4418
|
// Unified "Paylaş / İzinler" popup carries the public share link, per-user
|
|
3728
4419
|
// permissions AND the folder-only "Dosya İste" (file-drop) tab — the user
|
|
3729
4420
|
// picks the action from inside the modal, so there's no separate button.
|
|
3730
|
-
const accessLabel =
|
|
4421
|
+
const accessLabel = t('shortcuts.share'); // the same words the shortcut list uses
|
|
4422
|
+
/* ⚠ "Open" on an office document IS "open in ONLYOFFICE": every path it
|
|
4423
|
+
* takes (the standalone editor tab, the desktop's host window, the in-page
|
|
4424
|
+
* modal) ends at the document server. With none configured it used to open
|
|
4425
|
+
* a tab that printed `Config fetch 503: {"error":"onlyoffice not
|
|
4426
|
+
* configured"}`. The owner's rule (2026-09-21): an administrator sees it
|
|
4427
|
+
* greyed with where to set it up; everybody else is not offered it. Preview
|
|
4428
|
+
* stays — it says the same thing in words and offers the download. */
|
|
4429
|
+
const openExt = String(sel[0]?.extension ?? '').toLowerCase();
|
|
4430
|
+
const openGate = !isFile
|
|
4431
|
+
? {}
|
|
4432
|
+
: isOfficeExt(openExt)
|
|
4433
|
+
? gateOnService(!!effectiveOnlyOfficeBase.value, callerAdmin.value, t('ctx.needs_onlyoffice'))
|
|
4434
|
+
: openExt === 'drawio' || openExt === 'dio'
|
|
4435
|
+
? gateOnService(!!effectiveDrawioUrl.value, callerAdmin.value, t('ctx.needs_drawio'))
|
|
4436
|
+
: {};
|
|
4437
|
+
/* The legacy converter: configured-and-healthy is the only state in which
|
|
4438
|
+
it is offered as working, and never beside the Convert app. */
|
|
4439
|
+
const convertGate = legacyConvert.value;
|
|
3731
4440
|
return [
|
|
3732
|
-
{ key: 'open', label: t('ctx.open'), hidden: !single },
|
|
4441
|
+
{ key: 'open', label: t('ctx.open'), ...openGate, hidden: !single || openGate.hidden === true },
|
|
3733
4442
|
{ key: 'open-tab', label: t('ctx.open_new_tab'), hidden: !single || sel[0]?.type !== 'dir' } /* wiring:d1 — open the folder in a new tab */,
|
|
3734
4443
|
{ key: 'preview', label: t('ctx.preview'), hidden: !single, disabled: !isFile },
|
|
3735
4444
|
/* tasi:m1 — Download works on ANY selection now. It used to disappear the
|
|
@@ -3741,7 +4450,13 @@ function selectionActionList(sel: FileNode[]): ContextAction[] {
|
|
|
3741
4450
|
decrypted IN THE BROWSER, one file at a time, and the server has no
|
|
3742
4451
|
plaintext to zip. */
|
|
3743
4452
|
{ key: 'download', label: t('ctx.download'), hidden: !any || (e2eActive.value && !single), disabled: !any },
|
|
3744
|
-
{
|
|
4453
|
+
{
|
|
4454
|
+
key: 'convert',
|
|
4455
|
+
label: t('ctx.convert'),
|
|
4456
|
+
title: convertGate.title,
|
|
4457
|
+
hidden: !single || convertGate.hidden === true || !w || e2eActive.value /* wiring:e2 — convert is meaningless on ciphertext */,
|
|
4458
|
+
disabled: !isFile || convertGate.disabled === true,
|
|
4459
|
+
},
|
|
3745
4460
|
/* tasi:m1 — VISIBLE and grey above a multi-selection, not gone. Sharing
|
|
3746
4461
|
really is one item at a time (a share link addresses one node), and the
|
|
3747
4462
|
row now says so in its tooltip; vanishing taught the reader that filex
|
|
@@ -3790,6 +4505,10 @@ function selectionActionList(sel: FileNode[]): ContextAction[] {
|
|
|
3790
4505
|
hidden: !canStar,
|
|
3791
4506
|
},
|
|
3792
4507
|
{ key: 'tags', label: tagsLabel, hidden: !singleHasId, disabled: !singleHasId },
|
|
4508
|
+
/* App plugins — one row per action whose `applies` rule accepts the
|
|
4509
|
+
selection, under its own `sep-plugins` divider; `[]` when the feature
|
|
4510
|
+
is off, in the trash, or inside an encrypted folder (lib/pluginMenu). */
|
|
4511
|
+
...pluginActionRows(sel),
|
|
3793
4512
|
...keepActionsFor(sel),
|
|
3794
4513
|
{ divider: true, key: 'sep2', label: '', hidden: !w },
|
|
3795
4514
|
{ key: 'delete', label: t('ctx.delete'), danger: true, hidden: !any || !w, disabled: !any },
|
|
@@ -3860,6 +4579,12 @@ function actingInPane(): boolean {
|
|
|
3860
4579
|
}
|
|
3861
4580
|
|
|
3862
4581
|
async function dispatchItemAction(key: string, targets: FileNode[]) {
|
|
4582
|
+
/* App plugins — `plugin:<plugin>/<action>` rows are not in the switch: the
|
|
4583
|
+
set is whatever the server listed, so they are resolved by prefix. */
|
|
4584
|
+
if (isPluginActionKey(key)) {
|
|
4585
|
+
await onPluginAction(key, targets);
|
|
4586
|
+
return;
|
|
4587
|
+
}
|
|
3863
4588
|
switch (key) {
|
|
3864
4589
|
/* gorunum:v1 — the selection bar's × . It used to be delivered by
|
|
3865
4590
|
* synthesising a click on the listing's background, because nothing here
|
|
@@ -3939,7 +4664,7 @@ async function dispatchItemAction(key: string, targets: FileNode[]) {
|
|
|
3939
4664
|
if (targets[0] && typeof targets[0].id === 'number') {
|
|
3940
4665
|
const id = targets[0].id;
|
|
3941
4666
|
navigator.clipboard?.writeText(String(id)).then(
|
|
3942
|
-
() => flashToast(
|
|
4667
|
+
() => flashToast(t('toast.node_id_copied', { id })),
|
|
3943
4668
|
() => flashToast(`#${id}`),
|
|
3944
4669
|
);
|
|
3945
4670
|
}
|
|
@@ -4060,7 +4785,7 @@ async function paste() {
|
|
|
4060
4785
|
}
|
|
4061
4786
|
clipboard.value = { mode: null, items: [], sourcePath: null };
|
|
4062
4787
|
} catch (err) {
|
|
4063
|
-
|
|
4788
|
+
reportMutationError(err, { op: 'paste' });
|
|
4064
4789
|
}
|
|
4065
4790
|
}
|
|
4066
4791
|
|
|
@@ -4069,7 +4794,7 @@ async function duplicate(n: FileNode) {
|
|
|
4069
4794
|
const { op } = await api.copy([n.path], qualify(currentPath.value));
|
|
4070
4795
|
pendingOps.register(op);
|
|
4071
4796
|
} catch (err) {
|
|
4072
|
-
|
|
4797
|
+
reportMutationError(err, { op: 'duplicate' });
|
|
4073
4798
|
}
|
|
4074
4799
|
}
|
|
4075
4800
|
|
|
@@ -4204,6 +4929,12 @@ async function submitNewFolder(name: string) {
|
|
|
4204
4929
|
async function submitRename(name: string) {
|
|
4205
4930
|
const target = renameTarget.value;
|
|
4206
4931
|
if (!target) return;
|
|
4932
|
+
/* ⚠ Cleared BEFORE the attempt, not only when the dialog opens. The dialog
|
|
4933
|
+
* drops the line as soon as the name is edited and re-shows it when this ref
|
|
4934
|
+
* CHANGES — so retrying the same taken name, which fails with the identical
|
|
4935
|
+
* sentence, left the ref untouched, nothing re-rendered, and Save looked like
|
|
4936
|
+
* it did nothing at all. */
|
|
4937
|
+
renameError.value = null;
|
|
4207
4938
|
const inPane = mutationInPane.value; /* ui-fix — rename from the side pane */
|
|
4208
4939
|
try {
|
|
4209
4940
|
const dirWire = inPane ? qualify(splitPaneRef.value?.getPath() ?? '') : qualify(currentPath.value);
|
|
@@ -4222,7 +4953,11 @@ async function submitRename(name: string) {
|
|
|
4222
4953
|
});
|
|
4223
4954
|
}
|
|
4224
4955
|
} catch (err) {
|
|
4225
|
-
|
|
4956
|
+
const e = err as Error & { status?: number };
|
|
4957
|
+
// 409 is the server refusing to replace what already has the name
|
|
4958
|
+
// (NAME_TAKEN). Everything else still says what went wrong, in the dialog.
|
|
4959
|
+
renameError.value = e.status === 409 ? t('newdoc.err.exists', { name }) : e.message || String(err);
|
|
4960
|
+
reportMutationError(err, { op: 'rename' });
|
|
4226
4961
|
}
|
|
4227
4962
|
}
|
|
4228
4963
|
|
|
@@ -4275,17 +5010,19 @@ async function confirmDelete() {
|
|
|
4275
5010
|
if (inPane) void splitPaneRef.value?.reload();
|
|
4276
5011
|
else selection.clear();
|
|
4277
5012
|
} catch (err) {
|
|
4278
|
-
|
|
5013
|
+
reportMutationError(err, { op: 'delete' });
|
|
4279
5014
|
}
|
|
4280
5015
|
}
|
|
4281
5016
|
|
|
4282
5017
|
function openConvert(n: FileNode) {
|
|
5018
|
+
// A shortcut or a stale menu must not open what the rule withholds.
|
|
5019
|
+
if (!legacyConvertUrl.value) return;
|
|
4283
5020
|
convertTarget.value = n;
|
|
4284
5021
|
showConvert.value = true;
|
|
4285
5022
|
}
|
|
4286
5023
|
|
|
4287
5024
|
function onConvertDone(name: string) {
|
|
4288
|
-
flashToast(
|
|
5025
|
+
flashToast(t('toast.converted_to', { name }));
|
|
4289
5026
|
void load();
|
|
4290
5027
|
}
|
|
4291
5028
|
|
|
@@ -4294,7 +5031,7 @@ function onConvertDone(name: string) {
|
|
|
4294
5031
|
|
|
4295
5032
|
function triggerUpload() {
|
|
4296
5033
|
if (!canWriteHere.value) {
|
|
4297
|
-
flashToast(
|
|
5034
|
+
flashToast(t('toast.read_only_here'));
|
|
4298
5035
|
return;
|
|
4299
5036
|
}
|
|
4300
5037
|
fileInputEl.value?.click();
|
|
@@ -4570,7 +5307,7 @@ function onDropUpload(ev: DragEvent) {
|
|
|
4570
5307
|
dragOver.value = false;
|
|
4571
5308
|
// RBAC: block drag-drop upload where the user can't write.
|
|
4572
5309
|
if (!canWriteHere.value) {
|
|
4573
|
-
flashToast(
|
|
5310
|
+
flashToast(t('toast.read_only_here'));
|
|
4574
5311
|
return;
|
|
4575
5312
|
}
|
|
4576
5313
|
const list = ev.dataTransfer?.files ? Array.from(ev.dataTransfer.files) : [];
|
|
@@ -4821,7 +5558,7 @@ async function moveSourcesAsync(sources: string[], targetDir: string, opLabel: s
|
|
|
4821
5558
|
}
|
|
4822
5559
|
selection.clear();
|
|
4823
5560
|
} catch (err) {
|
|
4824
|
-
|
|
5561
|
+
reportMutationError(err, { op: opLabel, targetDir });
|
|
4825
5562
|
}
|
|
4826
5563
|
}
|
|
4827
5564
|
|
|
@@ -4956,6 +5693,12 @@ const { themeMode: themeModePref, setThemeMode: setThemeModePref } = useThemeMod
|
|
|
4956
5693
|
const themeMode = computed<ThemeMode>(() =>
|
|
4957
5694
|
themeModePref.value === 'host' ? props.config.theme || 'auto' : themeModePref.value,
|
|
4958
5695
|
);
|
|
5696
|
+
/* tablo:t3 — every table under the explorer (its own listing, the connection
|
|
5697
|
+
* panels, an archive's or a spreadsheet's preview, an app's list) is the one
|
|
5698
|
+
* DataTable, and its column menu and Actions menu teleport to <body>: they
|
|
5699
|
+
* learn the language and the mode here, once, instead of from a prop each
|
|
5700
|
+
* surface would have to remember to pass (lib/tableEnv). */
|
|
5701
|
+
provideTableEnv({ locale, theme: themeMode });
|
|
4959
5702
|
// Resolved mode: an explicit choice wins, otherwise the OS preference — the
|
|
4960
5703
|
// same logic variables.css encodes in CSS. The inline root variables beat every
|
|
4961
5704
|
// stylesheet rule, so they must track this resolution at runtime.
|
|
@@ -5094,23 +5837,15 @@ function retryUploadJob(job: UploadJob) {
|
|
|
5094
5837
|
}
|
|
5095
5838
|
/* /wiring:c3 */
|
|
5096
5839
|
/* === wiring:c4 — onboarding coach-mark tour ===
|
|
5097
|
-
*
|
|
5098
|
-
*
|
|
5099
|
-
*
|
|
5840
|
+
* Offered to a PERSON once, never once per mount (lib/tour): the first mount
|
|
5841
|
+
* that finds neither this browser's flag nor the account's opens it a moment
|
|
5842
|
+
* later and records it right away, so a second tab, a remount or another
|
|
5843
|
+
* device does not open it again. "Turu tekrar başlat" re-opens it any time.
|
|
5100
5844
|
* Restart arrives as a bubbled `fe:tour-restart` CustomEvent from the
|
|
5101
5845
|
* Toolbar overflow menu, so no extra prop/emit threading through the
|
|
5102
5846
|
* shared component tags is needed. */
|
|
5103
|
-
const TOUR_LS_KEY = 'filex.tourDone';
|
|
5104
5847
|
const showTour = ref(false);
|
|
5105
|
-
let
|
|
5106
|
-
|
|
5107
|
-
function tourAlreadyDone(): boolean {
|
|
5108
|
-
try {
|
|
5109
|
-
return localStorage.getItem(TOUR_LS_KEY) === '1';
|
|
5110
|
-
} catch {
|
|
5111
|
-
return true; // no storage → never auto-nag
|
|
5112
|
-
}
|
|
5113
|
-
}
|
|
5848
|
+
let cancelTourOffer: (() => void) | undefined;
|
|
5114
5849
|
|
|
5115
5850
|
function startTour() {
|
|
5116
5851
|
showTour.value = true;
|
|
@@ -5118,11 +5853,9 @@ function startTour() {
|
|
|
5118
5853
|
|
|
5119
5854
|
function onTourClose() {
|
|
5120
5855
|
showTour.value = false;
|
|
5121
|
-
|
|
5122
|
-
|
|
5123
|
-
|
|
5124
|
-
/* private mode / quota */
|
|
5125
|
-
}
|
|
5856
|
+
// Already recorded when it was offered; recorded again for a restart that
|
|
5857
|
+
// somebody opened on a browser where it had never been offered.
|
|
5858
|
+
markTourSeen();
|
|
5126
5859
|
rootEl.value?.focus();
|
|
5127
5860
|
}
|
|
5128
5861
|
|
|
@@ -5132,14 +5865,12 @@ function onTourRestartEvent() {
|
|
|
5132
5865
|
|
|
5133
5866
|
onMounted(() => {
|
|
5134
5867
|
rootEl.value?.addEventListener('fe:tour-restart', onTourRestartEvent);
|
|
5135
|
-
|
|
5136
|
-
|
|
5137
|
-
|
|
5138
|
-
}, 900);
|
|
5139
|
-
}
|
|
5868
|
+
cancelTourOffer = offerTourOnce(() => {
|
|
5869
|
+
if (!showTour.value) startTour();
|
|
5870
|
+
});
|
|
5140
5871
|
});
|
|
5141
5872
|
onBeforeUnmount(() => {
|
|
5142
|
-
|
|
5873
|
+
cancelTourOffer?.();
|
|
5143
5874
|
rootEl.value?.removeEventListener('fe:tour-restart', onTourRestartEvent);
|
|
5144
5875
|
});
|
|
5145
5876
|
/* === /wiring:c4 === */
|
|
@@ -5228,7 +5959,7 @@ function applyTabLocation(tb: TabState) {
|
|
|
5228
5959
|
* onto one, so the tab's copy is redundant and is ignored. With it off,
|
|
5229
5960
|
* nothing here changes at all and tabs keep exactly the behaviour they had.
|
|
5230
5961
|
*/
|
|
5231
|
-
if (!
|
|
5962
|
+
if (!folderMemoryOn.value && tb.viewMode && tb.viewMode !== viewMode.value) {
|
|
5232
5963
|
viewMode.value = tb.viewMode;
|
|
5233
5964
|
}
|
|
5234
5965
|
/* ⚠ AFTER the load, not before. `load()` is async, so `currentPath` — and
|
|
@@ -5684,8 +6415,11 @@ async function transferItems(
|
|
|
5684
6415
|
// "cross-storage is not supported" text here; it IS supported now, so
|
|
5685
6416
|
// that text would mask the real cause (permissions, a read-only storage,
|
|
5686
6417
|
// a full quota).
|
|
5687
|
-
|
|
5688
|
-
|
|
6418
|
+
// ⚠ `reportMutationError` already flashed the lock sentence when the
|
|
6419
|
+
// server answered 423; the generic message would then overwrite it.
|
|
6420
|
+
const held = lockedRefusal(err);
|
|
6421
|
+
reportMutationError(err, { op: 'transfer', targetWire });
|
|
6422
|
+
if (!held) flashToast((err as Error).message);
|
|
5689
6423
|
return;
|
|
5690
6424
|
}
|
|
5691
6425
|
} else {
|
|
@@ -6253,6 +6987,7 @@ function closeRecoveryKey() {
|
|
|
6253
6987
|
<div
|
|
6254
6988
|
ref="rootEl"
|
|
6255
6989
|
class="fe"
|
|
6990
|
+
:dir="dir"
|
|
6256
6991
|
:class="{
|
|
6257
6992
|
'fe--theme-light': themeMode === 'light',
|
|
6258
6993
|
'fe--theme-dark': themeMode === 'dark',
|
|
@@ -6290,7 +7025,7 @@ function closeRecoveryKey() {
|
|
|
6290
7025
|
bar's position can never describe two different panes. */"
|
|
6291
7026
|
:selection-pane="activePaneId /* pane:p1 — which half the bar mounts into */"
|
|
6292
7027
|
:paste-enabled="!!clipboard.mode"
|
|
6293
|
-
:convert-enabled="!!
|
|
7028
|
+
:convert-enabled="!!legacyConvertUrl"
|
|
6294
7029
|
:can-go-up="canGoUp"
|
|
6295
7030
|
:at-virtual-root="atVirtualRoot"
|
|
6296
7031
|
:can-write="canWriteHere"
|
|
@@ -6358,6 +7093,7 @@ function closeRecoveryKey() {
|
|
|
6358
7093
|
:narrow="isNarrow"
|
|
6359
7094
|
:active-view="navView"
|
|
6360
7095
|
:active-tag="navTag"
|
|
7096
|
+
:active-tag-kind="navTagKind"
|
|
6361
7097
|
:tags="navTags"
|
|
6362
7098
|
:tags-loaded="navTagsLoaded"
|
|
6363
7099
|
:active-storage="adapter"
|
|
@@ -6365,6 +7101,7 @@ function closeRecoveryKey() {
|
|
|
6365
7101
|
:shared-storages="sharedStorageNames"
|
|
6366
7102
|
:trash-visible="config.trashVisible !== false"
|
|
6367
7103
|
:show-connections="connectionsEnabled"
|
|
7104
|
+
:show-my-shares="mySharesEnabled /* paylas:m1 — off unless the host has the page */"
|
|
6368
7105
|
:show-identity-surfaces="identitySurfaces"
|
|
6369
7106
|
:can-write="canWriteHere && !atVirtualRoot && !trashActive"
|
|
6370
7107
|
:locale="locale"
|
|
@@ -6383,6 +7120,9 @@ function closeRecoveryKey() {
|
|
|
6383
7120
|
@new-folder="showNewFolder = true"
|
|
6384
7121
|
@open-connections="openConnections"
|
|
6385
7122
|
@open-tokens="openTokens"
|
|
7123
|
+
@open-my-shares="emit('open-my-shares') /* paylas:m1 — the host owns the page */"
|
|
7124
|
+
:apps="pluginHomeApps /* App plugins — the home views */"
|
|
7125
|
+
@open-app="openPluginHome"
|
|
6386
7126
|
/>
|
|
6387
7127
|
<!-- The drawer's scrim. A button, not a div: dismissing an overlay by
|
|
6388
7128
|
clicking beside it has to be reachable from the keyboard too. -->
|
|
@@ -6467,6 +7207,7 @@ function closeRecoveryKey() {
|
|
|
6467
7207
|
:show-view-switcher="navView !== 'home'"
|
|
6468
7208
|
:folder-key="currentFolderKey"
|
|
6469
7209
|
:show-parent-path="!!searchQuery || crossFolderView"
|
|
7210
|
+
:trash="trashActive"
|
|
6470
7211
|
:clipped="clippedPaths"
|
|
6471
7212
|
:extra-filters="advFilters"
|
|
6472
7213
|
:can-write="canWriteHere && !trashActive"
|
|
@@ -6520,6 +7261,16 @@ function closeRecoveryKey() {
|
|
|
6520
7261
|
|
|
6521
7262
|
<!-- Strips that describe the WINDOW's state rather than the listing. -->
|
|
6522
7263
|
<template #banners>
|
|
7264
|
+
<!-- A search that matched more than it returned: the index filled its page,
|
|
7265
|
+
or the index-less fallback filled its window of names. Without this a
|
|
7266
|
+
cut list reads as the whole answer. No count — the number that came
|
|
7267
|
+
back is the window's, not the matches'. -->
|
|
7268
|
+
<div
|
|
7269
|
+
v-if="searchQuery && searchTruncated && !navView && !trashActive"
|
|
7270
|
+
class="fe-search-cut"
|
|
7271
|
+
role="status"
|
|
7272
|
+
>{{ t('search.truncated') }}</div>
|
|
7273
|
+
|
|
6523
7274
|
<!-- Live presence: who else is viewing this folder (empty → nothing shown).
|
|
6524
7275
|
When the live socket is unavailable the same strip carries a small
|
|
6525
7276
|
degraded-connection badge instead (presence is empty in fallback);
|
|
@@ -6650,6 +7401,9 @@ function closeRecoveryKey() {
|
|
|
6650
7401
|
view rather than of the rows. -->
|
|
6651
7402
|
<div v-if="trashMode && !loading" class="fe-trashbar">
|
|
6652
7403
|
<p class="fe-trashbar__text">{{ trashBannerText }}</p>
|
|
7404
|
+
<p v-if="trashEmptyProgress" class="fe-trashbar__progress" role="status" aria-live="polite">
|
|
7405
|
+
{{ trashEmptyProgress }}
|
|
7406
|
+
</p>
|
|
6653
7407
|
<!-- Offered only when the SERVER has said this caller may purge. The
|
|
6654
7408
|
backend refuses regardless of what we draw; this is so nobody is
|
|
6655
7409
|
handed a button that always fails. -->
|
|
@@ -6993,6 +7747,7 @@ function closeRecoveryKey() {
|
|
|
6993
7747
|
not what is ticked in front of you, and it lives here. */"
|
|
6994
7748
|
:locale="locale"
|
|
6995
7749
|
:narrow="isNarrow"
|
|
7750
|
+
:caller-admin="callerAdmin /* the Node ID row is an administrator's (InspectorPanel) */"
|
|
6996
7751
|
:thumb-src="thumbs.src"
|
|
6997
7752
|
:api-base="props.config.apiBase ?? '' /* etiket:t1 — the details panel's
|
|
6998
7753
|
Tags section mounts the same TagPicker the context menu opens, and
|
|
@@ -7005,9 +7760,11 @@ function closeRecoveryKey() {
|
|
|
7005
7760
|
API breaks without it. */"
|
|
7006
7761
|
:auth-headers="() => buildAuthHeaders()"
|
|
7007
7762
|
:auth-credentials="api.credentialsMode()"
|
|
7008
|
-
|
|
7009
|
-
|
|
7010
|
-
|
|
7763
|
+
:plugin-views="pluginInspectorViews /* App plugins — inspector sections */"
|
|
7764
|
+
:theme="themeMode"
|
|
7765
|
+
:storages="(props.config.storages ?? []).map((st) => st.name)"
|
|
7766
|
+
@plugin-op="(op) => onPluginOpQueued(op)"
|
|
7767
|
+
@plugin-open="(p) => onSurfaceOpen(p.plugin, p.req)"
|
|
7011
7768
|
@open-tag="openTagView /* etiket:t1 — a tag chip in this panel is a door to
|
|
7012
7769
|
that tag's view, the same door the navigation panel's Tags section
|
|
7013
7770
|
opens. */"
|
|
@@ -7041,10 +7798,8 @@ function closeRecoveryKey() {
|
|
|
7041
7798
|
<ConnectionsPanel
|
|
7042
7799
|
v-if="showConnections"
|
|
7043
7800
|
:config="config"
|
|
7044
|
-
initial-tab="connect"
|
|
7045
7801
|
closable
|
|
7046
7802
|
@close="closeOverlays"
|
|
7047
|
-
@changed="() => load()"
|
|
7048
7803
|
@error="onConnectionsError"
|
|
7049
7804
|
/>
|
|
7050
7805
|
<template v-else>
|
|
@@ -7097,7 +7852,11 @@ function closeRecoveryKey() {
|
|
|
7097
7852
|
:ops="pendingOps.ops.value"
|
|
7098
7853
|
:locale="locale"
|
|
7099
7854
|
:center="opsCenter"
|
|
7855
|
+
:output-mode-of="pluginOutputModeOf"
|
|
7856
|
+
:caller-admin="callerAdmin"
|
|
7100
7857
|
@dismiss="(id) => pendingOps.dismiss(id)"
|
|
7858
|
+
@cancel="onCancelPendingOp"
|
|
7859
|
+
@open="onOpenOpOutput"
|
|
7101
7860
|
/>
|
|
7102
7861
|
|
|
7103
7862
|
<OperationsCenter
|
|
@@ -7165,6 +7924,7 @@ function closeRecoveryKey() {
|
|
|
7165
7924
|
typecheck. */"
|
|
7166
7925
|
:only-office-ready="!!effectiveOnlyOfficeBase"
|
|
7167
7926
|
:drawio-ready="!!effectiveDrawioUrl"
|
|
7927
|
+
:can-configure="callerAdmin"
|
|
7168
7928
|
@close="showNewDocument = false"
|
|
7169
7929
|
@created="onDocumentCreated"
|
|
7170
7930
|
@error="emit('error', { message: $event.message, context: { op: 'newdoc' } })"
|
|
@@ -7233,6 +7993,7 @@ function closeRecoveryKey() {
|
|
|
7233
7993
|
:open="showRename"
|
|
7234
7994
|
:locale="locale"
|
|
7235
7995
|
:current-name="renameTarget?.basename || ''"
|
|
7996
|
+
:error="renameError"
|
|
7236
7997
|
@close="showRename = false"
|
|
7237
7998
|
@submit="submitRename"
|
|
7238
7999
|
/>
|
|
@@ -7281,6 +8042,7 @@ function closeRecoveryKey() {
|
|
|
7281
8042
|
:download-url="(p) => (e2eUnlocked ? e2ePreviewSrc(p) : api.downloadUrl(p)) /* wiring:e2 */"
|
|
7282
8043
|
:only-office-base="e2eActive ? null : effectiveOnlyOfficeBase /* wiring:e2 — OO cannot open ciphertext */"
|
|
7283
8044
|
:only-office-config-endpoint="effectiveOnlyOfficeConfigEndpoint"
|
|
8045
|
+
:can-configure="callerAdmin"
|
|
7284
8046
|
:new-tab-enabled="!e2eActive /* wiring:e2 — the standalone route pulls raw bytes */"
|
|
7285
8047
|
:save-text-endpoint="e2eActive ? null : api.endpoints.saveText || null /* wiring:e2 — a plaintext save would be a leak */"
|
|
7286
8048
|
:archive-list-endpoint="api.endpoints.archiveList || null"
|
|
@@ -7306,9 +8068,39 @@ function closeRecoveryKey() {
|
|
|
7306
8068
|
@nav="onPreviewNav"
|
|
7307
8069
|
@close="showPreview = false"
|
|
7308
8070
|
/>
|
|
8071
|
+
<!-- App plugins — a `modal` view, and the manifest's confirm question. -->
|
|
8072
|
+
<PluginViewModal
|
|
8073
|
+
v-if="pluginView"
|
|
8074
|
+
:open="!!pluginView"
|
|
8075
|
+
:api="api"
|
|
8076
|
+
:locale="locale"
|
|
8077
|
+
:theme="themeMode"
|
|
8078
|
+
:plugin="pluginView.plugin"
|
|
8079
|
+
:view="pluginView.view"
|
|
8080
|
+
:surface="pluginView.surface"
|
|
8081
|
+
:path="pluginView.path"
|
|
8082
|
+
:size="pluginView.size"
|
|
8083
|
+
:storages="(props.config.storages ?? []).map((st) => st.name)"
|
|
8084
|
+
:start-at="qualify(paneIsActive ? (splitPaneRef?.getPath() ?? '') : currentPath)"
|
|
8085
|
+
@close="pluginView = null"
|
|
8086
|
+
@op="(op) => onPluginOpQueued(op)"
|
|
8087
|
+
@toast="flashToast"
|
|
8088
|
+
@open="(req) => onSurfaceOpen(pluginView?.plugin ?? '', req)"
|
|
8089
|
+
/>
|
|
8090
|
+
<PluginConfirmModal
|
|
8091
|
+
:open="!!pluginConfirm"
|
|
8092
|
+
:locale="locale"
|
|
8093
|
+
:theme="themeMode"
|
|
8094
|
+
:title="pluginConfirm ? pluginLabelOf(pluginConfirm.action.label, locale) : ''"
|
|
8095
|
+
:message="pluginConfirm ? pluginLabelOf(pluginConfirm.action.confirm, locale) : ''"
|
|
8096
|
+
:danger="pluginConfirm?.action.danger === true"
|
|
8097
|
+
@close="pluginConfirm = null"
|
|
8098
|
+
@confirm="onPluginConfirmed"
|
|
8099
|
+
/>
|
|
7309
8100
|
<ConvertModal
|
|
7310
|
-
v-if="showConvert && convertTarget &&
|
|
7311
|
-
:convert-url="
|
|
8101
|
+
v-if="showConvert && convertTarget && legacyConvertUrl"
|
|
8102
|
+
:convert-url="legacyConvertUrl"
|
|
8103
|
+
:admin-note="callerAdmin ? t('convert.legacy_admin') : ''"
|
|
7312
8104
|
:file-name="convertTarget?.basename || convertTarget?.path || ''"
|
|
7313
8105
|
:fetch-bytes="() => api.fetchArrayBuffer(convertTarget?.path ?? '')"
|
|
7314
8106
|
:upload="(f) => api.uploadMultipart(qualify(currentPath), [f]).then(() => {})"
|
|
@@ -7322,9 +8114,12 @@ function closeRecoveryKey() {
|
|
|
7322
8114
|
:path="permTarget.path"
|
|
7323
8115
|
:is-dir="permTarget.type === 'dir'"
|
|
7324
8116
|
:size="typeof permTarget.size === 'number' ? permTarget.size : undefined"
|
|
7325
|
-
:locale="locale
|
|
8117
|
+
:locale="locale"
|
|
7326
8118
|
:share-max-ttl-days="shareMaxTtlDays"
|
|
7327
8119
|
:initial-tab="permInitialTab /* surucu:d1 */"
|
|
8120
|
+
:mail-ready="capabilitiesData?.mail?.ready"
|
|
8121
|
+
:can-configure="callerAdmin"
|
|
8122
|
+
:perm="permTarget.perm || dirPerm"
|
|
7328
8123
|
@close="showPerm = false; permInitialTab = undefined"
|
|
7329
8124
|
/>
|
|
7330
8125
|
|
|
@@ -7345,10 +8140,11 @@ function closeRecoveryKey() {
|
|
|
7345
8140
|
>
|
|
7346
8141
|
<div class="fe-recents__panel" @click.stop>
|
|
7347
8142
|
<div class="fe-recents__header">
|
|
7348
|
-
<strong>{{
|
|
7349
|
-
<button class="fe-recents__close" aria-label="
|
|
8143
|
+
<strong>{{ t('recents.title') }}</strong>
|
|
8144
|
+
<button class="fe-recents__close" :aria-label="t('recents.close')" @click="showRecents = false">×</button>
|
|
7350
8145
|
</div>
|
|
7351
8146
|
<RecentlyOpened
|
|
8147
|
+
:locale="locale"
|
|
7352
8148
|
:api-base="props.config.apiBase ?? ''"
|
|
7353
8149
|
:auth-headers="() => buildAuthHeaders()"
|
|
7354
8150
|
:auth-credentials="api.credentialsMode()"
|
|
@@ -7372,9 +8168,9 @@ function closeRecoveryKey() {
|
|
|
7372
8168
|
<div class="fe-modal__card fe-modal__card--md" @click.stop>
|
|
7373
8169
|
<header class="fe-modal__head">
|
|
7374
8170
|
<h2 class="fe-modal__title">
|
|
7375
|
-
{{
|
|
8171
|
+
{{ t('tags.title') }} — {{ tagPickerNode.basename }}
|
|
7376
8172
|
</h2>
|
|
7377
|
-
<button class="fe-modal__close" aria-label="
|
|
8173
|
+
<button class="fe-modal__close" :aria-label="t('tags.close')" @click="showTagPicker = false">×</button>
|
|
7378
8174
|
</header>
|
|
7379
8175
|
<div class="fe-modal__body">
|
|
7380
8176
|
<TagPicker
|
|
@@ -7383,7 +8179,6 @@ function closeRecoveryKey() {
|
|
|
7383
8179
|
:api-base="props.config.apiBase ?? ''"
|
|
7384
8180
|
:auth-headers="() => buildAuthHeaders()"
|
|
7385
8181
|
:auth-credentials="api.credentialsMode()"
|
|
7386
|
-
@change="onNodeTagsChanged"
|
|
7387
8182
|
@open="openTagView /* etiket:t1 — the SAME door as in the details
|
|
7388
8183
|
panel; `openTagView` closes this dialog on the way, because
|
|
7389
8184
|
leaving a modal open over the view it just navigated to is
|
|
@@ -7407,6 +8202,7 @@ function closeRecoveryKey() {
|
|
|
7407
8202
|
:path-base="advPathBase"
|
|
7408
8203
|
:content-search="advContentAvailable"
|
|
7409
8204
|
:count="advSearchCount"
|
|
8205
|
+
:known-tags="advKnownTags"
|
|
7410
8206
|
@close="showAdvSearch = false"
|
|
7411
8207
|
@submit="applyAdvancedSearch"
|
|
7412
8208
|
/>
|
|
@@ -7511,6 +8307,7 @@ function closeRecoveryKey() {
|
|
|
7511
8307
|
:download-url="(p: string) => (e2eUnlocked ? e2ePreviewSrc(p) : api.downloadUrl(p)) /* wiring:e2 */"
|
|
7512
8308
|
:only-office-base="e2eActive ? null : effectiveOnlyOfficeBase /* wiring:e2 */"
|
|
7513
8309
|
:only-office-config-endpoint="effectiveOnlyOfficeConfigEndpoint"
|
|
8310
|
+
:can-configure="callerAdmin"
|
|
7514
8311
|
:auth-headers="() => buildAuthHeaders({ 'Content-Type': 'application/json' })"
|
|
7515
8312
|
:auth-credentials="api.credentialsMode()"
|
|
7516
8313
|
:drawio-url="effectiveDrawioUrl"
|
|
@@ -7535,3 +8332,8 @@ function closeRecoveryKey() {
|
|
|
7535
8332
|
|
|
7536
8333
|
<style src="./styles/variables.css"></style>
|
|
7537
8334
|
<style src="./styles/base.css"></style>
|
|
8335
|
+
<!-- The five signing faces, self-hosted. Declared here so the ONE rolled-up
|
|
8336
|
+
`style.css` every host already imports carries them; a browser fetches a
|
|
8337
|
+
`.woff2` only when text is actually drawn in that family, so a host that
|
|
8338
|
+
never opens a signing screen pays nothing. -->
|
|
8339
|
+
<style src="./styles/sign-fonts.css"></style>
|