@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.
Files changed (38) hide show
  1. package/README.md +37 -2
  2. package/dist/filex-core.js +10571 -8278
  3. package/dist/filex-core.js.map +1 -1
  4. package/dist/filex-core.umd.cjs +95 -87
  5. package/dist/filex-core.umd.cjs.map +1 -1
  6. package/dist/index.d.ts +548 -66
  7. package/dist/style.css +1 -1
  8. package/package.json +1 -1
  9. package/src/FileExplorer.vue +1104 -26
  10. package/src/components/Breadcrumb.vue +4 -2
  11. package/src/components/CommandPalette.vue +18 -2
  12. package/src/components/E2eRecoveryUnlockModal.vue +193 -0
  13. package/src/components/EncryptedFolderModal.vue +10 -0
  14. package/src/components/FilterBar.vue +244 -0
  15. package/src/components/GalleryView.vue +48 -0
  16. package/src/components/GridView.vue +85 -3
  17. package/src/components/InspectorPanel.vue +231 -8
  18. package/src/components/ListView.vue +11 -1
  19. package/src/components/RecoveryKeyModal.vue +133 -0
  20. package/src/components/SecondaryPane.vue +15 -1
  21. package/src/components/SideNav.vue +329 -6
  22. package/src/components/StarButton.vue +27 -15
  23. package/src/components/Toolbar.vue +235 -49
  24. package/src/components/ViewSwitcher.vue +81 -0
  25. package/src/composables/useFileApi.ts +83 -0
  26. package/src/composables/useKeyboardShortcuts.ts +7 -0
  27. package/src/index.ts +33 -1
  28. package/src/lib/e2ecrypto.ts +716 -70
  29. package/src/lib/fileFilters.ts +143 -0
  30. package/src/lib/listing.ts +103 -1
  31. package/src/lib/star.ts +42 -0
  32. package/src/lib/tags.ts +105 -0
  33. package/src/locales/en.ts +154 -2
  34. package/src/locales/tr.ts +154 -2
  35. package/src/modals/PermissionsModal.vue +18 -2
  36. package/src/styles/base.css +743 -0
  37. package/src/types/ExplorerConfig.ts +53 -1
  38. 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
+ }
@@ -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
- /** The locale key for a sentinel segment, or '' when it is a real folder. */
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
+ }
@@ -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
+ }
@@ -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 open ONLY with this password. The password is NEVER stored on the server; if you forget it the content is lost forever. There is no recovery not even an admin can open it.',
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
  };