@brftech/filex-core 0.44.1 → 0.45.1
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/dist/{ArchiveViewer-D_tJxJhJ.js → ArchiveViewer-QuNXAECN.js} +2 -2
- package/dist/{ArchiveViewer-D_tJxJhJ.js.map → ArchiveViewer-QuNXAECN.js.map} +1 -1
- package/dist/{CsvViewer-B95fArq0.js → CsvViewer-niuoz1Uk.js} +2 -2
- package/dist/{CsvViewer-B95fArq0.js.map → CsvViewer-niuoz1Uk.js.map} +1 -1
- package/dist/{DrawioViewer-BAaPMTge.js → DrawioViewer-BtqMiasr.js} +2 -2
- package/dist/{DrawioViewer-BAaPMTge.js.map → DrawioViewer-BtqMiasr.js.map} +1 -1
- package/dist/{EpubViewer-B-B7bRW7.js → EpubViewer-ERm7L1WN.js} +2 -2
- package/dist/{EpubViewer-B-B7bRW7.js.map → EpubViewer-ERm7L1WN.js.map} +1 -1
- package/dist/{IpynbViewer-COOTyOcu.js → IpynbViewer-Cmz5n0VR.js} +2 -2
- package/dist/{IpynbViewer-COOTyOcu.js.map → IpynbViewer-Cmz5n0VR.js.map} +1 -1
- package/dist/{MermaidViewer-CjVCmR3s.js → MermaidViewer-BJ5uOyhc.js} +2 -2
- package/dist/{MermaidViewer-CjVCmR3s.js.map → MermaidViewer-BJ5uOyhc.js.map} +1 -1
- package/dist/{PsdViewer-xsIYL9Gu.js → PsdViewer-DYCJF2BM.js} +2 -2
- package/dist/{PsdViewer-xsIYL9Gu.js.map → PsdViewer-DYCJF2BM.js.map} +1 -1
- package/dist/{TiffViewer-CtwnTxqL.js → TiffViewer-DbJv_0El.js} +2 -2
- package/dist/{TiffViewer-CtwnTxqL.js.map → TiffViewer-DbJv_0El.js.map} +1 -1
- package/dist/{Viewer3D-xYMwndEy.js → Viewer3D-DwZfYoO3.js} +2 -2
- package/dist/{Viewer3D-xYMwndEy.js.map → Viewer3D-DwZfYoO3.js.map} +1 -1
- package/dist/filex-core.js +298 -291
- package/dist/filex-core.umd.cjs +61 -61
- package/dist/filex-core.umd.cjs.map +1 -1
- package/dist/{index-BBToAjwL.js → index-1RaeWGFO.js} +14218 -13831
- package/dist/index-1RaeWGFO.js.map +1 -0
- package/dist/index.d.ts +253 -31
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/FileExplorer.vue +265 -99
- package/src/components/AdvancedSearch.vue +3 -3
- package/src/components/CommandPalette.vue +166 -42
- package/src/components/ConnectionsPanel.vue +3 -11
- package/src/components/SideNav.vue +16 -11
- package/src/components/plugin/PluginViewModal.vue +7 -0
- package/src/components/plugin/nodes/SurfacePdfFields.vue +16 -1
- package/src/components/public/PublicLinkPage.vue +6 -5
- package/src/composables/useFileApi.ts +32 -1
- package/src/composables/usePluginSurface.ts +12 -0
- package/src/composables/useSystemDark.ts +32 -0
- package/src/index.ts +16 -0
- package/src/lib/connectionGuides.ts +11 -15
- package/src/lib/downloadSelection.ts +34 -0
- package/src/lib/dragOut.ts +0 -0
- package/src/lib/searchHit.ts +129 -0
- package/src/lib/storageLine.ts +124 -0
- package/src/lib/webdavMount.ts +120 -0
- package/src/locales/en.ts +13 -0
- package/src/locales/tr.ts +13 -0
- package/src/styles/base.css +84 -7
- package/src/types/ExplorerConfig.ts +65 -1
- package/src/types/Plugins.ts +12 -0
- package/dist/index-BBToAjwL.js.map +0 -1
|
@@ -21,6 +21,13 @@
|
|
|
21
21
|
* look exactly like filex bugs.
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
+
import { gioUri, isPlainHttp, netUseCommand, webdavUrl, WEBCLIENT_LIMIT_COMMANDS } from './webdavMount';
|
|
25
|
+
|
|
26
|
+
// ⚠ The address forms and the OS commands live in webdavMount.ts, because the
|
|
27
|
+
// desktop app's "Mount as a drive" RUNS the same commands this page prints.
|
|
28
|
+
// Re-exported so the existing importers keep working.
|
|
29
|
+
export { isPlainHttp };
|
|
30
|
+
|
|
24
31
|
export type Translate = (key: string, vars?: Record<string, string | number>) => string;
|
|
25
32
|
|
|
26
33
|
/** Everything a guide is allowed to know about the deployment. */
|
|
@@ -131,12 +138,6 @@ export function hostOf(origin: string): string {
|
|
|
131
138
|
}
|
|
132
139
|
}
|
|
133
140
|
|
|
134
|
-
/** True when the deployment is not on TLS — several clients refuse that,
|
|
135
|
-
* and Windows refuses it silently, which is worse. */
|
|
136
|
-
export function isPlainHttp(origin: string): boolean {
|
|
137
|
-
return /^http:\/\//i.test(origin);
|
|
138
|
-
}
|
|
139
|
-
|
|
140
141
|
/**
|
|
141
142
|
* The username a client should be given. Falls back to a placeholder so a
|
|
142
143
|
* guide rendered before `/api/auth/me` answers is still readable rather
|
|
@@ -152,8 +153,7 @@ function userOf(ctx: GuideContext, t: Translate): string {
|
|
|
152
153
|
|
|
153
154
|
export const buildWebdavGuide: GuideBuilder = (ctx, t) => {
|
|
154
155
|
const origin = ctx.origin.replace(/\/+$/, '');
|
|
155
|
-
const
|
|
156
|
-
const target = ctx.storage ? `${origin}/dav/${ctx.storage}/` : root;
|
|
156
|
+
const target = webdavUrl(origin, ctx.storage);
|
|
157
157
|
const user = userOf(ctx, t);
|
|
158
158
|
const secret = t('conn.guide.secretPlaceholder');
|
|
159
159
|
|
|
@@ -180,7 +180,7 @@ export const buildWebdavGuide: GuideBuilder = (ctx, t) => {
|
|
|
180
180
|
{
|
|
181
181
|
kind: 'code',
|
|
182
182
|
caption: t('conn.guide.webdav.win.cmdCaption'),
|
|
183
|
-
code:
|
|
183
|
+
code: netUseCommand({ letter: 'Z:', url: target, user, password: secret, persistent: true }),
|
|
184
184
|
},
|
|
185
185
|
{
|
|
186
186
|
kind: 'warn',
|
|
@@ -189,11 +189,7 @@ export const buildWebdavGuide: GuideBuilder = (ctx, t) => {
|
|
|
189
189
|
{
|
|
190
190
|
kind: 'code',
|
|
191
191
|
caption: t('conn.guide.webdav.win.regCaption'),
|
|
192
|
-
code:
|
|
193
|
-
'reg add "HKLM\\SYSTEM\\CurrentControlSet\\Services\\WebClient\\Parameters" /v FileSizeLimitInBytes /t REG_DWORD /d 4294967295 /f',
|
|
194
|
-
'reg add "HKLM\\SYSTEM\\CurrentControlSet\\Services\\WebClient\\Parameters" /v FileAttributesLimitInBytes /t REG_DWORD /d 20000000 /f',
|
|
195
|
-
'net stop webclient && net start webclient',
|
|
196
|
-
].join('\n'),
|
|
192
|
+
code: WEBCLIENT_LIMIT_COMMANDS.join('\n'),
|
|
197
193
|
},
|
|
198
194
|
{ kind: 'note', text: t('conn.guide.webdav.win.https') },
|
|
199
195
|
{ kind: 'note', text: t('conn.guide.webdav.win.persist') },
|
|
@@ -228,7 +224,7 @@ export const buildWebdavGuide: GuideBuilder = (ctx, t) => {
|
|
|
228
224
|
`sudo mount -t davfs ${target} /mnt/filex`,
|
|
229
225
|
'',
|
|
230
226
|
`# ${t('conn.guide.webdav.linux.gvfsComment')}`,
|
|
231
|
-
`gio mount ${target
|
|
227
|
+
`gio mount ${gioUri(target)}`,
|
|
232
228
|
].join('\n'),
|
|
233
229
|
},
|
|
234
230
|
{
|
|
@@ -105,6 +105,40 @@ export async function requestArchive(
|
|
|
105
105
|
});
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
/**
|
|
109
|
+
* #71 — a short-lived, single-use, credential-free link to ONE file: the
|
|
110
|
+
* browser's drag-out (`DownloadURL`) for a session whose credential cannot
|
|
111
|
+
* travel on its own (a bearer — the admin SPA). The same mint as an archive,
|
|
112
|
+
* asked with `mode: "file"`, and the same `/z/<ticket>` redeem; the server
|
|
113
|
+
* streams the file as itself and re-checks the minter's reach at the drop
|
|
114
|
+
* (backend handlers/download_link.go).
|
|
115
|
+
*
|
|
116
|
+
* Resolves `null` for a server that does not know the mode. An older one
|
|
117
|
+
* ignores the field and mints a ZIP of the one file, and a zip dropped on the
|
|
118
|
+
* desktop under the file's own name is a broken file — worse than a drag that
|
|
119
|
+
* carries nothing. `ttlMs` is the link's life as the server counted it, so the
|
|
120
|
+
* caller never has to compare its clock with the server's.
|
|
121
|
+
*/
|
|
122
|
+
export async function requestFileLink(
|
|
123
|
+
api: Pick<FileApi, 'jsonFetch' | 'endpoints'>,
|
|
124
|
+
path: string,
|
|
125
|
+
): Promise<{ url: string; ttlMs: number } | null> {
|
|
126
|
+
const ticket = await api.jsonFetch<ArchiveTicket & { mode?: string; ttl_seconds?: number }>(
|
|
127
|
+
archiveTicketUrl(api.endpoints.manager),
|
|
128
|
+
{
|
|
129
|
+
method: 'POST',
|
|
130
|
+
headers: { 'Content-Type': 'application/json' },
|
|
131
|
+
body: JSON.stringify({ paths: [path], mode: 'file' }),
|
|
132
|
+
},
|
|
133
|
+
);
|
|
134
|
+
if (!ticket || ticket.mode !== 'file' || typeof ticket.url !== 'string') return null;
|
|
135
|
+
const ttl = Number(ticket.ttl_seconds);
|
|
136
|
+
return {
|
|
137
|
+
url: absoluteTicketUrl(api.endpoints.manager, ticket.url),
|
|
138
|
+
ttlMs: Number.isFinite(ttl) && ttl > 0 ? ttl * 1000 : 60_000,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
108
142
|
/**
|
|
109
143
|
* How long a download iframe is left in the document.
|
|
110
144
|
*
|
package/src/lib/dragOut.ts
CHANGED
|
Binary file
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A search hit, turned into the things the explorer can act on — task #47.
|
|
3
|
+
*
|
|
4
|
+
* `/api/files/search` answers with raw node rows, not listing rows: an
|
|
5
|
+
* in-storage relative `path`, a numeric `storage_id` and (on current servers)
|
|
6
|
+
* the drive's NAME in `storage`. Everything done with a hit — the advanced
|
|
7
|
+
* search's result rows, and the ⌘K palette's open / download / drag-out —
|
|
8
|
+
* needs the wire address a listing row carries (`name://rel`). One module
|
|
9
|
+
* builds it, so those surfaces cannot disagree about where a file is: "open
|
|
10
|
+
* works, download 404s" is what several inline copies of one rule turn into.
|
|
11
|
+
*
|
|
12
|
+
* The second half is the desktop's rail: several accounts searched at once,
|
|
13
|
+
* each hit tagged with its account, one group per account.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { GlobalSearchHit } from '../composables/useFileApi';
|
|
17
|
+
import type { SearchAccount } from '../types/ExplorerConfig';
|
|
18
|
+
import type { FileNode } from '../types/FileNode';
|
|
19
|
+
import type { DragItem } from './dragOut';
|
|
20
|
+
|
|
21
|
+
/** What the explorer knows about drives when a hit does not say its own. */
|
|
22
|
+
export interface HitDriveContext {
|
|
23
|
+
/** Drive names the host configured (`config.storages`). */
|
|
24
|
+
configured: string[];
|
|
25
|
+
/** The drive the explorer has open right now. */
|
|
26
|
+
current: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The hit's path inside its drive, without leading or trailing slashes. */
|
|
30
|
+
export function hitRelPath(hit: GlobalSearchHit): string {
|
|
31
|
+
return String(hit.path ?? '').replace(/^\/+|\/+$/g, '');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Which drive a hit lives on: the name the server sent > `storage_name`
|
|
36
|
+
* (older servers) > the only configured drive > the drive that is open.
|
|
37
|
+
* A wrong last-resort guess lands on the listing's "folder not found" state,
|
|
38
|
+
* which is a graceful dead end — not a request against the wrong file.
|
|
39
|
+
*/
|
|
40
|
+
export function hitStorageName(hit: GlobalSearchHit, ctx: HitDriveContext): string {
|
|
41
|
+
if (typeof hit.storage === 'string' && hit.storage) return hit.storage;
|
|
42
|
+
if (typeof hit.storage_name === 'string' && hit.storage_name) return hit.storage_name;
|
|
43
|
+
if (ctx.configured.length === 1 && ctx.configured[0]) return ctx.configured[0];
|
|
44
|
+
return ctx.current || '';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Map one raw search hit onto the listing shape.
|
|
49
|
+
*
|
|
50
|
+
* ⚠ The drive comes from the HIT (`describeHits` puts it there), not from the
|
|
51
|
+
* pane we happen to be standing in — a content search spans storages, so the
|
|
52
|
+
* open drive is only the right answer by accident. `storageName` stays as the
|
|
53
|
+
* fallback for a backend older than that field.
|
|
54
|
+
*
|
|
55
|
+
* (Moved here from FileExplorer's `advHitToNode` for #47, unchanged: the
|
|
56
|
+
* palette's download and drag-out need the same row the advanced search draws.)
|
|
57
|
+
*/
|
|
58
|
+
export function hitToNode(h: GlobalSearchHit, storageName: string): FileNode {
|
|
59
|
+
const rel = String(h.path ?? '').replace(/^\/+/, '');
|
|
60
|
+
const drive = typeof h.storage === 'string' && h.storage ? h.storage : storageName;
|
|
61
|
+
const name = String(h.name ?? rel.split('/').pop() ?? '');
|
|
62
|
+
const dot = name.lastIndexOf('.');
|
|
63
|
+
const mtime = typeof h.backend_mtime === 'string' ? h.backend_mtime : h.updated_at;
|
|
64
|
+
const ms = typeof mtime === 'string' ? Date.parse(mtime) : NaN;
|
|
65
|
+
return {
|
|
66
|
+
id: typeof h.id === 'number' ? h.id : undefined,
|
|
67
|
+
path: drive ? `${drive}://${rel}` : rel,
|
|
68
|
+
basename: name,
|
|
69
|
+
relativePath: rel,
|
|
70
|
+
type: h.type === 'dir' ? 'dir' : 'file',
|
|
71
|
+
extension: dot > 0 ? name.slice(dot + 1).toLowerCase() : '',
|
|
72
|
+
size: typeof h.size === 'number' ? h.size : 0,
|
|
73
|
+
// ⚠ Left UNSET when the row carries no parseable timestamp rather than
|
|
74
|
+
// defaulted to 0 or to now: `matchesModified` treats a missing timestamp
|
|
75
|
+
// as "unknown" and drops the row from a date filter, which is the honest
|
|
76
|
+
// answer. Stamping it with `Date.now()` would file every such file under
|
|
77
|
+
// "Today".
|
|
78
|
+
last_modified: Number.isNaN(ms) ? undefined : ms,
|
|
79
|
+
mime_type: typeof h.mime === 'string' ? h.mime : '',
|
|
80
|
+
/* The content snippet the hit came with, so a content match can show why
|
|
81
|
+
it matched. Undefined on name-only hits, exactly as the backend sends. */
|
|
82
|
+
snippet: typeof h.snippet === 'string' && h.snippet ? h.snippet : undefined,
|
|
83
|
+
/* Owner, so the People filter and the Owner column mean the same thing in a
|
|
84
|
+
content result as they do in a folder listing. Undefined rather than
|
|
85
|
+
guessed when the backend does not send it. */
|
|
86
|
+
owner_id: typeof h.owner_id === 'number' ? h.owner_id : undefined,
|
|
87
|
+
owner_name: typeof h.owner_name === 'string' ? h.owner_name : undefined,
|
|
88
|
+
owner_self: h.owner_self === true ? true : undefined,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The hit as a drag-out / download item (`name://rel`, its name, its kind) —
|
|
94
|
+
* or null when there is nothing to address: no path, or no drive to put it on.
|
|
95
|
+
* The same `{path, basename, type}` a listing row hands the drag-out hook.
|
|
96
|
+
*/
|
|
97
|
+
export function hitItem(hit: GlobalSearchHit, ctx: HitDriveContext): DragItem | null {
|
|
98
|
+
const drive = hitStorageName(hit, ctx);
|
|
99
|
+
if (!hitRelPath(hit) || !drive) return null;
|
|
100
|
+
const n = hitToNode(hit, drive);
|
|
101
|
+
return { path: n.path, basename: n.basename, type: n.type };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** One account's hits, in the server's order. `account` undefined = no rail. */
|
|
105
|
+
export interface HitGroup {
|
|
106
|
+
account?: SearchAccount;
|
|
107
|
+
hits: GlobalSearchHit[];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Hits → one group per account, in the order accounts first appear (the
|
|
112
|
+
* explorer lists its own account first), each capped at `limit` on its own —
|
|
113
|
+
* a busy account must not push a quieter one off the list.
|
|
114
|
+
*/
|
|
115
|
+
export function groupHitsByAccount(hits: GlobalSearchHit[], limit: number): HitGroup[] {
|
|
116
|
+
const groups: HitGroup[] = [];
|
|
117
|
+
const byKey = new Map<string, HitGroup>();
|
|
118
|
+
for (const hit of hits) {
|
|
119
|
+
const key = hit.account?.id ?? '';
|
|
120
|
+
let group = byKey.get(key);
|
|
121
|
+
if (!group) {
|
|
122
|
+
group = { account: hit.account, hits: [] };
|
|
123
|
+
byKey.set(key, group);
|
|
124
|
+
groups.push(group);
|
|
125
|
+
}
|
|
126
|
+
if (group.hits.length < limit) group.hits.push(hit);
|
|
127
|
+
}
|
|
128
|
+
return groups;
|
|
129
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* storageLine — WHICH number the storage line prints: the "Storage · 245.3 GB
|
|
3
|
+
* used" at the foot of the navigation panel (web explorer and desktop app)
|
|
4
|
+
* and the storage chip in the admin top bar.
|
|
5
|
+
*
|
|
6
|
+
* Two figures are in reach and they answer different questions:
|
|
7
|
+
*
|
|
8
|
+
* - `GET /api/files/quota/me` — the person's upload counter,
|
|
9
|
+
* `SUM(nodes.size) WHERE owner_id = me`. Only the upload path sets an
|
|
10
|
+
* owner, so every file a storage sync discovered counts for nobody;
|
|
11
|
+
* - `GET /api/files/quota/storages` — how full each drive the person can
|
|
12
|
+
* open is (RBAC-filtered, the figure Home's drive cards print).
|
|
13
|
+
*
|
|
14
|
+
* The line used to print the first for everybody, under "Storage", in the
|
|
15
|
+
* very sentence Home's card uses for the second. Measured on a production
|
|
16
|
+
* install (2026-09-25): the card said 245.3 GB, the line 523.5 MB — 228.6 GB
|
|
17
|
+
* of that drive had arrived through a sync and the rest was uploaded by
|
|
18
|
+
* eleven people.
|
|
19
|
+
*
|
|
20
|
+
* So:
|
|
21
|
+
* - a person WITH a quota sees their share against it. That ceiling is what
|
|
22
|
+
* refuses their next upload, so it is the number that matters to them;
|
|
23
|
+
* - a person WITHOUT one sees the size of the drives the panel lists — the
|
|
24
|
+
* host's figures where it sent them (the Home cards print those), the
|
|
25
|
+
* measured ones for the rest;
|
|
26
|
+
* - a figure the server does not stand behind in full is a lower bound:
|
|
27
|
+
* a drive whose catalogue does not cover it yet, or one nobody measured;
|
|
28
|
+
* - and with nothing trustworthy to say, the line says nothing (null).
|
|
29
|
+
*/
|
|
30
|
+
import { incomplete, type CatalogCoverage } from './catalogCoverage';
|
|
31
|
+
|
|
32
|
+
/** What the line says. */
|
|
33
|
+
export interface StorageLine {
|
|
34
|
+
/** The bytes it reports. */
|
|
35
|
+
used: number;
|
|
36
|
+
/** The person's ceiling; 0 when there is none. */
|
|
37
|
+
total: number;
|
|
38
|
+
/** No ceiling — `used` is the drives' size, not the person's uploads. */
|
|
39
|
+
unlimited: boolean;
|
|
40
|
+
/** `used` is a lower bound. */
|
|
41
|
+
partial: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The fields of `GET /api/files/quota/me` (quota.Snapshot) the line reads. */
|
|
45
|
+
export interface PersonUsage {
|
|
46
|
+
used_bytes: number;
|
|
47
|
+
quota_bytes: number;
|
|
48
|
+
unlimited?: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** One drive as the navigation panel holds it (`ExplorerConfig.storages`). */
|
|
52
|
+
export interface PanelDrive {
|
|
53
|
+
name: string;
|
|
54
|
+
usedBytes?: number;
|
|
55
|
+
usedPartial?: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** One row of `GET /api/files/quota/storages` (handlers.StorageUsageRow). */
|
|
59
|
+
export interface MeasuredDrive {
|
|
60
|
+
name: string;
|
|
61
|
+
used_bytes: number;
|
|
62
|
+
file_count?: number;
|
|
63
|
+
coverage?: CatalogCoverage | null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A byte count that can be added up. */
|
|
67
|
+
function bytes(n: unknown): number | null {
|
|
68
|
+
return typeof n === 'number' && Number.isFinite(n) && n >= 0 ? n : null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The person has a ceiling: a positive quota the server did not call unlimited. */
|
|
72
|
+
export function hasCeiling(p: PersonUsage | null | undefined): boolean {
|
|
73
|
+
return !!p && !p.unlimited && p.quota_bytes > 0;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Whether the line has to ask the server for drive sizes: there is no
|
|
78
|
+
* ceiling, and the host left at least one of the panel's drives unsized (the
|
|
79
|
+
* desktop app hands over names only) — or listed none at all.
|
|
80
|
+
*/
|
|
81
|
+
export function needsMeasuredDrives(
|
|
82
|
+
p: PersonUsage | null | undefined,
|
|
83
|
+
panel: readonly PanelDrive[],
|
|
84
|
+
): boolean {
|
|
85
|
+
if (!p || hasCeiling(p)) return false;
|
|
86
|
+
return panel.length === 0 || panel.some((d) => bytes(d.usedBytes) === null);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function storageLine(
|
|
90
|
+
p: PersonUsage | null | undefined,
|
|
91
|
+
panel: readonly PanelDrive[],
|
|
92
|
+
measured: readonly MeasuredDrive[] | null | undefined,
|
|
93
|
+
): StorageLine | null {
|
|
94
|
+
if (!p) return null;
|
|
95
|
+
if (hasCeiling(p)) {
|
|
96
|
+
return { used: bytes(p.used_bytes) ?? 0, total: p.quota_bytes, unlimited: false, partial: false };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const rows = new Map<string, MeasuredDrive>();
|
|
100
|
+
for (const r of measured ?? []) if (r && typeof r.name === 'string') rows.set(r.name, r);
|
|
101
|
+
// A host that lists no drives gets every drive the server measured for it.
|
|
102
|
+
const drives: readonly PanelDrive[] = panel.length ? panel : [...rows.keys()].map((name) => ({ name }));
|
|
103
|
+
|
|
104
|
+
let used = 0;
|
|
105
|
+
let counted = 0;
|
|
106
|
+
let partial = false;
|
|
107
|
+
const seen = new Set<string>();
|
|
108
|
+
for (const d of drives) {
|
|
109
|
+
if (seen.has(d.name)) continue; // one drive, one figure, however often it is listed
|
|
110
|
+
seen.add(d.name);
|
|
111
|
+
const own = bytes(d.usedBytes);
|
|
112
|
+
const row = own === null ? rows.get(d.name) : undefined;
|
|
113
|
+
const figure = own ?? bytes(row?.used_bytes);
|
|
114
|
+
if (figure === null) {
|
|
115
|
+
partial = true; // listed, but nobody could say how full it is
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
used += figure;
|
|
119
|
+
counted++;
|
|
120
|
+
if (own !== null ? d.usedPartial === true : incomplete(row?.coverage) !== null) partial = true;
|
|
121
|
+
}
|
|
122
|
+
if (!counted || used <= 0) return null;
|
|
123
|
+
return { used, total: 0, unlimited: true, partial };
|
|
124
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* webdavMount — the forms of a filex WebDAV address, and the operating
|
|
3
|
+
* systems' own commands that attach one as a drive.
|
|
4
|
+
*
|
|
5
|
+
* Two readers, one source:
|
|
6
|
+
* - the connection guide (`connectionGuides.ts`) PRINTS these for a person to
|
|
7
|
+
* copy;
|
|
8
|
+
* - the desktop app's "Mount as a drive" (`desktop/src/drive.ts`) RUNS them.
|
|
9
|
+
* Written twice, the command the guide teaches and the one the button runs
|
|
10
|
+
* would drift apart, and the person who copied the guide's version would hit
|
|
11
|
+
* a failure the button never shows — or the other way round.
|
|
12
|
+
*
|
|
13
|
+
* ⚠ No imports, and none may be added. The desktop main process and its
|
|
14
|
+
* `node --test` runner import this file by relative path (the way
|
|
15
|
+
* `web/src/lib/notificationTarget.ts` is imported), and plain node does not
|
|
16
|
+
* resolve an extensionless specifier.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** True when the deployment is not on TLS — several clients refuse that,
|
|
20
|
+
* and Windows refuses it silently, which is worse. */
|
|
21
|
+
export function isPlainHttp(origin: string): boolean {
|
|
22
|
+
return /^http:\/\//i.test(origin);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* `https://fm.example.com` (+ `docs`) → `https://fm.example.com/dav/docs/`.
|
|
27
|
+
*
|
|
28
|
+
* The storage name is NOT percent-encoded: this is the form a person reads and
|
|
29
|
+
* types, and the form `net use` and Explorer's "Map network drive" take — both
|
|
30
|
+
* encode it on the wire themselves. Consumers that need a URI (GVfs, NetFS)
|
|
31
|
+
* go through `new URL()`, which encodes it for them.
|
|
32
|
+
*/
|
|
33
|
+
export function webdavUrl(origin: string, storage?: string): string {
|
|
34
|
+
const base = origin.replace(/\/+$/, '');
|
|
35
|
+
return storage ? `${base}/dav/${storage}/` : `${base}/dav/`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The GVfs form (`gio mount`, GNOME Files, Dolphin): `https:` → `davs:`,
|
|
40
|
+
* `http:` → `dav:`. With `user`, the account goes in the URI — which is what
|
|
41
|
+
* makes `gio mount` ask for the PASSWORD only, so the one line piped to it
|
|
42
|
+
* lands in the right prompt (gvfs adds the username prompt only when the URI
|
|
43
|
+
* carries none).
|
|
44
|
+
*/
|
|
45
|
+
export function gioUri(url: string, user?: string): string {
|
|
46
|
+
let u: URL;
|
|
47
|
+
try {
|
|
48
|
+
u = new URL(url);
|
|
49
|
+
} catch {
|
|
50
|
+
return url.replace(/^https:/i, 'davs:').replace(/^http:/i, 'dav:');
|
|
51
|
+
}
|
|
52
|
+
const scheme = u.protocol === 'http:' ? 'dav' : 'davs';
|
|
53
|
+
const auth = user ? `${encodeURIComponent(user)}@` : '';
|
|
54
|
+
return `${scheme}://${auth}${u.host}${u.pathname}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The UNC path Windows' WebDAV redirector gives the same address:
|
|
59
|
+
* `https://fm.example.com:8443/dav/My files/` → `\\fm.example.com@SSL@8443\dav\My files`.
|
|
60
|
+
*
|
|
61
|
+
* It is what `net use` lists for a mapped drive and what WNetAddConnection2
|
|
62
|
+
* takes, so it is also how a mapping is recognised as ours.
|
|
63
|
+
*/
|
|
64
|
+
export function webdavUnc(url: string): string {
|
|
65
|
+
const u = new URL(url);
|
|
66
|
+
const ssl = u.protocol === 'https:';
|
|
67
|
+
let host = u.hostname.replace(/^\[|\]$/g, '');
|
|
68
|
+
if (ssl) host += '@SSL';
|
|
69
|
+
if (u.port) host += `@${u.port}`;
|
|
70
|
+
const segments = u.pathname
|
|
71
|
+
.split('/')
|
|
72
|
+
.filter(Boolean)
|
|
73
|
+
.map((s) => decodeURIComponent(s));
|
|
74
|
+
return `\\\\${host}${segments.length ? '\\' + segments.join('\\') : ''}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface NetUseOptions {
|
|
78
|
+
/** `Z:` */
|
|
79
|
+
letter: string;
|
|
80
|
+
url: string;
|
|
81
|
+
user: string;
|
|
82
|
+
/** What goes in the password slot. The guide prints a placeholder there;
|
|
83
|
+
* nothing that runs this should ever put a real secret in an argv. */
|
|
84
|
+
password: string;
|
|
85
|
+
persistent: boolean;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** `net use` arguments (without `net`), in the order `net` wants them. */
|
|
89
|
+
export function netUseArgs(o: NetUseOptions): string[] {
|
|
90
|
+
return ['use', o.letter, o.url, `/user:${o.user}`, o.password, `/persistent:${o.persistent ? 'yes' : 'no'}`];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The same, as a line to paste into cmd.exe — the address quoted, since a
|
|
94
|
+
* storage name may carry a space. */
|
|
95
|
+
export function netUseCommand(o: NetUseOptions): string {
|
|
96
|
+
const [verb, letter, url, ...rest] = netUseArgs(o);
|
|
97
|
+
return ['net', verb, letter, `"${url}"`, ...rest].join(' ');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Detaching a mapped drive: no credential involved, safe to run as is. */
|
|
101
|
+
export function netUseDeleteArgs(letter: string): string[] {
|
|
102
|
+
return ['use', letter, '/delete', '/y'];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Windows' WebDAV client has two built-in limits that look exactly like filex
|
|
107
|
+
* bugs: transfers stop at ~47.7 MB (`FileSizeLimitInBytes`, default
|
|
108
|
+
* 50,000,000) and folders of roughly a thousand entries fail to open
|
|
109
|
+
* (`FileAttributesLimitInBytes`, default 1,000,000). These are the values the
|
|
110
|
+
* guide recommends, and the registry lines that set them (an administrator
|
|
111
|
+
* runs them; the WebClient service must restart after).
|
|
112
|
+
*/
|
|
113
|
+
export const WEBCLIENT_KEY = 'HKLM\\SYSTEM\\CurrentControlSet\\Services\\WebClient\\Parameters';
|
|
114
|
+
export const WEBCLIENT_FILE_SIZE_RECOMMENDED = 4294967295;
|
|
115
|
+
export const WEBCLIENT_ATTRIBUTES_RECOMMENDED = 20000000;
|
|
116
|
+
export const WEBCLIENT_LIMIT_COMMANDS: string[] = [
|
|
117
|
+
`reg add "${WEBCLIENT_KEY}" /v FileSizeLimitInBytes /t REG_DWORD /d ${WEBCLIENT_FILE_SIZE_RECOMMENDED} /f`,
|
|
118
|
+
`reg add "${WEBCLIENT_KEY}" /v FileAttributesLimitInBytes /t REG_DWORD /d ${WEBCLIENT_ATTRIBUTES_RECOMMENDED} /f`,
|
|
119
|
+
'net stop webclient && net start webclient',
|
|
120
|
+
];
|
package/src/locales/en.ts
CHANGED
|
@@ -1339,6 +1339,19 @@ export const en: Record<string, string> = {
|
|
|
1339
1339
|
'storages.fields.tls': 'FTPS (explicit AUTH TLS)',
|
|
1340
1340
|
'storages.fields.url': 'Base URL',
|
|
1341
1341
|
'storages.fields.user': 'User',
|
|
1342
|
+
'storages.fields.s3AttemptTimeout': 'Attempt timeout (seconds)',
|
|
1343
|
+
'storages.fields.s3MaxAttempts': 'Attempts per request',
|
|
1344
|
+
'storages.fields.s3TotalTimeout': 'Give up after (seconds)',
|
|
1345
|
+
'storages.fieldHelp.s3AttemptTimeout': 'How long one attempt may wait for a sign of life from the store: connecting, the TLS handshake, the answer after the request is sent, and each next piece of the answer. A transfer that keeps moving is never cut, however long it takes; an upload the store stops taking is cut after 60 seconds, or after this if it is longer. Copies and renames wait up to 10 minutes for the answer, because the store answers them only when the copy is done.',
|
|
1346
|
+
'storages.fieldHelp.s3MaxAttempts': 'How many times a request is tried in all when the failure can pass: a network error, a timeout, a 5xx answer, throttling. 1 turns retrying off. A refusal (403, a missing bucket, a host name that does not resolve) is never retried.',
|
|
1347
|
+
'storages.fieldHelp.s3TotalTimeout': 'No new attempt starts unless it could finish within this many seconds of the first, so an upload or a listing on a store that is down reports the outage within this time. A transfer that is under way is not cut. A request that timed out is tried again only when this is at least twice the attempt timeout.',
|
|
1348
|
+
'storages.fields.attemptTimeout': 'Attempt timeout (seconds)',
|
|
1349
|
+
'storages.fields.maxAttempts': 'Attempts per request',
|
|
1350
|
+
'storages.fields.totalTimeout': 'Give up after (seconds)',
|
|
1351
|
+
'storages.fieldHelp.attemptTimeout': 'How long one attempt may wait for a sign of life from the server: connecting, logging in, the answer to each request, and each next piece of a transfer. A transfer that keeps moving is never cut, however long it takes; an upload the server stops taking is cut after 60 seconds, or after this if it is longer.',
|
|
1352
|
+
'storages.fieldHelp.webdavAttemptTimeout': 'How long one attempt may wait for a sign of life from the server: connecting, logging in, the answer to each request, and each next piece of a transfer. A transfer that keeps moving is never cut, however long it takes; an upload the server stops taking is cut after 60 seconds, or after this if it is longer. Copies, moves and deletes wait up to 10 minutes for the answer, because the server answers them only when the work is done.',
|
|
1353
|
+
'storages.fieldHelp.maxAttempts': 'How many times a request is tried in all when the failure can pass: the connection is refused or drops, no answer comes in time, the server answers that it is unavailable (5xx). 1 turns retrying off. A refusal (a wrong password, a missing folder, a certificate that does not verify) is never retried, and neither is an upload that has started sending.',
|
|
1354
|
+
'storages.fieldHelp.totalTimeout': 'No new attempt starts unless it could finish within this many seconds of the first, so a server that is down is reported within this time. A transfer that is under way is not cut. A request that timed out is tried again only when this is at least twice the attempt timeout.',
|
|
1342
1355
|
|
|
1343
1356
|
|
|
1344
1357
|
/* === gezinti:g1 — navigation panel (SideNav) === */
|
package/src/locales/tr.ts
CHANGED
|
@@ -1329,6 +1329,19 @@ export const tr: Record<string, string> = {
|
|
|
1329
1329
|
'storages.fields.tls': 'FTPS (açık AUTH TLS)',
|
|
1330
1330
|
'storages.fields.url': 'Temel URL',
|
|
1331
1331
|
'storages.fields.user': 'Kullanıcı',
|
|
1332
|
+
'storages.fields.s3AttemptTimeout': 'Deneme zaman aşımı (saniye)',
|
|
1333
|
+
'storages.fields.s3MaxAttempts': 'İstek başına deneme sayısı',
|
|
1334
|
+
'storages.fields.s3TotalTimeout': 'Vazgeçme süresi (saniye)',
|
|
1335
|
+
'storages.fieldHelp.s3AttemptTimeout': 'Tek bir denemenin depodan bir yaşam belirtisi için bekleyebileceği süre: bağlanma, TLS el sıkışması, istek gönderildikten sonraki cevap ve cevabın her yeni parçası. İlerleyen bir aktarım, süresi ne olursa olsun hiçbir zaman kesilmez; deponun almayı bıraktığı bir yükleme 60 saniye sonra, bu değer daha uzunsa bu kadar sonra kesilir. Kopyalama ve yeniden adlandırma cevap için 10 dakikaya kadar bekler, çünkü depo bunları ancak kopya bittiğinde cevaplar.',
|
|
1336
|
+
'storages.fieldHelp.s3MaxAttempts': 'Hata geçebilecek türdeyse bir isteğin toplamda kaç kez deneneceği: ağ hatası, zaman aşımı, 5xx cevabı, hız sınırlaması. 1 yeniden denemeyi kapatır. Bir ret (403, bulunmayan bir kova, çözümlenmeyen bir sunucu adı) hiçbir zaman yeniden denenmez.',
|
|
1337
|
+
'storages.fieldHelp.s3TotalTimeout': 'İlk denemeden itibaren bu kadar saniye içinde bitemeyecek yeni bir deneme başlatılmaz; böylece çalışmayan bir depoya yükleme ya da listeleme, kesintiyi bu süre içinde bildirir. Sürmekte olan bir aktarım kesilmez. Zaman aşımına uğrayan bir istek ancak bu değer deneme zaman aşımının en az iki katıysa yeniden denenir.',
|
|
1338
|
+
'storages.fields.attemptTimeout': 'Deneme zaman aşımı (saniye)',
|
|
1339
|
+
'storages.fields.maxAttempts': 'İstek başına deneme sayısı',
|
|
1340
|
+
'storages.fields.totalTimeout': 'Vazgeçme süresi (saniye)',
|
|
1341
|
+
'storages.fieldHelp.attemptTimeout': 'Tek bir denemenin sunucudan bir yaşam belirtisi için bekleyebileceği süre: bağlanma, oturum açma, her isteğin cevabı ve bir aktarımın her yeni parçası. İlerleyen bir aktarım, süresi ne olursa olsun hiçbir zaman kesilmez; sunucunun almayı bıraktığı bir yükleme 60 saniye sonra, bu değer daha uzunsa bu kadar sonra kesilir.',
|
|
1342
|
+
'storages.fieldHelp.webdavAttemptTimeout': 'Tek bir denemenin sunucudan bir yaşam belirtisi için bekleyebileceği süre: bağlanma, oturum açma, her isteğin cevabı ve bir aktarımın her yeni parçası. İlerleyen bir aktarım, süresi ne olursa olsun hiçbir zaman kesilmez; sunucunun almayı bıraktığı bir yükleme 60 saniye sonra, bu değer daha uzunsa bu kadar sonra kesilir. Kopyalama, taşıma ve silme cevap için 10 dakikaya kadar bekler, çünkü sunucu bunları ancak iş bittiğinde cevaplar.',
|
|
1343
|
+
'storages.fieldHelp.maxAttempts': 'Hata geçebilecek türdeyse bir isteğin toplamda kaç kez deneneceği: bağlantı reddedilir ya da kopar, cevap zamanında gelmez, sunucu şu an hizmet veremediğini söyler (5xx). 1 yeniden denemeyi kapatır. Bir ret (yanlış parola, bulunmayan bir klasör, doğrulanamayan bir sertifika) hiçbir zaman yeniden denenmez; gönderilmeye başlamış bir yükleme de yeniden denenmez.',
|
|
1344
|
+
'storages.fieldHelp.totalTimeout': 'İlk denemeden itibaren bu kadar saniye içinde bitemeyecek yeni bir deneme başlatılmaz; böylece çalışmayan bir sunucu bu süre içinde bildirilir. Sürmekte olan bir aktarım kesilmez. Zaman aşımına uğrayan bir istek ancak bu değer deneme zaman aşımının en az iki katıysa yeniden denenir.',
|
|
1332
1345
|
|
|
1333
1346
|
|
|
1334
1347
|
/* === gezinti:g1 — gezinti paneli (SideNav) === */
|