@brftech/filex-core 0.30.1 → 0.32.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 +37 -2
- package/dist/filex-core.js +10571 -8278
- package/dist/filex-core.js.map +1 -1
- package/dist/filex-core.umd.cjs +95 -87
- package/dist/filex-core.umd.cjs.map +1 -1
- package/dist/index.d.ts +548 -66
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/FileExplorer.vue +1104 -26
- package/src/components/Breadcrumb.vue +4 -2
- package/src/components/CommandPalette.vue +18 -2
- package/src/components/E2eRecoveryUnlockModal.vue +193 -0
- package/src/components/EncryptedFolderModal.vue +10 -0
- package/src/components/FilterBar.vue +244 -0
- package/src/components/GalleryView.vue +48 -0
- package/src/components/GridView.vue +85 -3
- package/src/components/InspectorPanel.vue +231 -8
- package/src/components/ListView.vue +11 -1
- package/src/components/RecoveryKeyModal.vue +133 -0
- package/src/components/SecondaryPane.vue +15 -1
- package/src/components/SideNav.vue +329 -6
- package/src/components/StarButton.vue +27 -15
- package/src/components/Toolbar.vue +235 -49
- package/src/components/ViewSwitcher.vue +81 -0
- package/src/composables/useFileApi.ts +83 -0
- package/src/composables/useKeyboardShortcuts.ts +7 -0
- package/src/index.ts +33 -1
- package/src/lib/e2ecrypto.ts +716 -70
- package/src/lib/fileFilters.ts +143 -0
- package/src/lib/listing.ts +103 -1
- package/src/lib/star.ts +42 -0
- package/src/lib/tags.ts +105 -0
- package/src/locales/en.ts +154 -2
- package/src/locales/tr.ts +154 -2
- package/src/modals/PermissionsModal.vue +18 -2
- package/src/styles/base.css +743 -0
- package/src/types/ExplorerConfig.ts +53 -1
- package/src/types/FileNode.ts +23 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* surucu:d1 — the filter row's model, kept out of the components so the
|
|
3
|
+
* predicate has one definition and can be tested without a DOM.
|
|
4
|
+
*
|
|
5
|
+
* ⚠ EVERY filter here answers from a field the listing row ALREADY carries
|
|
6
|
+
* (`type`, `extension`, `mime_type`, `size`, `last_modified`). That is not a
|
|
7
|
+
* shortcut, it is the constraint: `GET /api/files/manager?action=index` reads
|
|
8
|
+
* no `mime` / `min_size` / `modified_after` / `owner` parameter — it returns
|
|
9
|
+
* the whole directory and ignores anything else you send it (handlers/manager.go
|
|
10
|
+
* `List`, the complete parameter list is `action`, `path`, `filter`, `storage`,
|
|
11
|
+
* `parent`, `cache`). A control wired to a parameter the server does not read
|
|
12
|
+
* looks like it works and quietly changes nothing, which is worse than not
|
|
13
|
+
* shipping it.
|
|
14
|
+
*
|
|
15
|
+
* Because the endpoint has no `limit`/`offset` either, the listing in hand IS
|
|
16
|
+
* the folder — so filtering it client-side is complete for the folder, not a
|
|
17
|
+
* filter over "the first page". That is exactly why these three are honest and
|
|
18
|
+
* a People/owner filter is not: `nodes.owner_id` exists for quota accounting,
|
|
19
|
+
* is nil for anything a sync discovered, and is serialized by nothing
|
|
20
|
+
* (handlers/shared.go: "There is no per-node owner").
|
|
21
|
+
*/
|
|
22
|
+
import type { FileNode } from '../types/FileNode';
|
|
23
|
+
import { iconFamilyFor, type IconFamily } from './fileIcons';
|
|
24
|
+
|
|
25
|
+
export type TypeFilter =
|
|
26
|
+
| 'any'
|
|
27
|
+
| 'folder'
|
|
28
|
+
| 'document'
|
|
29
|
+
| 'spreadsheet'
|
|
30
|
+
| 'presentation'
|
|
31
|
+
| 'pdf'
|
|
32
|
+
| 'image'
|
|
33
|
+
| 'video'
|
|
34
|
+
| 'audio'
|
|
35
|
+
| 'archive'
|
|
36
|
+
| 'code';
|
|
37
|
+
|
|
38
|
+
export type ModifiedFilter = 'any' | 'today' | '7d' | '30d' | 'year';
|
|
39
|
+
|
|
40
|
+
export type SizeFilter = 'any' | 'lt1' | '1to10' | '10to100' | 'gt100';
|
|
41
|
+
|
|
42
|
+
export interface DriveFilters {
|
|
43
|
+
type: TypeFilter;
|
|
44
|
+
modified: ModifiedFilter;
|
|
45
|
+
size: SizeFilter;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const EMPTY_FILTERS: DriveFilters = { type: 'any', modified: 'any', size: 'any' };
|
|
49
|
+
|
|
50
|
+
export function filtersActive(f: DriveFilters): boolean {
|
|
51
|
+
return f.type !== 'any' || f.modified !== 'any' || f.size !== 'any';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function activeFilterCount(f: DriveFilters): number {
|
|
55
|
+
return (f.type !== 'any' ? 1 : 0) + (f.modified !== 'any' ? 1 : 0) + (f.size !== 'any' ? 1 : 0);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Families a type choice accepts. `iconFamilyFor` is the taxonomy the icons
|
|
59
|
+
* already use, so a row's filter group and its glyph can never disagree. */
|
|
60
|
+
const TYPE_FAMILIES: Record<Exclude<TypeFilter, 'any'>, IconFamily[]> = {
|
|
61
|
+
folder: ['folder'],
|
|
62
|
+
document: ['doc', 'text'],
|
|
63
|
+
spreadsheet: ['sheet'],
|
|
64
|
+
presentation: ['slides'],
|
|
65
|
+
pdf: ['pdf'],
|
|
66
|
+
image: ['image'],
|
|
67
|
+
video: ['video'],
|
|
68
|
+
audio: ['audio'],
|
|
69
|
+
archive: ['archive'],
|
|
70
|
+
code: ['code'],
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/** MIME fallback — a file with no extension still has a `mime_type` from the
|
|
74
|
+
* backend sniffer, and "IMG_0042" with no suffix is a real thing people have. */
|
|
75
|
+
const MIME_PREFIXES: Partial<Record<Exclude<TypeFilter, 'any'>, string[]>> = {
|
|
76
|
+
image: ['image/'],
|
|
77
|
+
video: ['video/'],
|
|
78
|
+
audio: ['audio/'],
|
|
79
|
+
pdf: ['application/pdf'],
|
|
80
|
+
document: ['text/plain', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessing'],
|
|
81
|
+
spreadsheet: ['text/csv', 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheet'],
|
|
82
|
+
presentation: ['application/vnd.ms-powerpoint', 'application/vnd.openxmlformats-officedocument.presentation'],
|
|
83
|
+
archive: ['application/zip', 'application/x-tar', 'application/gzip', 'application/x-7z'],
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
function matchesType(n: FileNode, t: TypeFilter): boolean {
|
|
87
|
+
if (t === 'any') return true;
|
|
88
|
+
if (t === 'folder') return n.type === 'dir';
|
|
89
|
+
if (n.type === 'dir') return false;
|
|
90
|
+
if (TYPE_FAMILIES[t].includes(iconFamilyFor(n))) return true;
|
|
91
|
+
const mime = (n.mime_type || '').toLowerCase();
|
|
92
|
+
return !!mime && (MIME_PREFIXES[t] ?? []).some((p) => mime.startsWith(p));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** `now` is a parameter so a test can pin the clock instead of sleeping. */
|
|
96
|
+
function matchesModified(n: FileNode, f: ModifiedFilter, now: number): boolean {
|
|
97
|
+
if (f === 'any') return true;
|
|
98
|
+
const ms = typeof n.last_modified === 'number' ? n.last_modified : 0;
|
|
99
|
+
// No timestamp = no answer. Dropping the row would hide files whose driver
|
|
100
|
+
// gave us nothing; keeping it would put them in "Today". Hiding is the
|
|
101
|
+
// honest one: the row does not satisfy "modified today", it is unknown.
|
|
102
|
+
if (!ms) return false;
|
|
103
|
+
if (f === 'year') {
|
|
104
|
+
return new Date(ms).getFullYear() === new Date(now).getFullYear();
|
|
105
|
+
}
|
|
106
|
+
if (f === 'today') {
|
|
107
|
+
const start = new Date(now);
|
|
108
|
+
start.setHours(0, 0, 0, 0);
|
|
109
|
+
return ms >= start.getTime();
|
|
110
|
+
}
|
|
111
|
+
const days = f === '7d' ? 7 : 30;
|
|
112
|
+
return ms >= now - days * 86_400_000;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const MB = 1024 * 1024;
|
|
116
|
+
|
|
117
|
+
function matchesSize(n: FileNode, f: SizeFilter): boolean {
|
|
118
|
+
if (f === 'any') return true;
|
|
119
|
+
// ⚠ Folders drop out of every size choice rather than passing through. A
|
|
120
|
+
// directory row's `size` is 0 from the projector, so "under 1 MB" would
|
|
121
|
+
// otherwise list every folder in the drive — an answer that looks like a
|
|
122
|
+
// measurement and is not one.
|
|
123
|
+
if (n.type === 'dir') return false;
|
|
124
|
+
const s = typeof n.size === 'number' ? n.size : 0;
|
|
125
|
+
if (f === 'lt1') return s < MB;
|
|
126
|
+
if (f === '1to10') return s >= MB && s < 10 * MB;
|
|
127
|
+
if (f === '10to100') return s >= 10 * MB && s < 100 * MB;
|
|
128
|
+
return s >= 100 * MB;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function applyFilters(
|
|
132
|
+
files: FileNode[],
|
|
133
|
+
f: DriveFilters,
|
|
134
|
+
now: number = Date.now(),
|
|
135
|
+
): FileNode[] {
|
|
136
|
+
// No active filter → the SAME array reference, so an unfiltered explorer
|
|
137
|
+
// renders exactly what it rendered before this file existed.
|
|
138
|
+
if (!filtersActive(f)) return files;
|
|
139
|
+
return files.filter(
|
|
140
|
+
(n) =>
|
|
141
|
+
matchesType(n, f.type) && matchesModified(n, f.modified, now) && matchesSize(n, f.size),
|
|
142
|
+
);
|
|
143
|
+
}
|
package/src/lib/listing.ts
CHANGED
|
@@ -120,14 +120,61 @@ export function makeTrashRow(adapter: string): FileNode {
|
|
|
120
120
|
* Inject the virtual `.trash` row at the front of a root listing when
|
|
121
121
|
* enabled. Returns true if a row was added (so the caller can hydrate
|
|
122
122
|
* it). Mutates `files` in place. Single source for both panes.
|
|
123
|
+
*
|
|
124
|
+
* ⚠ `isSearchResult` is not optional politeness — it is the one thing
|
|
125
|
+
* `dirname` cannot tell you. A search answers with the SCOPE it searched, so
|
|
126
|
+
* `?action=search&path=main://&filter=brief` comes back with
|
|
127
|
+
* `dirname: "main://"`, identical to the folder listing of the same path
|
|
128
|
+
* (measured). Without this flag the sentinel is unshifted into the search
|
|
129
|
+
* results, and a search for "brief" answers with `brief.md` and a folder
|
|
130
|
+
* called Trash that is not a folder, is not a hit, and cannot be searched for.
|
|
131
|
+
* Same family as the `.trash` / `.shared` sentinel bugs fixed in v0.31.0: a
|
|
132
|
+
* virtual row rendered somewhere it has no meaning.
|
|
133
|
+
*
|
|
134
|
+
* Both flags default to `false`, i.e. to the historical behaviour: a caller
|
|
135
|
+
* that never searches and has no panel — which is what an embed with
|
|
136
|
+
* `sideNav: false` is — keeps the row it has always had by saying nothing.
|
|
123
137
|
*/
|
|
138
|
+
export interface TrashRowContext {
|
|
139
|
+
/**
|
|
140
|
+
* This listing is the answer to a SEARCH, not the contents of a folder.
|
|
141
|
+
*
|
|
142
|
+
* ⚠ The one thing `dirname` cannot tell you: a search answers with the scope
|
|
143
|
+
* it searched, so `?action=search&path=main://&filter=notes` comes back
|
|
144
|
+
* carrying `dirname: "main://"`, byte-identical to that folder's listing
|
|
145
|
+
* (measured). Without this the sentinel lands among the hits.
|
|
146
|
+
*/
|
|
147
|
+
isSearchResult?: boolean;
|
|
148
|
+
/**
|
|
149
|
+
* The navigation panel is already offering Trash as a destination.
|
|
150
|
+
*
|
|
151
|
+
* ⚠ This row only ever existed because a listing had no other way into the
|
|
152
|
+
* bin. Once the panel carries a Trash entry that reason is gone and the row
|
|
153
|
+
* is a second door to the same place — drawn as a 0-byte folder that is not
|
|
154
|
+
* a folder, sitting among real ones, while the panel's own Trash entry is
|
|
155
|
+
* three inches to its left. Owner's decision, this release: do not offer the
|
|
156
|
+
* same door twice.
|
|
157
|
+
*
|
|
158
|
+
* ⚠ It is a question about the PANEL, not about the profile. The duplication
|
|
159
|
+
* is exactly as wrong in `standard` with the panel on as it is in `drive`;
|
|
160
|
+
* keying it to `uiProfile` would be a rule about the wrong thing.
|
|
161
|
+
*/
|
|
162
|
+
navOffersTrash?: boolean;
|
|
163
|
+
}
|
|
164
|
+
|
|
124
165
|
export function injectTrashRow(
|
|
125
166
|
files: FileNode[],
|
|
126
167
|
adapter: string,
|
|
127
168
|
dirname: string,
|
|
128
169
|
trashVisible: boolean,
|
|
170
|
+
ctx: TrashRowContext = {},
|
|
129
171
|
): boolean {
|
|
172
|
+
// The host's own switch, and still the outer gate: `trashVisible: false`
|
|
173
|
+
// means no Trash anywhere — no row here, and no entry in the panel either,
|
|
174
|
+
// so this must be answered before anything about the panel is considered.
|
|
130
175
|
if (!trashVisible) return false;
|
|
176
|
+
if (ctx.navOffersTrash) return false;
|
|
177
|
+
if (ctx.isSearchResult) return false;
|
|
131
178
|
if (isTrashListing(dirname)) return false;
|
|
132
179
|
if (!isStorageRootDir(dirname)) return false;
|
|
133
180
|
files.unshift(makeTrashRow(adapter));
|
|
@@ -179,7 +226,62 @@ export const VIRTUAL_SEGMENTS: Record<string, string> = {
|
|
|
179
226
|
'.shared': 'node.shared',
|
|
180
227
|
};
|
|
181
228
|
|
|
182
|
-
/**
|
|
229
|
+
/**
|
|
230
|
+
* The tag view's sentinel: `.tag~<name>`, ONE segment, e.g. `.tag~invoices`.
|
|
231
|
+
*
|
|
232
|
+
* Every other virtual view has a fixed label, so a segment→locale-key map is
|
|
233
|
+
* enough for them. A tag's label is the tag itself, so it cannot live in that
|
|
234
|
+
* map — but it must not become a SECOND place that knows about sentinels
|
|
235
|
+
* either, which is exactly how the tab strip came to print `.shared`. Hence
|
|
236
|
+
* `virtualSegmentLabel()` below: the map keeps the static views, this prefix
|
|
237
|
+
* keeps the dynamic one, and every surface that renders a path segment calls
|
|
238
|
+
* the one function that knows both.
|
|
239
|
+
*
|
|
240
|
+
* ⚠ `~` rather than `:` or `/`. `writePersistedPath` runs each segment through
|
|
241
|
+
* `encodeURIComponent`, which leaves `~ - _ . ! * ' ( )` alone and escapes `:`
|
|
242
|
+
* — so `#.tag~invoices` stays readable in the address bar while `.tag:` would
|
|
243
|
+
* show as `#.tag%3Ainvoices`. And one segment rather than two (`.tag/name`)
|
|
244
|
+
* because a two-segment path gives the breadcrumb a clickable `.tag` parent
|
|
245
|
+
* crumb that leads nowhere.
|
|
246
|
+
*/
|
|
247
|
+
export const TAG_SEGMENT_PREFIX = '.tag~';
|
|
248
|
+
|
|
249
|
+
/** `invoices` → `.tag~invoices`. */
|
|
250
|
+
export function makeTagSegment(tag: string): string {
|
|
251
|
+
return `${TAG_SEGMENT_PREFIX}${tag}`;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** `.tag~invoices` → `invoices`; '' for anything else (incl. a bare `.tag~`). */
|
|
255
|
+
export function tagOfSegment(segment: string): string {
|
|
256
|
+
return segment.startsWith(TAG_SEGMENT_PREFIX)
|
|
257
|
+
? segment.slice(TAG_SEGMENT_PREFIX.length)
|
|
258
|
+
: '';
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** True when `path` (user-facing form) IS a tag listing. */
|
|
262
|
+
export function tagOfPath(path: string): string {
|
|
263
|
+
const clean = (path || '').replace(/^\/+|\/+$/g, '');
|
|
264
|
+
return tagOfSegment(clean.split('/').pop() || clean);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** The locale key for a STATIC sentinel segment, or '' otherwise. Prefer
|
|
268
|
+
* `virtualSegmentLabel` — a tag segment has no locale key to return. */
|
|
183
269
|
export function virtualSegmentKey(segment: string): string {
|
|
184
270
|
return VIRTUAL_SEGMENTS[segment] ?? '';
|
|
185
271
|
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* What a path segment READS AS: the translated view name for a static
|
|
275
|
+
* sentinel, `#<tag>` for a tag view, '' when it is an ordinary folder (the
|
|
276
|
+
* caller then shows the segment itself).
|
|
277
|
+
*
|
|
278
|
+
* `#` and not the bare name: a lone `invoices` crumb between `/` and nothing
|
|
279
|
+
* is indistinguishable from a folder called invoices. The glyph needs no
|
|
280
|
+
* translation and carries the tag's own name verbatim, which is the point.
|
|
281
|
+
*/
|
|
282
|
+
export function virtualSegmentLabel(segment: string, t: (key: string) => string): string {
|
|
283
|
+
const key = VIRTUAL_SEGMENTS[segment];
|
|
284
|
+
if (key) return t(key);
|
|
285
|
+
const tag = tagOfSegment(segment);
|
|
286
|
+
return tag ? `#${tag}` : '';
|
|
287
|
+
}
|
package/src/lib/star.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* star.ts — the ONE place that talks to `POST /api/files/manager/star`.
|
|
3
|
+
*
|
|
4
|
+
* Starring grew a second surface (the context menu, then the grid/gallery
|
|
5
|
+
* cards) and a menu entry cannot render a component, so the request had to
|
|
6
|
+
* come out of `StarButton.vue` — but only ONCE. `StarButton` calls this, the
|
|
7
|
+
* explorer's menu action calls this, and there is no third copy of the URL,
|
|
8
|
+
* the credentials rule or the payload shape anywhere.
|
|
9
|
+
*
|
|
10
|
+
* ⚠ `credentials` defaults to 'same-origin', never 'include': a credentialed
|
|
11
|
+
* cross-origin request may not be answered with `Access-Control-Allow-Origin:
|
|
12
|
+
* *`, which is what filex sends — hardcoding 'include' silently broke starring
|
|
13
|
+
* in every embed served from a different origin to the API (the desktop app is
|
|
14
|
+
* one). Same trap as `loadStarred`/`fetchNavRows` in FileExplorer.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export interface StarRequestOptions {
|
|
18
|
+
apiBase?: string;
|
|
19
|
+
authHeaders?: () => Record<string, string> | Promise<Record<string, string>>;
|
|
20
|
+
authCredentials?: RequestCredentials;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Toggle the starred flag for ONE node. Throws on a non-2xx answer so the
|
|
24
|
+
* caller can roll its optimistic state back. */
|
|
25
|
+
export async function setNodeStarred(
|
|
26
|
+
nodeId: number,
|
|
27
|
+
starred: boolean,
|
|
28
|
+
opts: StarRequestOptions = {},
|
|
29
|
+
): Promise<void> {
|
|
30
|
+
const headers = {
|
|
31
|
+
'Content-Type': 'application/json',
|
|
32
|
+
...(await (opts.authHeaders ?? (() => ({})))()),
|
|
33
|
+
};
|
|
34
|
+
const base = opts.apiBase ?? '';
|
|
35
|
+
const res = await fetch(`${base}/api/files/manager/star`, {
|
|
36
|
+
method: 'POST',
|
|
37
|
+
headers,
|
|
38
|
+
credentials: opts.authCredentials ?? 'same-origin',
|
|
39
|
+
body: JSON.stringify({ node_id: nodeId, starred }),
|
|
40
|
+
});
|
|
41
|
+
if (!res.ok) throw new Error(`star toggle failed: ${res.status}`);
|
|
42
|
+
}
|
package/src/lib/tags.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tags.ts — the list of tags that exist, for the navigation panel.
|
|
3
|
+
*
|
|
4
|
+
* `GET /api/files/manager/tags/all` is one query per call, and the panel is
|
|
5
|
+
* rendered by every mounted explorer: the web app, the desktop app and every
|
|
6
|
+
* embed on a page (work.example.com renders two side by side). Asking on each mount
|
|
7
|
+
* would multiply a database scan by however many explorers a host happens to
|
|
8
|
+
* put on screen, for a list that changes when somebody edits a tag — i.e.
|
|
9
|
+
* rarely.
|
|
10
|
+
*
|
|
11
|
+
* So: a MODULE-level cache, shared by every instance in the page.
|
|
12
|
+
* - in-flight requests are deduped, so N explorers mounting in the same tick
|
|
13
|
+
* produce ONE request;
|
|
14
|
+
* - the answer is reused for TTL_MS;
|
|
15
|
+
* - `invalidateTagCache()` drops it the moment tags are written, so the
|
|
16
|
+
* panel is never stale after the user's own edit — which is the only
|
|
17
|
+
* staleness a user can actually notice.
|
|
18
|
+
*
|
|
19
|
+
* Keyed by apiBase: the desktop app can point at a different server between
|
|
20
|
+
* mounts and must not be handed the previous server's tags.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const TTL_MS = 60_000;
|
|
24
|
+
|
|
25
|
+
interface CacheEntry {
|
|
26
|
+
at: number;
|
|
27
|
+
tags: string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const cache = new Map<string, CacheEntry>();
|
|
31
|
+
const inflight = new Map<string, Promise<string[]>>();
|
|
32
|
+
|
|
33
|
+
export interface TagListOptions {
|
|
34
|
+
apiBase?: string;
|
|
35
|
+
authHeaders?: () => Record<string, string> | Promise<Record<string, string>>;
|
|
36
|
+
authCredentials?: RequestCredentials;
|
|
37
|
+
/** Skip the cache (after a tag edit). */
|
|
38
|
+
force?: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Drop every cached tag list. Called after a successful tag write. */
|
|
42
|
+
export function invalidateTagCache(): void {
|
|
43
|
+
cache.clear();
|
|
44
|
+
inflight.clear();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Every distinct tag, alphabetical, as the backend returns them. Never
|
|
49
|
+
* throws: an older backend with no such route, or a caller with no
|
|
50
|
+
* permission, gets an empty list and the panel simply shows no tag section.
|
|
51
|
+
*/
|
|
52
|
+
export async function fetchAllTags(opts: TagListOptions = {}): Promise<string[]> {
|
|
53
|
+
const base = opts.apiBase ?? '';
|
|
54
|
+
const key = base || '(same-origin)';
|
|
55
|
+
if (!opts.force) {
|
|
56
|
+
const hit = cache.get(key);
|
|
57
|
+
if (hit && Date.now() - hit.at < TTL_MS) return hit.tags;
|
|
58
|
+
const pending = inflight.get(key);
|
|
59
|
+
if (pending) return pending;
|
|
60
|
+
}
|
|
61
|
+
const run = (async () => {
|
|
62
|
+
try {
|
|
63
|
+
const res = await fetch(`${base}/api/files/manager/tags/all`, {
|
|
64
|
+
headers: await (opts.authHeaders ?? (() => ({})))(),
|
|
65
|
+
credentials: opts.authCredentials ?? 'same-origin',
|
|
66
|
+
});
|
|
67
|
+
if (!res.ok) return [];
|
|
68
|
+
const body = await res.json();
|
|
69
|
+
const tags: string[] = Array.isArray(body?.tags)
|
|
70
|
+
? body.tags.filter((x: unknown): x is string => typeof x === 'string' && x !== '')
|
|
71
|
+
: [];
|
|
72
|
+
cache.set(key, { at: Date.now(), tags });
|
|
73
|
+
return tags;
|
|
74
|
+
} catch {
|
|
75
|
+
return [];
|
|
76
|
+
} finally {
|
|
77
|
+
inflight.delete(key);
|
|
78
|
+
}
|
|
79
|
+
})();
|
|
80
|
+
inflight.set(key, run);
|
|
81
|
+
return run;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Nodes carrying `tag`, as raw node rows (`{id, path, name, type, …}`) — the
|
|
86
|
+
* same shape `star/list` and `recent` answer with, so the caller maps them
|
|
87
|
+
* through its own `nodeRowToFileNode`.
|
|
88
|
+
*/
|
|
89
|
+
export async function fetchTaggedRows(
|
|
90
|
+
tag: string,
|
|
91
|
+
opts: TagListOptions = {},
|
|
92
|
+
limit = 200,
|
|
93
|
+
): Promise<Record<string, unknown>[]> {
|
|
94
|
+
const base = opts.apiBase ?? '';
|
|
95
|
+
const res = await fetch(
|
|
96
|
+
`${base}/api/files/manager/tagged?tag=${encodeURIComponent(tag)}&limit=${limit}`,
|
|
97
|
+
{
|
|
98
|
+
headers: await (opts.authHeaders ?? (() => ({})))(),
|
|
99
|
+
credentials: opts.authCredentials ?? 'same-origin',
|
|
100
|
+
},
|
|
101
|
+
);
|
|
102
|
+
if (!res.ok) throw new Error(String(res.status));
|
|
103
|
+
const body = await res.json();
|
|
104
|
+
return Array.isArray(body?.nodes) ? body.nodes : [];
|
|
105
|
+
}
|
package/src/locales/en.ts
CHANGED
|
@@ -186,6 +186,7 @@ export const en: Record<string, string> = {
|
|
|
186
186
|
'shortcuts.toggle_hidden': 'Show/hide hidden files',
|
|
187
187
|
'shortcuts.help': 'This help dialog',
|
|
188
188
|
'shortcuts.select_all': 'Select all',
|
|
189
|
+
'shortcuts.star': 'Star / unstar',
|
|
189
190
|
'shortcuts.rename': 'Rename',
|
|
190
191
|
'shortcuts.delete': 'Delete',
|
|
191
192
|
'shortcuts.cut': 'Cut',
|
|
@@ -227,6 +228,10 @@ export const en: Record<string, string> = {
|
|
|
227
228
|
/* === koru:k1 === */
|
|
228
229
|
'toolbar.inspector': 'Details',
|
|
229
230
|
'ctx.details': 'Details',
|
|
231
|
+
/* yildiz:s1 — starring is an action, so it has a verb in the menu. */
|
|
232
|
+
'ctx.star': 'Star',
|
|
233
|
+
'ctx.unstar': 'Unstar',
|
|
234
|
+
'star.failed': 'Could not change the star',
|
|
230
235
|
'shortcuts.inspector': 'Toggle details panel',
|
|
231
236
|
'inspector.title': 'Details',
|
|
232
237
|
'inspector.close': 'Close',
|
|
@@ -403,8 +408,8 @@ export const en: Record<string, string> = {
|
|
|
403
408
|
'e2e.create.pw2_placeholder': 'Repeat the password',
|
|
404
409
|
'e2e.create.warn_title': 'NO WAY BACK',
|
|
405
410
|
'e2e.create.warn_body':
|
|
406
|
-
'Files
|
|
407
|
-
'e2e.create.ack': 'I understand: if I lose the password my data cannot be recovered.',
|
|
411
|
+
'Files in this folder can only be opened with this password or the recovery key shown once when the folder is created. The password is NEVER stored on the server, and filex keeps no copy of the recovery key either. Lose both and the contents are gone for good.',
|
|
412
|
+
'e2e.create.ack': 'I understand: if I lose both the password and the recovery key, my data cannot be recovered.',
|
|
408
413
|
'e2e.create.ack_required': 'Please confirm the warning to continue.',
|
|
409
414
|
'e2e.create.pw_short': 'Password must be at least 8 characters.',
|
|
410
415
|
'e2e.create.pw_mismatch': 'Passwords do not match.',
|
|
@@ -428,6 +433,86 @@ export const en: Record<string, string> = {
|
|
|
428
433
|
'e2e.upload.locked': 'Unlock the folder first',
|
|
429
434
|
'e2e.decrypting': 'Decrypting…',
|
|
430
435
|
'e2e.decrypt_failed': 'Could not decrypt the file (password changed or file corrupted).',
|
|
436
|
+
/* wiring:e2 recovery — recovery keys + operator escrow */
|
|
437
|
+
'e2e.create.escrow_title': 'This server holds a second key',
|
|
438
|
+
'e2e.create.escrow_body':
|
|
439
|
+
'Key escrow is enabled on this installation, so its operator can open this folder without your password. Using that key notifies you. Escrow is fixed when the server is installed and cannot be turned off for a folder.',
|
|
440
|
+
'e2e.locked.use_recovery': 'Lost the password? Use a recovery key',
|
|
441
|
+
'e2e.recovery.title': 'Save your recovery key',
|
|
442
|
+
'e2e.recovery.title_upgraded': 'Your recovery key',
|
|
443
|
+
'e2e.recovery.lead':
|
|
444
|
+
'This key opens the folder without its password. It is shown once — filex does not store it and cannot show it again.',
|
|
445
|
+
'e2e.recovery.lead_upgraded':
|
|
446
|
+
'This folder now has a recovery key. It opens the folder without its password, is shown once, and is not stored by filex.',
|
|
447
|
+
'e2e.recovery.copy': 'Copy',
|
|
448
|
+
'e2e.recovery.copied': 'Copied',
|
|
449
|
+
'e2e.recovery.download': 'Download as a file',
|
|
450
|
+
'e2e.recovery.warn_title': 'TREAT THIS LIKE A PASSWORD',
|
|
451
|
+
'e2e.recovery.warn_body':
|
|
452
|
+
'Anyone holding this key can read the folder. Keep it somewhere separate from the password — a key stored next to the password protects you from forgetting, not from anyone else.',
|
|
453
|
+
'e2e.recovery.escrow_title': 'This server also holds a key',
|
|
454
|
+
'e2e.recovery.escrow_body':
|
|
455
|
+
'Key escrow is enabled here, so the operator of this installation can open this folder without your password. You are notified when that key is used.',
|
|
456
|
+
'e2e.recovery.escrow_kid': 'Escrow key',
|
|
457
|
+
'e2e.recovery.ack': 'I have saved this key somewhere safe.',
|
|
458
|
+
'e2e.recovery.done': 'Done',
|
|
459
|
+
'e2e.recover.title': 'Unlock without the password',
|
|
460
|
+
'e2e.recover.none':
|
|
461
|
+
'This folder was created before recovery keys existed, so it has none. Its password is the only way in. Unlock it with the password once and filex will offer to add a recovery key.',
|
|
462
|
+
'e2e.recover.tab_recovery': 'Recovery key',
|
|
463
|
+
'e2e.recover.tab_escrow': 'Escrow key',
|
|
464
|
+
'e2e.recover.recovery_hint': 'The key shown once when this folder was created.',
|
|
465
|
+
'e2e.recover.recovery_placeholder': 'XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX',
|
|
466
|
+
'e2e.recover.escrow_warn_title': 'The owner will be told',
|
|
467
|
+
'e2e.recover.escrow_warn_body':
|
|
468
|
+
'Opening a folder with the escrow key notifies its owner. Use it only when you are entitled to.',
|
|
469
|
+
'e2e.recover.escrow_kid': 'Escrow key',
|
|
470
|
+
'e2e.recover.escrow_predates':
|
|
471
|
+
'This installation has an escrow key, but this folder does not. It was created before escrow was turned on here, so its master key was never wrapped to the escrow identity. The escrow key will not open it. Nothing the operator can do changes that — adding a slot needs this folder\u2019s password, which the server has never had. The folder\u2019s owner can grant it: unlock with the password, then choose to give the operator a key. Until they do, use the password or the recovery key.',
|
|
472
|
+
'e2e.recover.escrow_other_key':
|
|
473
|
+
'This folder carries an escrow key from a DIFFERENT installation. It was created elsewhere and restored here, so this installation\u2019s escrow key does not open it. Only the private key belonging to the escrow key it was sealed to can.',
|
|
474
|
+
'e2e.recover.escrow_placeholder': 'Paste the escrow private key (PKCS#8, base64 or PEM)',
|
|
475
|
+
'e2e.recover.bad_format':
|
|
476
|
+
'That does not look like a recovery key. It is 32 characters in eight groups of four.',
|
|
477
|
+
'e2e.recover.escrow_required': 'Paste the escrow private key.',
|
|
478
|
+
'e2e.recover.wrong_recovery': 'That recovery key does not open this folder.',
|
|
479
|
+
'e2e.recover.wrong_escrow':
|
|
480
|
+
'That escrow key does not open this folder. Folders created before escrow was enabled have no escrow key, and cannot be given one.',
|
|
481
|
+
'e2e.recover.bad_escrow_key': 'That is not a readable private key (expected PKCS#8, base64 or PEM).',
|
|
482
|
+
'e2e.recover.notify_failed':
|
|
483
|
+
'Could not notify the folder owner, so the folder was not unlocked. Escrow use is always announced.',
|
|
484
|
+
'e2e.recover.recovery_done': 'Unlocked with the recovery key',
|
|
485
|
+
'e2e.recover.escrow_done': 'Unlocked with the escrow key — the owner has been notified',
|
|
486
|
+
'e2e.recover.unlock': 'Unlock',
|
|
487
|
+
'e2e.recover.busy': 'Unlocking…',
|
|
488
|
+
'e2e.escrowoffer.title': 'Give the operator a key to this folder?',
|
|
489
|
+
'e2e.escrowoffer.body':
|
|
490
|
+
'This folder was created before key escrow was turned on here, so the operator of this installation cannot open it. Right now — while your password is in memory — filex can seal this folder to the escrow key. Your files are not re-encrypted or moved; only the key file changes.',
|
|
491
|
+
'e2e.escrowoffer.consequence':
|
|
492
|
+
'What this means: the operator gains a second, permanent way into this folder, without your password. You are notified when that key is used, but that notification is an announcement rather than a control.',
|
|
493
|
+
'e2e.escrowoffer.learn_more': 'What escrow can and cannot do',
|
|
494
|
+
'e2e.escrowoffer.password_label': 'Folder password',
|
|
495
|
+
'e2e.escrowoffer.password_required': 'Enter the folder password.',
|
|
496
|
+
'e2e.escrowoffer.wrong_password': 'That is not this folder\u2019s password.',
|
|
497
|
+
'e2e.escrowoffer.accept': 'Give the operator a key',
|
|
498
|
+
'e2e.escrowoffer.decline': 'No, keep it to myself',
|
|
499
|
+
'e2e.escrowoffer.cancel': 'Cancel',
|
|
500
|
+
'e2e.escrowoffer.busy': 'Sealing…',
|
|
501
|
+
'e2e.escrowoffer.done': 'The operator can now open this folder with the escrow key',
|
|
502
|
+
'e2e.escrowoffer.declined_toast': 'Kept to yourself. filex will not ask again for this folder.',
|
|
503
|
+
'e2e.escrowoffer.decline_failed':
|
|
504
|
+
'Could not record your answer, so filex will ask again next time. Nothing about the folder changed.',
|
|
505
|
+
'e2e.escrowoffer.failed': 'Could not give the operator a key to this folder',
|
|
506
|
+
'e2e.escrowoffer.strip_action': 'Escrow key…',
|
|
507
|
+
'e2e.upgrade.title': 'This folder has no recovery key',
|
|
508
|
+
'e2e.upgrade.body':
|
|
509
|
+
'It was created before recovery keys existed, so its password is the only way in. Right now — and only right now, while the password is in memory — filex can add one. Your files are not re-encrypted or moved.',
|
|
510
|
+
'e2e.upgrade.escrow_note':
|
|
511
|
+
'Note: this installation has key escrow enabled, so adding a recovery key also lets its operator open this folder without your password.',
|
|
512
|
+
'e2e.upgrade.accept': 'Create a recovery key',
|
|
513
|
+
'e2e.upgrade.decline': 'Not now',
|
|
514
|
+
'e2e.upgrade.busy': 'Creating…',
|
|
515
|
+
'e2e.upgrade.failed': 'Could not add a recovery key to this folder',
|
|
431
516
|
'e2e.download.failed': 'Could not download the encrypted file.',
|
|
432
517
|
/* /wiring:e2 */
|
|
433
518
|
|
|
@@ -863,6 +948,13 @@ export const en: Record<string, string> = {
|
|
|
863
948
|
'empty.starred.hint': 'Star a file to find it here',
|
|
864
949
|
'empty.shared.title': 'Nothing has been shared with you',
|
|
865
950
|
'empty.shared.hint': 'Folders and files other people share appear here',
|
|
951
|
+
/* etiket:t1 */
|
|
952
|
+
'sidenav.tags': 'Tags',
|
|
953
|
+
'sidenav.tags.empty': 'No tags yet — add one from a file’s right-click menu.',
|
|
954
|
+
'sidenav.tags.more': 'Show {count} more',
|
|
955
|
+
'sidenav.tags.less': 'Show fewer',
|
|
956
|
+
'empty.tag.title': 'Nothing is tagged “{tag}”',
|
|
957
|
+
'empty.tag.hint': 'Right-click a file and choose Tags to put it here',
|
|
866
958
|
'sidenav.connections': 'Connections',
|
|
867
959
|
'sidenav.connect': 'How to connect',
|
|
868
960
|
'sidenav.apikeys': 'API keys',
|
|
@@ -874,4 +966,64 @@ export const en: Record<string, string> = {
|
|
|
874
966
|
'conn.tokens.expiryNever': 'never',
|
|
875
967
|
'conn.tokens.capNote': 'Your account and its permissions are the ceiling — asking for more than you have is refused, not granted.',
|
|
876
968
|
/* === /gezinti:g1 === */
|
|
969
|
+
|
|
970
|
+
/* === surucu:d1 — the Drive shell (uiProfile: 'drive', GitHub #14) ===== */
|
|
971
|
+
'drive.new': 'New',
|
|
972
|
+
'drive.new.upload': 'Upload files',
|
|
973
|
+
'drive.new.folder': 'New folder',
|
|
974
|
+
'drive.new.request': 'Request files',
|
|
975
|
+
'drive.search.placeholder': 'Search in {scope}',
|
|
976
|
+
'drive.search.placeholder_all': 'Search your files',
|
|
977
|
+
'drive.search.hint_title': 'Search everywhere and run commands ({combo})',
|
|
978
|
+
'drive.storage.used': '{used} of {total} used',
|
|
979
|
+
'drive.storage.used_unlimited': '{used} used',
|
|
980
|
+
'drive.storage.label': 'Storage',
|
|
981
|
+
'drive.section.folders': 'Folders',
|
|
982
|
+
'drive.section.files': 'Files',
|
|
983
|
+
'filter.aria': 'Filters',
|
|
984
|
+
'filter.type': 'Type',
|
|
985
|
+
'filter.type.any': 'Any type',
|
|
986
|
+
'filter.type.folder': 'Folders',
|
|
987
|
+
'filter.type.document': 'Documents',
|
|
988
|
+
'filter.type.spreadsheet': 'Spreadsheets',
|
|
989
|
+
'filter.type.presentation': 'Presentations',
|
|
990
|
+
'filter.type.pdf': 'PDFs',
|
|
991
|
+
'filter.type.image': 'Images',
|
|
992
|
+
'filter.type.video': 'Videos',
|
|
993
|
+
'filter.type.audio': 'Audio',
|
|
994
|
+
'filter.type.archive': 'Archives',
|
|
995
|
+
'filter.type.code': 'Code',
|
|
996
|
+
'filter.modified': 'Modified',
|
|
997
|
+
'filter.modified.any': 'Any time',
|
|
998
|
+
'filter.modified.today': 'Today',
|
|
999
|
+
'filter.modified.7d': 'Last 7 days',
|
|
1000
|
+
'filter.modified.30d': 'Last 30 days',
|
|
1001
|
+
'filter.modified.year': 'This year',
|
|
1002
|
+
'filter.size': 'Size',
|
|
1003
|
+
'filter.size.any': 'Any size',
|
|
1004
|
+
'filter.size.lt1': 'Under 1 MB',
|
|
1005
|
+
'filter.size.1to10': '1 - 10 MB',
|
|
1006
|
+
'filter.size.10to100': '10 - 100 MB',
|
|
1007
|
+
'filter.size.gt100': 'Over 100 MB',
|
|
1008
|
+
'filter.clear': 'Clear filters',
|
|
1009
|
+
'filter.empty.title': 'Nothing here matches those filters',
|
|
1010
|
+
'filter.empty.hint': 'Clear a filter, or search everywhere with the field above.',
|
|
1011
|
+
'filter.count': '{shown} of {total}',
|
|
1012
|
+
'inspector.tab.details': 'Details',
|
|
1013
|
+
'inspector.tab.activity': 'Activity',
|
|
1014
|
+
'inspector.people': 'People with access',
|
|
1015
|
+
'inspector.people.you': 'You',
|
|
1016
|
+
'inspector.people.inherited': 'from a parent folder',
|
|
1017
|
+
'inspector.people.empty': 'Only you, and anyone with a link below.',
|
|
1018
|
+
'inspector.people.manage': 'Manage access',
|
|
1019
|
+
'inspector.link': 'Shared link',
|
|
1020
|
+
'inspector.link.none': 'Not shared',
|
|
1021
|
+
'inspector.link.create': 'Create link',
|
|
1022
|
+
'inspector.link.copy': 'Copy link',
|
|
1023
|
+
'inspector.activity.empty': 'No activity recorded for this item yet.',
|
|
1024
|
+
'inspector.activity.hint': 'Versions are written when a file changes; comments are written by people.',
|
|
1025
|
+
'inspector.activity.version': 'Version {n} saved',
|
|
1026
|
+
'inspector.activity.comment': 'Comment by {who}',
|
|
1027
|
+
'inspector.activity.select': 'Select a file or folder to see its activity.',
|
|
1028
|
+
/* === /surucu:d1 === */
|
|
877
1029
|
};
|