pinokiod 8.0.39 → 8.0.41
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/kernel/api/hf/index.js +2 -2
- package/kernel/api/index.js +12 -3
- package/kernel/connect/index.js +2 -2
- package/kernel/connect/providers/huggingface/index.js +14 -5
- package/kernel/index.js +14 -2
- package/kernel/shell.js +3 -2
- package/kernel/shells.js +6 -0
- package/kernel/vault/constants.js +13 -0
- package/kernel/vault/hash_worker.js +9 -2
- package/kernel/vault/index.js +1856 -316
- package/kernel/vault/registry.js +526 -52
- package/kernel/vault/snapshot.js +29 -0
- package/kernel/vault/sweeper.js +480 -210
- package/kernel/vault/walker.js +142 -0
- package/package.json +1 -1
- package/server/index.js +79 -90
- package/server/lib/privacy_filter_cache.js +4 -1
- package/server/public/storage-size.js +12 -0
- package/server/public/style.css +13 -0
- package/server/public/tab-link-popover.js +3 -0
- package/server/public/vault-nav.js +96 -0
- package/server/public/vault.css +1079 -0
- package/server/public/vault.js +1835 -0
- package/server/views/app.ejs +93 -16
- package/server/views/connect/huggingface.ejs +7 -9
- package/server/views/partials/main_sidebar.ejs +6 -1
- package/server/views/partials/vault_workspace.ejs +55 -0
- package/server/views/vault.ejs +5 -1532
- package/server/views/vault_app.ejs +22 -0
- package/test/hf-api.test.js +4 -2
- package/test/huggingface-connect.test.js +7 -11
- package/test/huggingface-token-validity.test.js +135 -0
- package/test/plugin-action-functions.test.js +19 -0
- package/test/privacy-filter-cache.test.js +1 -0
- package/test/vault-engine.test.js +1276 -26
- package/test/vault-sweep.test.js +1043 -35
- package/test/vault-ui.test.js +2184 -65
package/server/views/vault.ejs
CHANGED
|
@@ -12,1545 +12,18 @@
|
|
|
12
12
|
<% if (agent === "electron") { %>
|
|
13
13
|
<link href="/electron.css" rel="stylesheet"/>
|
|
14
14
|
<% } %>
|
|
15
|
-
<
|
|
16
|
-
.vault-page {
|
|
17
|
-
--vault-warning: #b06a09;
|
|
18
|
-
--vault-warning-soft: color-mix(in srgb, var(--vault-warning) 8%, var(--task-panel));
|
|
19
|
-
--vault-selected: color-mix(in srgb, var(--task-accent) 10%, var(--task-panel));
|
|
20
|
-
--vault-inline: 18px;
|
|
21
|
-
--vault-control-height: 28px;
|
|
22
|
-
--vault-row-height: 44px;
|
|
23
|
-
--vault-tree-row: 26px;
|
|
24
|
-
--vault-tree-path-row: 34px;
|
|
25
|
-
}
|
|
26
|
-
body.dark.vault-page {
|
|
27
|
-
--vault-warning: #dca451;
|
|
28
|
-
--vault-warning-soft: color-mix(in srgb, var(--vault-warning) 8%, var(--task-panel));
|
|
29
|
-
--vault-selected: color-mix(in srgb, var(--task-accent) 9%, var(--task-panel));
|
|
30
|
-
}
|
|
31
|
-
.vault-shell,
|
|
32
|
-
.vault-shell * { box-sizing: border-box; }
|
|
33
|
-
body.vault-page .task-container {
|
|
34
|
-
display: flex;
|
|
35
|
-
min-height: 0;
|
|
36
|
-
overflow: hidden;
|
|
37
|
-
}
|
|
38
|
-
.vault-shell {
|
|
39
|
-
display: flex;
|
|
40
|
-
min-height: 0;
|
|
41
|
-
flex: 1 1 auto;
|
|
42
|
-
flex-direction: column;
|
|
43
|
-
}
|
|
44
|
-
.vault-shell .task-shell-body {
|
|
45
|
-
display: block;
|
|
46
|
-
min-height: 0;
|
|
47
|
-
flex: 1 1 auto;
|
|
48
|
-
overflow: hidden;
|
|
49
|
-
padding: 0;
|
|
50
|
-
}
|
|
51
|
-
.vault-body {
|
|
52
|
-
display: flex;
|
|
53
|
-
height: 100%;
|
|
54
|
-
min-width: 0;
|
|
55
|
-
min-height: 0;
|
|
56
|
-
flex-direction: column;
|
|
57
|
-
color: var(--task-text);
|
|
58
|
-
}
|
|
59
|
-
.vault-overview {
|
|
60
|
-
display: flex;
|
|
61
|
-
align-items: center;
|
|
62
|
-
gap: 10px;
|
|
63
|
-
padding: 7px var(--vault-inline);
|
|
64
|
-
border-bottom: 1px solid var(--task-border);
|
|
65
|
-
}
|
|
66
|
-
.vault-metrics {
|
|
67
|
-
display: flex;
|
|
68
|
-
flex: 1;
|
|
69
|
-
min-width: 0;
|
|
70
|
-
align-items: stretch;
|
|
71
|
-
}
|
|
72
|
-
.vault-metric {
|
|
73
|
-
min-width: 0;
|
|
74
|
-
padding: 0 12px;
|
|
75
|
-
border: 0;
|
|
76
|
-
border-left: 1px solid var(--task-border);
|
|
77
|
-
}
|
|
78
|
-
.vault-metric:first-child { padding-left: 0; border-left: 0; }
|
|
79
|
-
.vault-metric-value {
|
|
80
|
-
display: block;
|
|
81
|
-
color: var(--task-text);
|
|
82
|
-
font-size: 15px;
|
|
83
|
-
line-height: 1.15;
|
|
84
|
-
font-weight: 650;
|
|
85
|
-
letter-spacing: -0.015em;
|
|
86
|
-
white-space: nowrap;
|
|
87
|
-
}
|
|
88
|
-
.vault-metric-label {
|
|
89
|
-
display: block;
|
|
90
|
-
margin-top: 1px;
|
|
91
|
-
color: var(--task-muted);
|
|
92
|
-
font-size: 10.5px;
|
|
93
|
-
line-height: 1.2;
|
|
94
|
-
white-space: nowrap;
|
|
95
|
-
}
|
|
96
|
-
button.vault-metric {
|
|
97
|
-
appearance: none;
|
|
98
|
-
background: transparent;
|
|
99
|
-
color: inherit;
|
|
100
|
-
font: inherit;
|
|
101
|
-
text-align: left;
|
|
102
|
-
cursor: pointer;
|
|
103
|
-
}
|
|
104
|
-
button.vault-metric:hover .vault-metric-label,
|
|
105
|
-
button.vault-metric:focus-visible .vault-metric-label { color: var(--task-text); }
|
|
106
|
-
.vault-overview-actions {
|
|
107
|
-
display: flex;
|
|
108
|
-
align-items: center;
|
|
109
|
-
justify-content: flex-end;
|
|
110
|
-
gap: 8px;
|
|
111
|
-
flex: 0 0 auto;
|
|
112
|
-
}
|
|
113
|
-
.vault-button,
|
|
114
|
-
.vault-icon-button,
|
|
115
|
-
.vault-text-button {
|
|
116
|
-
appearance: none;
|
|
117
|
-
border: 1px solid var(--task-border-strong);
|
|
118
|
-
background: color-mix(in srgb, var(--task-panel) 94%, var(--task-soft));
|
|
119
|
-
color: var(--task-text);
|
|
120
|
-
font: inherit;
|
|
121
|
-
cursor: pointer;
|
|
122
|
-
}
|
|
123
|
-
.vault-button {
|
|
124
|
-
display: inline-flex;
|
|
125
|
-
min-height: var(--vault-control-height);
|
|
126
|
-
align-items: center;
|
|
127
|
-
justify-content: center;
|
|
128
|
-
gap: 7px;
|
|
129
|
-
padding: 6px 12px;
|
|
130
|
-
border-radius: 7px;
|
|
131
|
-
font-size: 12px;
|
|
132
|
-
line-height: 1;
|
|
133
|
-
white-space: nowrap;
|
|
134
|
-
}
|
|
135
|
-
.vault-button:hover { background: var(--task-soft); }
|
|
136
|
-
.vault-button.primary {
|
|
137
|
-
border-color: var(--task-accent-surface);
|
|
138
|
-
background: var(--task-accent-surface);
|
|
139
|
-
color: var(--task-accent-contrast);
|
|
140
|
-
}
|
|
141
|
-
.vault-button.primary:hover { background: var(--task-accent-hover); }
|
|
142
|
-
.vault-button:disabled { opacity: .5; cursor: default; }
|
|
143
|
-
.vault-icon-button {
|
|
144
|
-
width: var(--vault-control-height);
|
|
145
|
-
height: var(--vault-control-height);
|
|
146
|
-
display: inline-flex;
|
|
147
|
-
align-items: center;
|
|
148
|
-
justify-content: center;
|
|
149
|
-
padding: 0;
|
|
150
|
-
border-radius: 7px;
|
|
151
|
-
font-size: 12px;
|
|
152
|
-
}
|
|
153
|
-
.vault-icon-button:hover { background: var(--task-soft); }
|
|
154
|
-
.vault-advanced { position: relative; }
|
|
155
|
-
.vault-advanced > summary { list-style: none; cursor: pointer; }
|
|
156
|
-
.vault-advanced > summary::-webkit-details-marker { display: none; }
|
|
157
|
-
.vault-advanced-menu {
|
|
158
|
-
position: absolute;
|
|
159
|
-
z-index: 20;
|
|
160
|
-
top: calc(100% + 6px);
|
|
161
|
-
right: 0;
|
|
162
|
-
width: 272px;
|
|
163
|
-
box-sizing: border-box;
|
|
164
|
-
padding: 12px;
|
|
165
|
-
border: 1px solid var(--task-border-strong);
|
|
166
|
-
border-radius: 9px;
|
|
167
|
-
background: var(--task-panel);
|
|
168
|
-
box-shadow: 0 12px 30px color-mix(in srgb, var(--task-text) 10%, transparent);
|
|
169
|
-
}
|
|
170
|
-
.vault-advanced-title { color: var(--task-text); font-size: 12.5px; font-weight: 650; }
|
|
171
|
-
.vault-advanced-copy { margin: 4px 0 10px; color: var(--task-muted); font-size: 11px; line-height: 1.45; }
|
|
172
|
-
.vault-button:focus-visible,
|
|
173
|
-
.vault-icon-button:focus-visible,
|
|
174
|
-
.vault-text-button:focus-visible,
|
|
175
|
-
button.vault-metric:focus-visible,
|
|
176
|
-
.vault-rail-add:focus-visible,
|
|
177
|
-
.vault-nav-row:focus-visible,
|
|
178
|
-
.vault-search input:focus-visible,
|
|
179
|
-
.vault-select:focus-visible {
|
|
180
|
-
outline: 2px solid color-mix(in srgb, var(--task-accent) 55%, transparent);
|
|
181
|
-
outline-offset: 2px;
|
|
182
|
-
}
|
|
183
|
-
.vault-scan-state,
|
|
184
|
-
.vault-result,
|
|
185
|
-
.vault-feedback {
|
|
186
|
-
display: none;
|
|
187
|
-
align-items: center;
|
|
188
|
-
min-height: 38px;
|
|
189
|
-
gap: 9px;
|
|
190
|
-
padding: 6px var(--vault-inline);
|
|
191
|
-
border-bottom: 1px solid var(--task-border);
|
|
192
|
-
font-size: 12.5px;
|
|
193
|
-
}
|
|
194
|
-
.vault-scan-state {
|
|
195
|
-
position: relative;
|
|
196
|
-
overflow: hidden;
|
|
197
|
-
}
|
|
198
|
-
.vault-scan-state.show,
|
|
199
|
-
.vault-result.show,
|
|
200
|
-
.vault-feedback.show { display: flex; }
|
|
201
|
-
.vault-scan-state i,
|
|
202
|
-
.vault-result > i { color: var(--task-accent); }
|
|
203
|
-
.vault-result strong { font-weight: 650; }
|
|
204
|
-
.vault-result-detail,
|
|
205
|
-
.vault-scan-detail { color: var(--task-muted); }
|
|
206
|
-
.vault-scan-detail {
|
|
207
|
-
min-width: 0;
|
|
208
|
-
overflow: hidden;
|
|
209
|
-
text-overflow: ellipsis;
|
|
210
|
-
white-space: nowrap;
|
|
211
|
-
}
|
|
212
|
-
.vault-scan-percent {
|
|
213
|
-
flex: 0 0 auto;
|
|
214
|
-
margin-left: auto;
|
|
215
|
-
color: var(--task-muted);
|
|
216
|
-
font-size: 11.5px;
|
|
217
|
-
font-variant-numeric: tabular-nums;
|
|
218
|
-
font-weight: 650;
|
|
219
|
-
}
|
|
220
|
-
.vault-progress-track {
|
|
221
|
-
position: absolute;
|
|
222
|
-
right: 0;
|
|
223
|
-
bottom: 0;
|
|
224
|
-
left: 0;
|
|
225
|
-
height: 2px;
|
|
226
|
-
overflow: hidden;
|
|
227
|
-
background: color-mix(in srgb, var(--task-accent) 12%, transparent);
|
|
228
|
-
}
|
|
229
|
-
.vault-progress-bar {
|
|
230
|
-
display: block;
|
|
231
|
-
width: 100%;
|
|
232
|
-
height: 100%;
|
|
233
|
-
background: var(--task-accent);
|
|
234
|
-
transform-origin: left center;
|
|
235
|
-
}
|
|
236
|
-
.vault-progress-bar.determinate {
|
|
237
|
-
transform: scaleX(var(--vault-progress, 0));
|
|
238
|
-
transition: transform 320ms cubic-bezier(.22, 1, .36, 1);
|
|
239
|
-
}
|
|
240
|
-
.vault-progress-bar.indeterminate {
|
|
241
|
-
transform: scaleX(1);
|
|
242
|
-
animation: vault-progress-pulse 1.4s ease-in-out infinite;
|
|
243
|
-
}
|
|
244
|
-
@keyframes vault-progress-pulse {
|
|
245
|
-
0%, 100% { opacity: .22; }
|
|
246
|
-
50% { opacity: .78; }
|
|
247
|
-
}
|
|
248
|
-
.vault-result .vault-button { min-height: 28px; margin-left: auto; padding: 4px 10px; }
|
|
249
|
-
.vault-feedback { min-height: 34px; background: var(--task-soft); }
|
|
250
|
-
.vault-feedback.error { color: var(--vault-warning); }
|
|
251
|
-
.vault-explorer {
|
|
252
|
-
display: grid;
|
|
253
|
-
min-height: 0;
|
|
254
|
-
flex: 1 1 auto;
|
|
255
|
-
grid-template-columns: 248px minmax(0, 1fr);
|
|
256
|
-
overflow: hidden;
|
|
257
|
-
}
|
|
258
|
-
.vault-rail {
|
|
259
|
-
min-width: 0;
|
|
260
|
-
min-height: 0;
|
|
261
|
-
border-right: 1px solid var(--task-border);
|
|
262
|
-
background: color-mix(in srgb, var(--task-panel) 96%, var(--task-soft));
|
|
263
|
-
overflow-x: hidden;
|
|
264
|
-
overflow-y: auto;
|
|
265
|
-
overscroll-behavior: contain;
|
|
266
|
-
}
|
|
267
|
-
.vault-rail-section {
|
|
268
|
-
display: block;
|
|
269
|
-
min-height: 0;
|
|
270
|
-
padding: 12px 9px;
|
|
271
|
-
}
|
|
272
|
-
.vault-rail-section + .vault-rail-section { border-top: 1px solid var(--task-border); }
|
|
273
|
-
.vault-rail-label {
|
|
274
|
-
margin: 0 7px 6px;
|
|
275
|
-
color: var(--task-muted);
|
|
276
|
-
font-size: 10.5px;
|
|
277
|
-
font-weight: 700;
|
|
278
|
-
letter-spacing: .08em;
|
|
279
|
-
}
|
|
280
|
-
.vault-rail-heading {
|
|
281
|
-
display: flex;
|
|
282
|
-
height: 20px;
|
|
283
|
-
align-items: center;
|
|
284
|
-
margin: 0 5px 4px 7px;
|
|
285
|
-
}
|
|
286
|
-
.vault-rail-heading .vault-rail-label { flex: 1 1 auto; margin: 0; }
|
|
287
|
-
.vault-rail-add {
|
|
288
|
-
width: 20px;
|
|
289
|
-
height: 20px;
|
|
290
|
-
display: inline-flex;
|
|
291
|
-
flex: 0 0 20px;
|
|
292
|
-
align-items: center;
|
|
293
|
-
justify-content: center;
|
|
294
|
-
padding: 0;
|
|
295
|
-
border: 0;
|
|
296
|
-
border-radius: 5px;
|
|
297
|
-
background: transparent;
|
|
298
|
-
color: var(--task-muted);
|
|
299
|
-
cursor: pointer;
|
|
300
|
-
font: inherit;
|
|
301
|
-
font-size: 10px;
|
|
302
|
-
}
|
|
303
|
-
.vault-rail-add:hover { background: var(--task-soft); color: var(--task-text); }
|
|
304
|
-
.vault-rail-add:disabled { opacity: .45; cursor: default; }
|
|
305
|
-
.vault-nav-list { display: grid; gap: 0; }
|
|
306
|
-
.vault-nav-row {
|
|
307
|
-
width: 100%;
|
|
308
|
-
min-width: 0;
|
|
309
|
-
height: var(--vault-tree-row);
|
|
310
|
-
min-height: var(--vault-tree-row);
|
|
311
|
-
max-height: var(--vault-tree-row);
|
|
312
|
-
display: grid;
|
|
313
|
-
grid-template-columns: 18px minmax(0, 1fr) auto;
|
|
314
|
-
align-items: center;
|
|
315
|
-
gap: 6px;
|
|
316
|
-
padding: 2px 6px;
|
|
317
|
-
border: 1px solid transparent;
|
|
318
|
-
border-radius: 7px;
|
|
319
|
-
background: transparent;
|
|
320
|
-
color: var(--task-text);
|
|
321
|
-
text-align: left;
|
|
322
|
-
font: inherit;
|
|
323
|
-
cursor: pointer;
|
|
324
|
-
}
|
|
325
|
-
.vault-nav-row:hover { background: var(--task-soft); }
|
|
326
|
-
.vault-nav-row.selected {
|
|
327
|
-
border-color: color-mix(in srgb, var(--task-accent) 72%, var(--task-border-strong));
|
|
328
|
-
background: var(--vault-selected);
|
|
329
|
-
}
|
|
330
|
-
.vault-nav-row > i { width: 15px; color: var(--task-muted); text-align: center; font-size: 11.5px; }
|
|
331
|
-
.vault-nav-copy { min-width: 0; }
|
|
332
|
-
.vault-nav-name {
|
|
333
|
-
display: block;
|
|
334
|
-
overflow: hidden;
|
|
335
|
-
font-size: 11.75px;
|
|
336
|
-
font-weight: 520;
|
|
337
|
-
line-height: 1.25;
|
|
338
|
-
text-overflow: ellipsis;
|
|
339
|
-
white-space: nowrap;
|
|
340
|
-
}
|
|
341
|
-
.vault-nav-path {
|
|
342
|
-
display: block;
|
|
343
|
-
overflow: hidden;
|
|
344
|
-
margin-top: 1px;
|
|
345
|
-
color: var(--task-muted);
|
|
346
|
-
font-size: 10px;
|
|
347
|
-
line-height: 1.25;
|
|
348
|
-
text-overflow: ellipsis;
|
|
349
|
-
white-space: nowrap;
|
|
350
|
-
}
|
|
351
|
-
.vault-nav-count {
|
|
352
|
-
color: var(--task-muted);
|
|
353
|
-
font-size: 11px;
|
|
354
|
-
font-variant-numeric: tabular-nums;
|
|
355
|
-
}
|
|
356
|
-
.vault-nav-count.attention { color: var(--vault-warning); font-weight: 650; }
|
|
357
|
-
#vault-locations {
|
|
358
|
-
display: flex;
|
|
359
|
-
min-height: 0;
|
|
360
|
-
flex-direction: column;
|
|
361
|
-
align-items: stretch;
|
|
362
|
-
justify-content: flex-start;
|
|
363
|
-
gap: 0;
|
|
364
|
-
}
|
|
365
|
-
.vault-source-line {
|
|
366
|
-
display: grid;
|
|
367
|
-
flex: 0 0 auto;
|
|
368
|
-
height: var(--vault-tree-row);
|
|
369
|
-
min-height: var(--vault-tree-row);
|
|
370
|
-
max-height: var(--vault-tree-row);
|
|
371
|
-
grid-template-columns: 18px minmax(0, 1fr);
|
|
372
|
-
align-items: start;
|
|
373
|
-
align-self: stretch;
|
|
374
|
-
margin: 0;
|
|
375
|
-
}
|
|
376
|
-
.vault-source-line.has-path {
|
|
377
|
-
height: var(--vault-tree-path-row);
|
|
378
|
-
min-height: var(--vault-tree-path-row);
|
|
379
|
-
max-height: var(--vault-tree-path-row);
|
|
380
|
-
}
|
|
381
|
-
.vault-source-line.depth-1 { padding-left: 12px; }
|
|
382
|
-
.vault-source-line.depth-2 { padding-left: 24px; }
|
|
383
|
-
.vault-source-toggle {
|
|
384
|
-
width: 18px;
|
|
385
|
-
height: 100%;
|
|
386
|
-
padding: 0;
|
|
387
|
-
border: 0;
|
|
388
|
-
background: transparent;
|
|
389
|
-
color: var(--task-muted);
|
|
390
|
-
cursor: pointer;
|
|
391
|
-
font-size: 10px;
|
|
392
|
-
}
|
|
393
|
-
.vault-source-toggle.placeholder { cursor: default; }
|
|
394
|
-
.vault-source-line .vault-nav-row { grid-template-columns: 18px minmax(0, 1fr) auto; }
|
|
395
|
-
.vault-source-line.has-path .vault-nav-row {
|
|
396
|
-
height: var(--vault-tree-path-row);
|
|
397
|
-
min-height: var(--vault-tree-path-row);
|
|
398
|
-
max-height: var(--vault-tree-path-row);
|
|
399
|
-
}
|
|
400
|
-
.vault-rail-footer { padding: 0 16px 12px; }
|
|
401
|
-
.vault-text-button {
|
|
402
|
-
padding: 2px 0;
|
|
403
|
-
border: 0;
|
|
404
|
-
background: transparent;
|
|
405
|
-
color: var(--task-muted);
|
|
406
|
-
font-size: 11.5px;
|
|
407
|
-
}
|
|
408
|
-
.vault-text-button:hover { color: var(--task-text); }
|
|
409
|
-
.vault-pane {
|
|
410
|
-
display: grid;
|
|
411
|
-
min-width: 0;
|
|
412
|
-
min-height: 0;
|
|
413
|
-
grid-template-rows: auto minmax(0, 1fr) auto;
|
|
414
|
-
overflow: hidden;
|
|
415
|
-
}
|
|
416
|
-
.vault-pane-action-note { color: var(--task-muted); font-size: 11.5px; white-space: nowrap; }
|
|
417
|
-
.vault-toolbar {
|
|
418
|
-
display: flex;
|
|
419
|
-
align-items: center;
|
|
420
|
-
gap: 8px;
|
|
421
|
-
min-height: 44px;
|
|
422
|
-
padding: 6px var(--vault-inline);
|
|
423
|
-
border-bottom: 1px solid var(--task-border);
|
|
424
|
-
}
|
|
425
|
-
.vault-search { position: relative; width: min(320px, 55%); }
|
|
426
|
-
.vault-search i {
|
|
427
|
-
position: absolute;
|
|
428
|
-
top: 50%;
|
|
429
|
-
left: 10px;
|
|
430
|
-
color: var(--task-muted);
|
|
431
|
-
font-size: 12px;
|
|
432
|
-
transform: translateY(-50%);
|
|
433
|
-
pointer-events: none;
|
|
434
|
-
}
|
|
435
|
-
.vault-search input,
|
|
436
|
-
.vault-select {
|
|
437
|
-
height: 30px;
|
|
438
|
-
box-sizing: border-box;
|
|
439
|
-
border: 1px solid var(--task-border-strong);
|
|
440
|
-
border-radius: 7px;
|
|
441
|
-
background: var(--task-panel);
|
|
442
|
-
color: var(--task-text);
|
|
443
|
-
font: inherit;
|
|
444
|
-
font-size: 12px;
|
|
445
|
-
}
|
|
446
|
-
.vault-search input { width: 100%; padding: 5px 10px 5px 30px; }
|
|
447
|
-
.vault-search input::placeholder { color: var(--task-muted); }
|
|
448
|
-
.vault-select { padding: 4px 28px 4px 9px; }
|
|
449
|
-
.vault-toolbar-count { margin-left: auto; color: var(--task-muted); font-size: 11.5px; white-space: nowrap; }
|
|
450
|
-
.vault-table { min-width: 660px; }
|
|
451
|
-
.vault-table-wrap {
|
|
452
|
-
min-height: 0;
|
|
453
|
-
overflow: auto;
|
|
454
|
-
overscroll-behavior: contain;
|
|
455
|
-
}
|
|
456
|
-
.vault-columns,
|
|
457
|
-
.vault-file-row,
|
|
458
|
-
.vault-group-row {
|
|
459
|
-
display: grid;
|
|
460
|
-
grid-template-columns: minmax(220px, 1.6fr) minmax(64px, .4fr) minmax(118px, .8fr) minmax(96px, .6fr) minmax(76px, auto);
|
|
461
|
-
align-items: center;
|
|
462
|
-
column-gap: 10px;
|
|
463
|
-
}
|
|
464
|
-
.vault-table.matches .vault-columns,
|
|
465
|
-
.vault-table.matches .vault-file-row,
|
|
466
|
-
.vault-table.matches .vault-group-row {
|
|
467
|
-
grid-template-columns: minmax(210px, 1.5fr) minmax(64px, .4fr) minmax(140px, 1fr) minmax(96px, .55fr) minmax(76px, auto);
|
|
468
|
-
}
|
|
469
|
-
.vault-columns {
|
|
470
|
-
min-height: 34px;
|
|
471
|
-
padding: 0 var(--vault-inline);
|
|
472
|
-
border-bottom: 1px solid var(--task-border);
|
|
473
|
-
color: var(--task-muted);
|
|
474
|
-
font-size: 10.5px;
|
|
475
|
-
font-weight: 600;
|
|
476
|
-
}
|
|
477
|
-
.vault-file-row {
|
|
478
|
-
min-height: var(--vault-row-height);
|
|
479
|
-
padding: 6px var(--vault-inline);
|
|
480
|
-
border-bottom: 1px solid var(--task-border);
|
|
481
|
-
font-size: 12px;
|
|
482
|
-
}
|
|
483
|
-
.vault-file-row:hover { background: color-mix(in srgb, var(--task-soft) 64%, transparent); }
|
|
484
|
-
.vault-file-row.directory { min-height: 38px; padding-top: 4px; padding-bottom: 4px; background: color-mix(in srgb, var(--task-soft) 38%, transparent); }
|
|
485
|
-
.vault-group-row {
|
|
486
|
-
min-height: 48px;
|
|
487
|
-
padding: 6px var(--vault-inline);
|
|
488
|
-
border-bottom: 1px solid var(--task-border-strong);
|
|
489
|
-
background: color-mix(in srgb, var(--task-soft) 56%, transparent);
|
|
490
|
-
}
|
|
491
|
-
.vault-group-main { min-width: 0; }
|
|
492
|
-
.vault-group-title {
|
|
493
|
-
display: flex;
|
|
494
|
-
min-width: 0;
|
|
495
|
-
align-items: center;
|
|
496
|
-
gap: 8px;
|
|
497
|
-
color: var(--task-text);
|
|
498
|
-
font-size: 12.5px;
|
|
499
|
-
font-weight: 600;
|
|
500
|
-
}
|
|
501
|
-
.vault-group-title span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
502
|
-
.vault-group-meta { margin: 2px 0 0 23px; color: var(--task-muted); font-size: 10px; }
|
|
503
|
-
.vault-group-action { grid-column: 5; justify-self: end; }
|
|
504
|
-
.vault-group-action .vault-button { min-height: 28px; padding: 4px 9px; }
|
|
505
|
-
.vault-name-cell { display: flex; min-width: 0; align-items: center; gap: 8px; }
|
|
506
|
-
.vault-name-cell.indent-1 { padding-left: 18px; }
|
|
507
|
-
.vault-name-cell.indent-2 { padding-left: 36px; }
|
|
508
|
-
.vault-name-icon { width: 16px; flex: 0 0 16px; color: var(--task-muted); text-align: center; }
|
|
509
|
-
.vault-disclosure {
|
|
510
|
-
width: 16px;
|
|
511
|
-
height: 24px;
|
|
512
|
-
flex: 0 0 16px;
|
|
513
|
-
padding: 0;
|
|
514
|
-
border: 0;
|
|
515
|
-
background: transparent;
|
|
516
|
-
color: var(--task-muted);
|
|
517
|
-
cursor: pointer;
|
|
518
|
-
font-size: 10px;
|
|
519
|
-
}
|
|
520
|
-
.vault-name-copy { min-width: 0; }
|
|
521
|
-
.vault-file-name {
|
|
522
|
-
display: block;
|
|
523
|
-
overflow: hidden;
|
|
524
|
-
color: var(--task-text);
|
|
525
|
-
font-size: 12px;
|
|
526
|
-
font-weight: 520;
|
|
527
|
-
line-height: 1.3;
|
|
528
|
-
text-overflow: ellipsis;
|
|
529
|
-
white-space: nowrap;
|
|
530
|
-
}
|
|
531
|
-
.vault-file-path {
|
|
532
|
-
display: block;
|
|
533
|
-
overflow: hidden;
|
|
534
|
-
color: var(--task-muted);
|
|
535
|
-
font-size: 10.5px;
|
|
536
|
-
line-height: 1.3;
|
|
537
|
-
text-overflow: ellipsis;
|
|
538
|
-
white-space: nowrap;
|
|
539
|
-
}
|
|
540
|
-
.vault-match-path {
|
|
541
|
-
display: block;
|
|
542
|
-
min-width: 0;
|
|
543
|
-
color: var(--task-muted);
|
|
544
|
-
font-size: 10.5px;
|
|
545
|
-
line-height: 1.35;
|
|
546
|
-
overflow-wrap: anywhere;
|
|
547
|
-
white-space: normal;
|
|
548
|
-
word-break: break-word;
|
|
549
|
-
}
|
|
550
|
-
.vault-size,
|
|
551
|
-
.vault-space { color: var(--task-muted); font-variant-numeric: tabular-nums; white-space: nowrap; }
|
|
552
|
-
.vault-status { display: inline-flex; min-width: 0; align-items: center; gap: 7px; color: var(--task-text); }
|
|
553
|
-
.vault-status-dot { width: 6px; height: 6px; flex: 0 0 6px; border-radius: 50%; background: var(--task-muted); }
|
|
554
|
-
.vault-status-dot.warning { background: var(--vault-warning); }
|
|
555
|
-
.vault-status i { width: 12px; color: var(--task-muted); text-align: center; }
|
|
556
|
-
.vault-row-action { justify-self: end; }
|
|
557
|
-
.vault-row-action .vault-text-button { color: var(--task-text); font-size: 11.5px; }
|
|
558
|
-
.vault-detail {
|
|
559
|
-
padding: 8px var(--vault-inline) 9px 56px;
|
|
560
|
-
border-bottom: 1px solid var(--task-border);
|
|
561
|
-
background: color-mix(in srgb, var(--task-soft) 48%, transparent);
|
|
562
|
-
}
|
|
563
|
-
.vault-detail-label { margin-bottom: 5px; color: var(--task-muted); font-size: 10px; font-weight: 650; }
|
|
564
|
-
.vault-location-detail { display: flex; align-items: center; gap: 7px; padding: 2px 0; color: var(--task-text); font-size: 10.5px; }
|
|
565
|
-
.vault-location-detail i { color: var(--task-muted); }
|
|
566
|
-
.vault-empty {
|
|
567
|
-
display: grid;
|
|
568
|
-
min-height: 190px;
|
|
569
|
-
place-items: center;
|
|
570
|
-
padding: 28px;
|
|
571
|
-
text-align: center;
|
|
572
|
-
}
|
|
573
|
-
.vault-empty-inner { max-width: 420px; }
|
|
574
|
-
.vault-empty i { color: var(--task-muted); font-size: 18px; }
|
|
575
|
-
.vault-empty h3 { margin: 9px 0 4px; font-size: 14px; }
|
|
576
|
-
.vault-empty p { margin: 0; color: var(--task-muted); font-size: 12px; line-height: 1.55; }
|
|
577
|
-
.vault-empty .vault-button { margin-top: 10px; }
|
|
578
|
-
.vault-pane-footer {
|
|
579
|
-
padding: 9px var(--vault-inline) 11px;
|
|
580
|
-
color: var(--task-muted);
|
|
581
|
-
font-size: 10.5px;
|
|
582
|
-
}
|
|
583
|
-
.vault-event-kind { font-weight: 570; }
|
|
584
|
-
.vault-event-time { color: var(--task-muted); font-size: 10.5px; }
|
|
585
|
-
.vault-unavailable { color: var(--task-muted); font-size: 11px; }
|
|
586
|
-
@media (max-width: 1140px) {
|
|
587
|
-
.vault-overview { align-items: flex-start; flex-direction: column; }
|
|
588
|
-
.vault-overview-actions { width: 100%; justify-content: flex-start; }
|
|
589
|
-
.vault-metric { padding-right: 12px; padding-left: 12px; }
|
|
590
|
-
.vault-explorer { grid-template-columns: 224px minmax(0, 1fr); }
|
|
591
|
-
}
|
|
592
|
-
@media (max-width: 820px) {
|
|
593
|
-
body.vault-page .task-container { display: block; overflow-y: auto; }
|
|
594
|
-
.vault-shell,
|
|
595
|
-
.vault-shell .task-shell-body,
|
|
596
|
-
.vault-body { height: auto; overflow: visible; }
|
|
597
|
-
.vault-metrics { width: 100%; overflow-x: auto; }
|
|
598
|
-
.vault-explorer { display: block; overflow: visible; }
|
|
599
|
-
.vault-rail { max-height: 330px; border-right: 0; border-bottom: 1px solid var(--task-border); }
|
|
600
|
-
.vault-pane { display: block; overflow: visible; }
|
|
601
|
-
.vault-table-wrap { overflow-x: auto; overflow-y: visible; }
|
|
602
|
-
}
|
|
603
|
-
@media (pointer: coarse) {
|
|
604
|
-
.vault-button,
|
|
605
|
-
.vault-result .vault-button,
|
|
606
|
-
.vault-group-action .vault-button { min-height: 40px; }
|
|
607
|
-
.vault-icon-button { width: 40px; height: 40px; }
|
|
608
|
-
.vault-rail-add { width: 28px; height: 28px; flex-basis: 28px; }
|
|
609
|
-
}
|
|
610
|
-
@media (prefers-reduced-motion: reduce) {
|
|
611
|
-
.vault-page * { scroll-behavior: auto !important; }
|
|
612
|
-
.vault-progress-bar { transition: none; }
|
|
613
|
-
.vault-progress-bar.indeterminate {
|
|
614
|
-
animation: none;
|
|
615
|
-
opacity: .5;
|
|
616
|
-
transform: scaleX(1);
|
|
617
|
-
}
|
|
618
|
-
}
|
|
619
|
-
</style>
|
|
15
|
+
<link href="/vault.css" rel="stylesheet"/>
|
|
620
16
|
</head>
|
|
621
|
-
<body class='<%=theme%> main-sidebar-page task-launcher-page task-page vault-page' data-agent="<%=agent%>">
|
|
17
|
+
<body class='<%=theme%> main-sidebar-page task-launcher-page task-page vault-page' data-agent="<%=agent%>" data-vault-mode="global">
|
|
622
18
|
<%- include('partials/app_navheader', { agent }) %>
|
|
623
19
|
<main>
|
|
624
20
|
<div class='task-container'>
|
|
625
|
-
|
|
626
|
-
<div class='task-shell-body'>
|
|
627
|
-
<div class='vault-body'>
|
|
628
|
-
<section class='vault-overview'>
|
|
629
|
-
<div class='vault-metrics' id='vault-metrics'></div>
|
|
630
|
-
<div class='vault-overview-actions'>
|
|
631
|
-
<button class='vault-button primary' id='btn-scan' type='button'></button>
|
|
632
|
-
<details class='vault-advanced' id='vault-advanced'>
|
|
633
|
-
<summary class='vault-icon-button' id='btn-vault-options'><i class='fa-solid fa-ellipsis'></i></summary>
|
|
634
|
-
<div class='vault-advanced-menu'>
|
|
635
|
-
<div class='vault-advanced-title' id='vault-repair-title'></div>
|
|
636
|
-
<p class='vault-advanced-copy' id='vault-repair-description'></p>
|
|
637
|
-
<button class='vault-button' id='btn-repair' type='button'></button>
|
|
638
|
-
</div>
|
|
639
|
-
</details>
|
|
640
|
-
</div>
|
|
641
|
-
</section>
|
|
642
|
-
<div class='vault-scan-state' id='vault-scan-state'></div>
|
|
643
|
-
<div class='vault-result' id='vault-result'></div>
|
|
644
|
-
<div class='vault-feedback' id='vault-feedback'></div>
|
|
645
|
-
<section class='vault-explorer' id='vault-explorer'>
|
|
646
|
-
<aside class='vault-rail'>
|
|
647
|
-
<div class='vault-rail-section'>
|
|
648
|
-
<div class='vault-rail-label' id='views-label'></div>
|
|
649
|
-
<div class='vault-nav-list' id='vault-views'></div>
|
|
650
|
-
</div>
|
|
651
|
-
<div class='vault-rail-section'>
|
|
652
|
-
<div class='vault-rail-heading'>
|
|
653
|
-
<div class='vault-rail-label' id='locations-label'></div>
|
|
654
|
-
<button class='vault-rail-add' id='btn-add-source' type='button' aria-label='Add external folder' title='Add external folder'><i class='fa-solid fa-plus'></i></button>
|
|
655
|
-
</div>
|
|
656
|
-
<div id='vault-locations'></div>
|
|
657
|
-
</div>
|
|
658
|
-
<div class='vault-rail-footer' id='vault-rail-footer'></div>
|
|
659
|
-
</aside>
|
|
660
|
-
<section class='vault-pane' aria-label='Vault files'>
|
|
661
|
-
<div class='vault-toolbar' id='vault-toolbar'></div>
|
|
662
|
-
<div class='vault-table-wrap' id='vault-table-wrap'></div>
|
|
663
|
-
<footer class='vault-pane-footer' id='vault-pane-footer'></footer>
|
|
664
|
-
</section>
|
|
665
|
-
</section>
|
|
666
|
-
</div>
|
|
667
|
-
</div>
|
|
668
|
-
</section>
|
|
21
|
+
<%- include('partials/vault_workspace', { appMode: false }) %>
|
|
669
22
|
</div>
|
|
670
23
|
<%- include('partials/main_sidebar', { selected: 'vault' }) %>
|
|
671
24
|
</main>
|
|
672
25
|
<script src="/Socket.js"></script>
|
|
673
|
-
<script>
|
|
674
|
-
|
|
675
|
-
title: "Vault",
|
|
676
|
-
subtitle: "One copy on disk, shared by every app that uses it. Nothing is scanned or changed unless you ask.",
|
|
677
|
-
views: "VIEWS",
|
|
678
|
-
locations: "LOCATIONS",
|
|
679
|
-
all: "All tracked files",
|
|
680
|
-
duplicates: "Duplicates",
|
|
681
|
-
shared: "Shared",
|
|
682
|
-
independent: "Independent",
|
|
683
|
-
reclaimable: "Reclaimable",
|
|
684
|
-
activity: "Activity",
|
|
685
|
-
pinokio: "Pinokio",
|
|
686
|
-
apps: "Apps",
|
|
687
|
-
external: "External folders",
|
|
688
|
-
add_external_folder: "Add external folder",
|
|
689
|
-
external_added: "Added to Locations. Run a scan when you’re ready.",
|
|
690
|
-
external_exists: "That folder is already in Locations.",
|
|
691
|
-
external_other_disk: "Added to Locations. It can be scanned, but this disk cannot share space with the Vault.",
|
|
692
|
-
on_disk: "On disk",
|
|
693
|
-
saved: "Saved",
|
|
694
|
-
already_shared: "Already shared",
|
|
695
|
-
already_shared_help: "Space already avoided by files sharing the same data",
|
|
696
|
-
saved_by_vault: "Saved by Vault",
|
|
697
|
-
saved_by_vault_help: "Space saved through your Deduplicate actions",
|
|
698
|
-
can_save: "Can save",
|
|
699
|
-
pinokio_folder: "Pinokio folder",
|
|
700
|
-
last_scanned: "Last scanned",
|
|
701
|
-
never: "Never",
|
|
702
|
-
scan: "Scan now",
|
|
703
|
-
scan_again: "Scan again",
|
|
704
|
-
scanning: "Scanning…",
|
|
705
|
-
vault_options: "Vault options",
|
|
706
|
-
repair_index: "Repair Vault index",
|
|
707
|
-
repair_action: "Repair index",
|
|
708
|
-
repair_description: "Reconstruct Vault’s internal records if tracked files or sharing status look incorrect. This does not scan for new duplicates.",
|
|
709
|
-
repairing: "Repairing…",
|
|
710
|
-
repair_done: "Vault index repaired",
|
|
711
|
-
scan_progress: "Scanning your configured locations",
|
|
712
|
-
scan_analyzing: "Analyzing large files",
|
|
713
|
-
scan_finishing: "Finishing scan",
|
|
714
|
-
scan_estimate_help: "Estimated from your last completed scan",
|
|
715
|
-
scan_folders: "folders checked",
|
|
716
|
-
scan_files: "files checked",
|
|
717
|
-
analyzing: "analyzing",
|
|
718
|
-
waiting: "files waiting",
|
|
719
|
-
scan_complete: "Scan complete",
|
|
720
|
-
found_in: "found in",
|
|
721
|
-
locations_lower: "locations",
|
|
722
|
-
can_be_saved: "can be saved",
|
|
723
|
-
review: "Review",
|
|
724
|
-
search_all: "Search tracked files",
|
|
725
|
-
search_duplicates: "Search duplicates",
|
|
726
|
-
search_shared: "Search shared files",
|
|
727
|
-
search_independent: "Search independent files",
|
|
728
|
-
search_activity: "Search activity",
|
|
729
|
-
all_statuses: "All statuses",
|
|
730
|
-
by_location: "By location",
|
|
731
|
-
name: "Name",
|
|
732
|
-
size: "Size",
|
|
733
|
-
status: "Vault status",
|
|
734
|
-
matches: "Matches",
|
|
735
|
-
space: "Space",
|
|
736
|
-
duplicate: "Duplicate",
|
|
737
|
-
tracked: "Tracked",
|
|
738
|
-
different_disk: "Different disk",
|
|
739
|
-
saved_suffix: "saved",
|
|
740
|
-
can_save_suffix: "can save",
|
|
741
|
-
unavailable: "Unavailable",
|
|
742
|
-
sharing_unavailable: "Sharing is unavailable on this disk",
|
|
743
|
-
without_sharing: "without sharing",
|
|
744
|
-
saved_all_time: "saved all-time",
|
|
745
|
-
just_now: "Just now",
|
|
746
|
-
minutes_ago: "m ago",
|
|
747
|
-
hours_ago: "h ago",
|
|
748
|
-
event: "event",
|
|
749
|
-
events: "events",
|
|
750
|
-
unknown_location: "Unknown location",
|
|
751
|
-
expand: "Expand",
|
|
752
|
-
collapse: "Collapse",
|
|
753
|
-
file: "file",
|
|
754
|
-
files: "files",
|
|
755
|
-
location: "location",
|
|
756
|
-
deduplicate: "Deduplicate",
|
|
757
|
-
ignore: "Ignore",
|
|
758
|
-
make_independent: "Make independent",
|
|
759
|
-
allow_sharing: "Allow sharing again",
|
|
760
|
-
reclaim: "Reclaim",
|
|
761
|
-
reclaim_all: "Reclaim all",
|
|
762
|
-
undo: "Undo",
|
|
763
|
-
identical_contents_at: "Identical contents at",
|
|
764
|
-
no_files: "Nothing tracked yet",
|
|
765
|
-
no_files_hint: "Run a scan to find large files. Scanning never changes them.",
|
|
766
|
-
scan_waiting: "Waiting for scan results",
|
|
767
|
-
scan_waiting_hint: "Tracked files will appear here when this scan finishes.",
|
|
768
|
-
no_duplicates: "Everything is already shared",
|
|
769
|
-
no_duplicates_hint: "There are no files waiting for your review.",
|
|
770
|
-
no_shared: "Nothing is shared yet",
|
|
771
|
-
no_shared_hint: "Shared files will appear here after you review duplicates.",
|
|
772
|
-
no_independent: "No independent files",
|
|
773
|
-
no_independent_hint: "Files you choose to keep separate will appear here.",
|
|
774
|
-
no_reclaimable: "Nothing to reclaim",
|
|
775
|
-
no_reclaimable_hint: "Files no longer used by any configured location will appear here.",
|
|
776
|
-
no_activity: "No activity yet",
|
|
777
|
-
no_activity_hint: "Scans and actions will be recorded here.",
|
|
778
|
-
view_all: "View all tracked files",
|
|
779
|
-
show_all_locations: "Show all locations",
|
|
780
|
-
tracked_note: "Only tracked files 100 MB and larger appear here. Files keep their current locations.",
|
|
781
|
-
duplicate_note: "Only files waiting for review are shown.",
|
|
782
|
-
disabled: "The vault is turned off (PINOKIO_VAULT=false).",
|
|
783
|
-
done: "Done",
|
|
784
|
-
converted: "Deduplicated",
|
|
785
|
-
ignored: "Kept independent",
|
|
786
|
-
reclaimed: "Reclaimed",
|
|
787
|
-
allowed: "Sharing allowed again",
|
|
788
|
-
event_convert: "Deduplicated",
|
|
789
|
-
event_found: "Duplicate found",
|
|
790
|
-
event_adopt: "Added to your vault",
|
|
791
|
-
event_reclaim: "Reclaimed",
|
|
792
|
-
event_undo: "Undid deduplication",
|
|
793
|
-
event_diverged: "Changed by an app — no longer shared",
|
|
794
|
-
event_detach: "Made independent",
|
|
795
|
-
event_reshare: "Sharing allowed again"
|
|
796
|
-
}
|
|
797
|
-
|
|
798
|
-
const state = {
|
|
799
|
-
data: null,
|
|
800
|
-
view: "all",
|
|
801
|
-
sourceId: null,
|
|
802
|
-
query: "",
|
|
803
|
-
statusFilter: "all",
|
|
804
|
-
collapsedSources: new Set(),
|
|
805
|
-
collapsedDirs: new Set(),
|
|
806
|
-
expandedFiles: new Set(),
|
|
807
|
-
scanRequested: false,
|
|
808
|
-
scanBaseline: null,
|
|
809
|
-
scanResult: null,
|
|
810
|
-
feedback: null
|
|
811
|
-
}
|
|
812
|
-
|
|
813
|
-
const el = (id) => document.getElementById(id)
|
|
814
|
-
const esc = (value) => String(value == null ? "" : value).replace(/[&<>"']/g, (char) => ({
|
|
815
|
-
"&": "&", "<": "<", ">": ">", '"': """, "'": "'"
|
|
816
|
-
}[char]))
|
|
817
|
-
const attr = esc
|
|
818
|
-
const fmt = (value) => {
|
|
819
|
-
const n = Number(value) || 0
|
|
820
|
-
if (!n) return "0 B"
|
|
821
|
-
const units = ["B", "KB", "MB", "GB", "TB"]
|
|
822
|
-
const index = Math.min(units.length - 1, Math.floor(Math.log(n) / Math.log(1024)))
|
|
823
|
-
const digits = index > 1 ? 1 : 0
|
|
824
|
-
return `${(n / Math.pow(1024, index)).toFixed(digits)} ${units[index]}`
|
|
825
|
-
}
|
|
826
|
-
const countLabel = (count, singular = COPY.file, plural = COPY.files) => `${count} ${count === 1 ? singular : plural}`
|
|
827
|
-
const basename = (value) => String(value || "").split(/[\\/]/).filter(Boolean).pop() || ""
|
|
828
|
-
const dirname = (value) => {
|
|
829
|
-
const parts = String(value || "").split("/").filter(Boolean)
|
|
830
|
-
parts.pop()
|
|
831
|
-
return parts.join(" / ")
|
|
832
|
-
}
|
|
833
|
-
const timeAgo = (ts) => {
|
|
834
|
-
if (!ts) return COPY.never
|
|
835
|
-
const seconds = Math.max(0, Math.floor((Date.now() - ts) / 1000))
|
|
836
|
-
if (seconds < 60) return COPY.just_now
|
|
837
|
-
const minutes = Math.floor(seconds / 60)
|
|
838
|
-
if (minutes < 60) return `${minutes}${COPY.minutes_ago}`
|
|
839
|
-
const hours = Math.floor(minutes / 60)
|
|
840
|
-
if (hours < 24) return `${hours}${COPY.hours_ago}`
|
|
841
|
-
return new Date(ts).toLocaleDateString()
|
|
842
|
-
}
|
|
843
|
-
const post = async (payload) => {
|
|
844
|
-
const response = await fetch("/vault/action", {
|
|
845
|
-
method: "POST",
|
|
846
|
-
headers: { "Content-Type": "application/json" },
|
|
847
|
-
body: JSON.stringify(payload)
|
|
848
|
-
})
|
|
849
|
-
return response.json()
|
|
850
|
-
}
|
|
851
|
-
const sourceById = (id) => (state.data.sources || []).find((source) => source.id === id)
|
|
852
|
-
const sourceChildren = (id) => (state.data.sources || []).filter((source) => source.parent_id === id)
|
|
853
|
-
const sourcePath = (source) => {
|
|
854
|
-
if (!source) return ""
|
|
855
|
-
if (source.kind === "pinokio") return `~/${basename(source.root)}`
|
|
856
|
-
if (source.kind === "external") return source.target_path || source.display_path || ""
|
|
857
|
-
if (source.kind === "app") return ""
|
|
858
|
-
return source.display_path || source.root || ""
|
|
859
|
-
}
|
|
860
|
-
const isDescendantSource = (candidateId, parentId) => {
|
|
861
|
-
if (!parentId) return true
|
|
862
|
-
if (candidateId === parentId) return true
|
|
863
|
-
let current = sourceById(candidateId)
|
|
864
|
-
const seen = new Set()
|
|
865
|
-
while (current && current.parent_id && !seen.has(current.id)) {
|
|
866
|
-
if (current.parent_id === parentId) return true
|
|
867
|
-
seen.add(current.id)
|
|
868
|
-
current = sourceById(current.parent_id)
|
|
869
|
-
}
|
|
870
|
-
return false
|
|
871
|
-
}
|
|
872
|
-
|
|
873
|
-
const buildItems = () => {
|
|
874
|
-
const data = state.data
|
|
875
|
-
const items = []
|
|
876
|
-
const duplicatePaths = new Set(data.duplicates.map((item) => item.path))
|
|
877
|
-
const excludedPaths = new Set(data.excluded.map((item) => item.path))
|
|
878
|
-
for (const blob of data.blobs) {
|
|
879
|
-
const linkedNames = blob.names.filter((name) => name.mode === "link")
|
|
880
|
-
for (const name of blob.names) {
|
|
881
|
-
if (duplicatePaths.has(name.path) || excludedPaths.has(name.path)) continue
|
|
882
|
-
const shared = name.mode === "link" && linkedNames.length > 1
|
|
883
|
-
items.push({
|
|
884
|
-
kind: "file", path: name.path, relative_path: name.relative_path || basename(name.path),
|
|
885
|
-
source_id: name.source_id, source_kind: name.source_kind, source_label: name.source_label,
|
|
886
|
-
size: blob.size || 0, hash: blob.hash, blob, status: shared ? "shared" : "tracked",
|
|
887
|
-
locations: blob.names, saved: shared ? (blob.size || 0) * Math.max(1, linkedNames.length - 1) : 0
|
|
888
|
-
})
|
|
889
|
-
}
|
|
890
|
-
}
|
|
891
|
-
for (const duplicate of data.duplicates) {
|
|
892
|
-
items.push({
|
|
893
|
-
kind: "file", path: duplicate.path, relative_path: duplicate.relative_path || basename(duplicate.path),
|
|
894
|
-
source_id: duplicate.source_id, source_kind: duplicate.source_kind, source_label: duplicate.source_label,
|
|
895
|
-
size: duplicate.size || 0, hash: duplicate.hash, status: "duplicate",
|
|
896
|
-
shareable: duplicate.shareable !== false, matches: duplicate.matches || [], saved: 0
|
|
897
|
-
})
|
|
898
|
-
}
|
|
899
|
-
for (const independent of data.excluded) {
|
|
900
|
-
items.push({
|
|
901
|
-
kind: "file", path: independent.path, relative_path: independent.relative_path || basename(independent.path),
|
|
902
|
-
source_id: independent.source_id, source_kind: independent.source_kind, source_label: independent.source_label,
|
|
903
|
-
size: independent.size || 0, status: "independent", saved: 0
|
|
904
|
-
})
|
|
905
|
-
}
|
|
906
|
-
return items
|
|
907
|
-
}
|
|
908
|
-
|
|
909
|
-
const getCounts = (items) => ({
|
|
910
|
-
all: items.length,
|
|
911
|
-
duplicates: items.filter((item) => item.status === "duplicate").length,
|
|
912
|
-
shared: items.filter((item) => item.status === "shared").length,
|
|
913
|
-
independent: items.filter((item) => item.status === "independent").length,
|
|
914
|
-
reclaimable: state.data.blobs.filter((blob) => blob.orphan).length,
|
|
915
|
-
activity: state.data.events.length
|
|
916
|
-
})
|
|
917
|
-
|
|
918
|
-
const sourceDuplicateCount = (id) => state.data.duplicates.filter((item) => isDescendantSource(item.source_id, id)).length
|
|
919
|
-
const sourceTrackedCount = (id, items) => items.filter((item) => isDescendantSource(item.source_id, id)).length
|
|
920
|
-
const activeItems = (items) => {
|
|
921
|
-
let result = items
|
|
922
|
-
if (state.view === "duplicates") result = result.filter((item) => item.status === "duplicate")
|
|
923
|
-
if (state.view === "shared") result = result.filter((item) => item.status === "shared")
|
|
924
|
-
if (state.view === "independent") result = result.filter((item) => item.status === "independent")
|
|
925
|
-
if (state.sourceId) result = result.filter((item) => isDescendantSource(item.source_id, state.sourceId))
|
|
926
|
-
if (state.view === "all" && state.statusFilter !== "all") result = result.filter((item) => item.status === state.statusFilter)
|
|
927
|
-
const query = state.query.trim().toLowerCase()
|
|
928
|
-
if (query) result = result.filter((item) => `${item.relative_path} ${item.path} ${item.source_label || ""}`.toLowerCase().includes(query))
|
|
929
|
-
return result
|
|
930
|
-
}
|
|
931
|
-
|
|
932
|
-
const viewIcon = {
|
|
933
|
-
all: "fa-regular fa-file-lines",
|
|
934
|
-
duplicates: "fa-regular fa-copy",
|
|
935
|
-
shared: "fa-solid fa-link",
|
|
936
|
-
independent: "fa-solid fa-code-branch",
|
|
937
|
-
reclaimable: "fa-regular fa-trash-can",
|
|
938
|
-
activity: "fa-solid fa-wave-square"
|
|
939
|
-
}
|
|
940
|
-
const viewLabel = {
|
|
941
|
-
all: COPY.all,
|
|
942
|
-
duplicates: COPY.duplicates,
|
|
943
|
-
shared: COPY.shared,
|
|
944
|
-
independent: COPY.independent,
|
|
945
|
-
reclaimable: COPY.reclaimable,
|
|
946
|
-
activity: COPY.activity
|
|
947
|
-
}
|
|
948
|
-
|
|
949
|
-
const renderViews = (items) => {
|
|
950
|
-
const counts = getCounts(items)
|
|
951
|
-
el("views-label").textContent = COPY.views
|
|
952
|
-
const views = ["all", "duplicates", "shared", "independent", "reclaimable", "activity"]
|
|
953
|
-
el("vault-views").innerHTML = views.map((view) => `
|
|
954
|
-
<button class="vault-nav-row ${state.view === view ? "selected" : ""}" type="button" data-view="${view}">
|
|
955
|
-
<i class="${viewIcon[view]}"></i>
|
|
956
|
-
<span class="vault-nav-copy"><span class="vault-nav-name">${esc(viewLabel[view])}</span></span>
|
|
957
|
-
<span class="vault-nav-count ${view === "duplicates" && counts[view] ? "attention" : ""}">${counts[view]}</span>
|
|
958
|
-
</button>`).join("")
|
|
959
|
-
}
|
|
960
|
-
|
|
961
|
-
const renderSourceNode = (source, depth, items) => {
|
|
962
|
-
const children = sourceChildren(source.id).filter((child) => {
|
|
963
|
-
const count = state.view === "duplicates" ? sourceDuplicateCount(child.id) : sourceTrackedCount(child.id, items)
|
|
964
|
-
return child.kind === "virtual" || count > 0 || (child.kind === "external" && child.available)
|
|
965
|
-
})
|
|
966
|
-
const duplicateCount = sourceDuplicateCount(source.id)
|
|
967
|
-
const trackedCount = sourceTrackedCount(source.id, items)
|
|
968
|
-
if (state.view === "duplicates" && duplicateCount === 0) return ""
|
|
969
|
-
const collapsed = state.collapsedSources.has(source.id)
|
|
970
|
-
const pathText = source.kind === "virtual" ? (source.id === "apps" ? "api" : "") : sourcePath(source)
|
|
971
|
-
const count = state.view === "duplicates" ? duplicateCount : trackedCount
|
|
972
|
-
const icon = source.kind === "app" ? "fa-regular fa-folder-open" : "fa-regular fa-folder"
|
|
973
|
-
let html = `<div class="vault-source-line depth-${Math.min(depth, 2)} ${pathText ? "has-path" : ""}">`
|
|
974
|
-
if (children.length) {
|
|
975
|
-
html += `<button class="vault-source-toggle" type="button" data-toggle-source="${attr(source.id)}" aria-label="${collapsed ? COPY.expand : COPY.collapse}"><i class="fa-solid fa-chevron-${collapsed ? "right" : "down"}"></i></button>`
|
|
976
|
-
} else {
|
|
977
|
-
html += `<span class="vault-source-toggle placeholder"></span>`
|
|
978
|
-
}
|
|
979
|
-
html += `<button class="vault-nav-row ${state.sourceId === source.id ? "selected" : ""}" type="button" data-source="${attr(source.id)}">
|
|
980
|
-
<i class="${icon}"></i>
|
|
981
|
-
<span class="vault-nav-copy"><span class="vault-nav-name">${esc(source.label)}</span>${pathText ? `<span class="vault-nav-path" title="${attr(pathText)}">${esc(pathText)}</span>` : ""}</span>
|
|
982
|
-
<span class="vault-nav-count ${duplicateCount ? "attention" : ""}">${count || ""}</span>
|
|
983
|
-
</button></div>`
|
|
984
|
-
if (!collapsed) html += children.map((child) => renderSourceNode(child, depth + 1, items)).join("")
|
|
985
|
-
return html
|
|
986
|
-
}
|
|
987
|
-
|
|
988
|
-
const renderLocations = (items) => {
|
|
989
|
-
el("locations-label").textContent = COPY.locations
|
|
990
|
-
const pinokio = sourceById("pinokio")
|
|
991
|
-
const external = sourceById("external")
|
|
992
|
-
let html = pinokio ? renderSourceNode(pinokio, 0, items) : ""
|
|
993
|
-
if (external && sourceChildren("external").length) html += renderSourceNode(external, 0, items)
|
|
994
|
-
el("vault-locations").innerHTML = html
|
|
995
|
-
el("vault-rail-footer").innerHTML = state.view === "duplicates"
|
|
996
|
-
? `<button class="vault-text-button" type="button" data-view="all">${esc(COPY.show_all_locations)}</button>`
|
|
997
|
-
: ""
|
|
998
|
-
}
|
|
999
|
-
|
|
1000
|
-
const selectedSource = () => state.sourceId ? sourceById(state.sourceId) : null
|
|
1001
|
-
const scopeDuplicates = (sourceId) => state.data.duplicates.filter((item) => item.source_id === sourceId)
|
|
1002
|
-
const batchAction = (source) => {
|
|
1003
|
-
if (!source || source.kind === "virtual" || source.kind === "pinokio") return ""
|
|
1004
|
-
const duplicates = scopeDuplicates(source.id)
|
|
1005
|
-
if (!duplicates.length) return ""
|
|
1006
|
-
const shareable = duplicates.filter((item) => item.shareable !== false)
|
|
1007
|
-
if (!source.shareable || !shareable.length) return `<div class="vault-pane-action-note">${esc(COPY.sharing_unavailable)}</div>`
|
|
1008
|
-
return `<button class="vault-button" type="button" data-deduplicate-scope="${attr(source.id)}">${esc(COPY.deduplicate)} ${countLabel(shareable.length)}</button>`
|
|
1009
|
-
}
|
|
1010
|
-
|
|
1011
|
-
const toolbarSummary = (visibleItems) => {
|
|
1012
|
-
if (state.view === "duplicates") {
|
|
1013
|
-
const locations = new Set(visibleItems.map((item) => item.source_id).filter(Boolean)).size
|
|
1014
|
-
const bytes = visibleItems.filter((item) => item.shareable).reduce((sum, item) => sum + item.size, 0)
|
|
1015
|
-
return `${countLabel(visibleItems.length)} · ${countLabel(locations, COPY.location, COPY.locations_lower)} · ${fmt(bytes)} ${COPY.can_save_suffix}`
|
|
1016
|
-
} else if (state.view === "activity") {
|
|
1017
|
-
return countLabel(visibleItems.length, COPY.event, COPY.events)
|
|
1018
|
-
} else if (state.view === "reclaimable") {
|
|
1019
|
-
return `${countLabel(visibleItems.length)} · ${fmt(state.data.reclaimable)}`
|
|
1020
|
-
}
|
|
1021
|
-
const duplicateCount = visibleItems.filter((item) => item.status === "duplicate").length
|
|
1022
|
-
const saveable = visibleItems.filter((item) => item.status === "duplicate" && item.shareable).reduce((sum, item) => sum + item.size, 0)
|
|
1023
|
-
return `${countLabel(visibleItems.length)}${duplicateCount ? ` · ${countLabel(duplicateCount, COPY.duplicate.toLowerCase(), COPY.duplicates.toLowerCase())} · ${fmt(saveable)} ${COPY.can_save_suffix}` : ""}`
|
|
1024
|
-
}
|
|
1025
|
-
|
|
1026
|
-
const updateToolbarSummary = (visibleItems) => {
|
|
1027
|
-
const summary = el("vault-toolbar-summary")
|
|
1028
|
-
if (summary) summary.textContent = toolbarSummary(visibleItems)
|
|
1029
|
-
}
|
|
1030
|
-
|
|
1031
|
-
const searchPlaceholder = () => {
|
|
1032
|
-
if (state.view === "duplicates") return COPY.search_duplicates
|
|
1033
|
-
if (state.view === "shared") return COPY.search_shared
|
|
1034
|
-
if (state.view === "independent") return COPY.search_independent
|
|
1035
|
-
if (state.view === "activity") return COPY.search_activity
|
|
1036
|
-
const source = selectedSource()
|
|
1037
|
-
return source && source.kind === "app" ? `Search in ${source.label}` : COPY.search_all
|
|
1038
|
-
}
|
|
1039
|
-
const renderToolbar = (visibleItems) => {
|
|
1040
|
-
if (state.view === "reclaimable") {
|
|
1041
|
-
const count = state.data.blobs.filter((blob) => blob.orphan).length
|
|
1042
|
-
el("vault-toolbar").innerHTML = count
|
|
1043
|
-
? `<span class="vault-toolbar-count" id="vault-toolbar-summary">${esc(toolbarSummary(visibleItems))}</span><button class="vault-button" type="button" id="btn-reclaim-all">${esc(COPY.reclaim_all)}</button>`
|
|
1044
|
-
: ""
|
|
1045
|
-
return
|
|
1046
|
-
}
|
|
1047
|
-
const source = selectedSource()
|
|
1048
|
-
el("vault-toolbar").innerHTML = `<label class="vault-search"><i class="fa-solid fa-magnifying-glass"></i><input id="vault-search" value="${attr(state.query)}" placeholder="${attr(searchPlaceholder())}" /></label>
|
|
1049
|
-
${state.view === "all" ? `<select class="vault-select" id="vault-status-filter" aria-label="${attr(COPY.all_statuses)}">
|
|
1050
|
-
<option value="all" ${state.statusFilter === "all" ? "selected" : ""}>${esc(COPY.all_statuses)}</option>
|
|
1051
|
-
<option value="duplicate" ${state.statusFilter === "duplicate" ? "selected" : ""}>${esc(COPY.duplicates)}</option>
|
|
1052
|
-
<option value="shared" ${state.statusFilter === "shared" ? "selected" : ""}>${esc(COPY.shared)}</option>
|
|
1053
|
-
<option value="tracked" ${state.statusFilter === "tracked" ? "selected" : ""}>${esc(COPY.tracked)}</option>
|
|
1054
|
-
<option value="independent" ${state.statusFilter === "independent" ? "selected" : ""}>${esc(COPY.independent)}</option>
|
|
1055
|
-
</select>` : state.view === "duplicates" ? `<span class="vault-select">${esc(COPY.by_location)}</span>` : ""}
|
|
1056
|
-
<span class="vault-toolbar-count" id="vault-toolbar-summary">${esc(toolbarSummary(visibleItems))}</span>
|
|
1057
|
-
${state.view === "all" ? batchAction(source) : ""}`
|
|
1058
|
-
}
|
|
1059
|
-
|
|
1060
|
-
const statusMarkup = (item) => {
|
|
1061
|
-
if (item.status === "duplicate") {
|
|
1062
|
-
return item.shareable
|
|
1063
|
-
? `<span class="vault-status"><span class="vault-status-dot warning"></span>${esc(COPY.duplicate)}</span>`
|
|
1064
|
-
: `<span class="vault-status"><i class="fa-regular fa-circle-xmark"></i>${esc(COPY.different_disk)}</span>`
|
|
1065
|
-
}
|
|
1066
|
-
if (item.status === "shared") return `<span class="vault-status"><i class="fa-solid fa-link"></i>${esc(COPY.shared)} · ${item.locations.length} ${esc(COPY.locations_lower)}</span>`
|
|
1067
|
-
if (item.status === "independent") return `<span class="vault-status"><i class="fa-solid fa-code-branch"></i>${esc(COPY.independent)}</span>`
|
|
1068
|
-
return `<span class="vault-status"><span class="vault-status-dot"></span>${esc(COPY.tracked)}</span>`
|
|
1069
|
-
}
|
|
1070
|
-
const spaceMarkup = (item) => {
|
|
1071
|
-
if (item.status === "duplicate") return item.shareable ? `${fmt(item.size)} ${COPY.can_save_suffix}` : COPY.unavailable
|
|
1072
|
-
if (item.status === "shared") return `${fmt(item.saved)} ${COPY.saved_suffix}`
|
|
1073
|
-
return "—"
|
|
1074
|
-
}
|
|
1075
|
-
const rowAction = (item) => {
|
|
1076
|
-
if (item.status === "duplicate") return `<button class="vault-text-button" type="button" data-detach="${attr(item.path)}">${esc(COPY.ignore)}</button>`
|
|
1077
|
-
if (item.status === "independent") return `<button class="vault-text-button" type="button" data-reshare="${attr(item.path)}">${esc(COPY.allow_sharing)}</button>`
|
|
1078
|
-
return `<button class="vault-text-button" type="button" data-detach="${attr(item.path)}">${esc(COPY.make_independent)}</button>`
|
|
1079
|
-
}
|
|
1080
|
-
|
|
1081
|
-
const fileDetail = (item) => {
|
|
1082
|
-
if (!state.expandedFiles.has(item.path) || !item.locations || item.locations.length < 2) return ""
|
|
1083
|
-
return `<div class="vault-detail"><div class="vault-detail-label">${esc(COPY.identical_contents_at)} ${countLabel(item.locations.length, COPY.location, COPY.locations_lower)}</div>${item.locations.map((location) => `
|
|
1084
|
-
<div class="vault-location-detail"><i class="fa-regular fa-file"></i><span>${esc(location.source_label || "")}${location.relative_path ? ` / ${esc(location.relative_path)}` : ""}</span></div>`).join("")}</div>`
|
|
1085
|
-
}
|
|
1086
|
-
|
|
1087
|
-
const renderFileRow = (item, depth = 0, showMatch = false) => {
|
|
1088
|
-
const directoryPath = dirname(item.relative_path)
|
|
1089
|
-
const match = (item.matches || []).find((location) => location.path !== item.path)
|
|
1090
|
-
const expandable = item.locations && item.locations.length > 1
|
|
1091
|
-
return `<div class="vault-file-row">
|
|
1092
|
-
<div class="vault-name-cell indent-${Math.min(depth, 2)}">
|
|
1093
|
-
${expandable ? `<button class="vault-disclosure" type="button" data-expand-file="${attr(item.path)}"><i class="fa-solid fa-chevron-${state.expandedFiles.has(item.path) ? "down" : "right"}"></i></button>` : `<span class="vault-disclosure"></span>`}
|
|
1094
|
-
<i class="fa-regular fa-file vault-name-icon"></i>
|
|
1095
|
-
<span class="vault-name-copy"><span class="vault-file-name">${esc(basename(item.relative_path))}</span>${directoryPath && depth === 0 ? `<span class="vault-file-path">${esc(directoryPath)}</span>` : ""}</span>
|
|
1096
|
-
</div>
|
|
1097
|
-
<span class="vault-size">${item.size ? fmt(item.size) : "—"}</span>
|
|
1098
|
-
<span>${showMatch ? (match ? `<span class="vault-match-path">${esc(match.path)}</span>` : "—") : statusMarkup(item)}</span>
|
|
1099
|
-
<span class="vault-space">${esc(spaceMarkup(item))}</span>
|
|
1100
|
-
<span class="vault-row-action">${rowAction(item)}</span>
|
|
1101
|
-
</div>${fileDetail(item)}`
|
|
1102
|
-
}
|
|
1103
|
-
|
|
1104
|
-
const makeTree = (items) => {
|
|
1105
|
-
const root = { dirs: new Map(), files: [] }
|
|
1106
|
-
for (const item of items) {
|
|
1107
|
-
const parts = String(item.relative_path || basename(item.path)).split("/").filter(Boolean)
|
|
1108
|
-
const fileName = parts.pop() || basename(item.path)
|
|
1109
|
-
item._treeName = fileName
|
|
1110
|
-
let node = root
|
|
1111
|
-
for (const part of parts) {
|
|
1112
|
-
if (!node.dirs.has(part)) node.dirs.set(part, { name: part, dirs: new Map(), files: [] })
|
|
1113
|
-
node = node.dirs.get(part)
|
|
1114
|
-
}
|
|
1115
|
-
node.files.push(item)
|
|
1116
|
-
}
|
|
1117
|
-
return root
|
|
1118
|
-
}
|
|
1119
|
-
const nodeItems = (node) => [...node.files, ...[...node.dirs.values()].flatMap((child) => nodeItems(child))]
|
|
1120
|
-
const renderTreeNode = (node, prefix, depth) => {
|
|
1121
|
-
let current = node
|
|
1122
|
-
const labels = [node.name]
|
|
1123
|
-
while (current.files.length === 0 && current.dirs.size === 1) {
|
|
1124
|
-
current = [...current.dirs.values()][0]
|
|
1125
|
-
labels.push(current.name)
|
|
1126
|
-
}
|
|
1127
|
-
const key = [...prefix, ...labels].join("/")
|
|
1128
|
-
const collapsed = state.collapsedDirs.has(key)
|
|
1129
|
-
const items = nodeItems(current)
|
|
1130
|
-
const duplicateCount = items.filter((item) => item.status === "duplicate").length
|
|
1131
|
-
const shareableBytes = items.filter((item) => item.status === "duplicate" && item.shareable).reduce((sum, item) => sum + item.size, 0)
|
|
1132
|
-
const shared = items.filter((item) => item.status === "shared").length
|
|
1133
|
-
const summary = duplicateCount ? countLabel(duplicateCount, COPY.duplicate.toLowerCase(), COPY.duplicates.toLowerCase()) : shared ? COPY.shared : COPY.tracked
|
|
1134
|
-
let html = `<div class="vault-file-row directory">
|
|
1135
|
-
<div class="vault-name-cell indent-${Math.min(depth, 2)}"><button class="vault-disclosure" type="button" data-toggle-dir="${attr(key)}"><i class="fa-solid fa-chevron-${collapsed ? "right" : "down"}"></i></button><i class="fa-regular fa-folder vault-name-icon"></i><span class="vault-file-name">${esc(labels.join(" / "))}</span></div>
|
|
1136
|
-
<span class="vault-size">${countLabel(items.length)}</span><span>${esc(summary)}</span><span class="vault-space">${shareableBytes ? `${fmt(shareableBytes)} ${COPY.can_save_suffix}` : "—"}</span><span></span>
|
|
1137
|
-
</div>`
|
|
1138
|
-
if (!collapsed) {
|
|
1139
|
-
html += current.files.sort((a, b) => a._treeName.localeCompare(b._treeName)).map((item) => renderFileRow(item, depth + 1)).join("")
|
|
1140
|
-
html += [...current.dirs.values()].sort((a, b) => a.name.localeCompare(b.name)).map((child) => renderTreeNode(child, [...prefix, ...labels], depth + 1)).join("")
|
|
1141
|
-
}
|
|
1142
|
-
return html
|
|
1143
|
-
}
|
|
1144
|
-
const renderTree = (items) => {
|
|
1145
|
-
const tree = makeTree(items)
|
|
1146
|
-
return tree.files.map((item) => renderFileRow(item)).join("") + [...tree.dirs.values()].sort((a, b) => a.name.localeCompare(b.name)).map((node) => renderTreeNode(node, [], 0)).join("")
|
|
1147
|
-
}
|
|
1148
|
-
|
|
1149
|
-
const groupTitle = (source) => {
|
|
1150
|
-
if (!source) return COPY.unknown_location
|
|
1151
|
-
const parts = [source.label]
|
|
1152
|
-
let current = source
|
|
1153
|
-
while (current && current.parent_id) {
|
|
1154
|
-
current = sourceById(current.parent_id)
|
|
1155
|
-
if (current) parts.unshift(current.label)
|
|
1156
|
-
}
|
|
1157
|
-
return parts.join(" / ")
|
|
1158
|
-
}
|
|
1159
|
-
const sourceSort = (a, b) => {
|
|
1160
|
-
const first = sourceById(a[0])
|
|
1161
|
-
const second = sourceById(b[0])
|
|
1162
|
-
const rank = (source) => source && source.kind === "external" ? 1 : source ? 0 : 2
|
|
1163
|
-
return rank(first) - rank(second) || groupTitle(first).localeCompare(groupTitle(second))
|
|
1164
|
-
}
|
|
1165
|
-
const renderDuplicateGroups = (items) => {
|
|
1166
|
-
const groups = new Map()
|
|
1167
|
-
for (const item of items) {
|
|
1168
|
-
const key = item.source_id || "unknown"
|
|
1169
|
-
if (!groups.has(key)) groups.set(key, [])
|
|
1170
|
-
groups.get(key).push(item)
|
|
1171
|
-
}
|
|
1172
|
-
return [...groups.entries()].sort(sourceSort).map(([sourceId, group]) => {
|
|
1173
|
-
const source = sourceById(sourceId)
|
|
1174
|
-
const bytes = group.filter((item) => item.shareable).reduce((sum, item) => sum + item.size, 0)
|
|
1175
|
-
const action = source && source.shareable && group.some((item) => item.shareable)
|
|
1176
|
-
? `<button class="vault-button" type="button" data-deduplicate-scope="${attr(sourceId)}">${esc(COPY.deduplicate)} ${countLabel(group.filter((item) => item.shareable).length)}</button>`
|
|
1177
|
-
: `<span class="vault-unavailable">${esc(COPY.sharing_unavailable)}</span>`
|
|
1178
|
-
return `<div class="vault-group-row"><div class="vault-group-main"><div class="vault-group-title"><i class="fa-regular fa-folder"></i><span>${esc(groupTitle(source))}</span></div><div class="vault-group-meta">${countLabel(group.length, COPY.duplicate.toLowerCase(), COPY.duplicates.toLowerCase())} · ${bytes ? fmt(bytes) : COPY.unavailable}</div></div><div class="vault-group-action">${action}</div></div>${group.sort((a, b) => a.relative_path.localeCompare(b.relative_path)).map((item) => renderFileRow(item, 0, true)).join("")}`
|
|
1179
|
-
}).join("")
|
|
1180
|
-
}
|
|
1181
|
-
|
|
1182
|
-
const renderInventoryGroups = (items) => {
|
|
1183
|
-
const groups = new Map()
|
|
1184
|
-
for (const item of items) {
|
|
1185
|
-
const key = item.source_id || "unknown"
|
|
1186
|
-
if (!groups.has(key)) groups.set(key, [])
|
|
1187
|
-
groups.get(key).push(item)
|
|
1188
|
-
}
|
|
1189
|
-
return [...groups.entries()].sort(sourceSort).map(([sourceId, group]) => {
|
|
1190
|
-
const source = sourceById(sourceId)
|
|
1191
|
-
const duplicates = group.filter((item) => item.status === "duplicate")
|
|
1192
|
-
const saveable = duplicates.filter((item) => item.shareable).reduce((sum, item) => sum + item.size, 0)
|
|
1193
|
-
const shared = group.filter((item) => item.status === "shared").length
|
|
1194
|
-
const meta = [countLabel(group.length)]
|
|
1195
|
-
if (duplicates.length) meta.push(countLabel(duplicates.length, COPY.duplicate.toLowerCase(), COPY.duplicates.toLowerCase()))
|
|
1196
|
-
else if (shared) meta.push(`${shared} ${COPY.shared.toLowerCase()}`)
|
|
1197
|
-
if (saveable) meta.push(`${fmt(saveable)} ${COPY.can_save_suffix}`)
|
|
1198
|
-
const action = batchAction(source)
|
|
1199
|
-
return `<div class="vault-group-row"><div class="vault-group-main"><div class="vault-group-title"><i class="fa-regular fa-folder"></i><span>${esc(groupTitle(source))}</span></div><div class="vault-group-meta">${esc(meta.join(" · "))}</div></div>${action ? `<div class="vault-group-action">${action}</div>` : ""}</div>${renderTree(group)}`
|
|
1200
|
-
}).join("")
|
|
1201
|
-
}
|
|
1202
|
-
|
|
1203
|
-
const emptyState = (view) => {
|
|
1204
|
-
const scanning = scanActive(state.data && state.data.scan)
|
|
1205
|
-
const content = {
|
|
1206
|
-
all: scanning
|
|
1207
|
-
? [COPY.scan_waiting, COPY.scan_waiting_hint, "fa-solid fa-circle-notch fa-spin"]
|
|
1208
|
-
: [COPY.no_files, COPY.no_files_hint, "fa-regular fa-folder-open"],
|
|
1209
|
-
duplicates: [COPY.no_duplicates, COPY.no_duplicates_hint, "fa-regular fa-circle-check"],
|
|
1210
|
-
shared: [COPY.no_shared, COPY.no_shared_hint, "fa-solid fa-link"],
|
|
1211
|
-
independent: [COPY.no_independent, COPY.no_independent_hint, "fa-solid fa-code-branch"],
|
|
1212
|
-
reclaimable: [COPY.no_reclaimable, COPY.no_reclaimable_hint, "fa-regular fa-circle-check"],
|
|
1213
|
-
activity: [COPY.no_activity, COPY.no_activity_hint, "fa-solid fa-wave-square"]
|
|
1214
|
-
}[view]
|
|
1215
|
-
return `<div class="vault-empty"><div class="vault-empty-inner"><i class="${content[2]}"></i><h3>${esc(content[0])}</h3><p>${esc(content[1])}</p>${view === "duplicates" ? `<button class="vault-button" type="button" data-view="all">${esc(COPY.view_all)}</button>` : view === "all" && !scanning ? `<button class="vault-button" type="button" id="btn-empty-scan">${esc(COPY.scan)}</button>` : ""}</div></div>`
|
|
1216
|
-
}
|
|
1217
|
-
|
|
1218
|
-
const renderReclaimable = () => {
|
|
1219
|
-
const blobs = state.data.blobs.filter((blob) => blob.orphan)
|
|
1220
|
-
if (!blobs.length) return emptyState("reclaimable")
|
|
1221
|
-
return blobs.map((blob) => `<div class="vault-file-row"><div class="vault-name-cell"><span class="vault-disclosure"></span><i class="fa-regular fa-file vault-name-icon"></i><span class="vault-name-copy"><span class="vault-file-name">${esc(blob.names[0] ? basename(blob.names[0].path) : `${blob.hash.slice(0, 12)}…`)}</span></span></div><span class="vault-size">${fmt(blob.size)}</span><span>${esc(COPY.reclaimable)}</span><span class="vault-space">${fmt(blob.size)}</span><span class="vault-row-action"><button class="vault-text-button" type="button" data-reclaim="${attr(blob.hash)}">${esc(COPY.reclaim)}</button></span></div>`).join("")
|
|
1222
|
-
}
|
|
1223
|
-
|
|
1224
|
-
const renderActivity = () => {
|
|
1225
|
-
const labels = {
|
|
1226
|
-
convert: COPY.event_convert, found: COPY.event_found, adopt: COPY.event_adopt,
|
|
1227
|
-
reclaim: COPY.event_reclaim, undo: COPY.event_undo, diverged: COPY.event_diverged,
|
|
1228
|
-
detach: COPY.event_detach, reshare: COPY.event_reshare, skip_download: COPY.event_adopt
|
|
1229
|
-
}
|
|
1230
|
-
const query = state.query.toLowerCase()
|
|
1231
|
-
const events = state.data.events.filter((event) => !query || `${labels[event.kind] || event.kind} ${event.path || ""}`.toLowerCase().includes(query))
|
|
1232
|
-
if (!events.length) return emptyState("activity")
|
|
1233
|
-
const seenBatches = new Set()
|
|
1234
|
-
return events.map((event) => {
|
|
1235
|
-
let undo = ""
|
|
1236
|
-
if (event.kind === "convert" && event.batch_id && !seenBatches.has(event.batch_id)) {
|
|
1237
|
-
seenBatches.add(event.batch_id)
|
|
1238
|
-
undo = `<button class="vault-text-button" type="button" data-undo="${attr(event.batch_id)}">${esc(COPY.undo)}</button>`
|
|
1239
|
-
}
|
|
1240
|
-
const location = event.path ? state.data.sources.find((source) => source.id === event.source_id) : null
|
|
1241
|
-
return `<div class="vault-file-row"><div class="vault-name-cell"><span class="vault-disclosure"></span><i class="fa-solid fa-wave-square vault-name-icon"></i><span class="vault-name-copy"><span class="vault-file-name vault-event-kind">${esc(labels[event.kind] || event.kind)}</span><span class="vault-file-path">${esc(location ? `${location.label} / ` : "")}${esc(event.path ? basename(event.path) : (event.hash || "").slice(0, 12))}</span></span></div><span class="vault-size">${event.bytes_saved ? fmt(event.bytes_saved) : event.size ? fmt(event.size) : "—"}</span><span class="vault-event-time">${esc(new Date(event.ts).toLocaleString())}</span><span></span><span class="vault-row-action">${undo}</span></div>`
|
|
1242
|
-
}).join("")
|
|
1243
|
-
}
|
|
1244
|
-
|
|
1245
|
-
const renderTable = (items) => {
|
|
1246
|
-
let body = ""
|
|
1247
|
-
let matches = false
|
|
1248
|
-
if (state.view === "duplicates") {
|
|
1249
|
-
matches = true
|
|
1250
|
-
body = items.length ? renderDuplicateGroups(items) : emptyState("duplicates")
|
|
1251
|
-
} else if (state.view === "reclaimable") {
|
|
1252
|
-
body = renderReclaimable()
|
|
1253
|
-
} else if (state.view === "activity") {
|
|
1254
|
-
body = renderActivity()
|
|
1255
|
-
} else {
|
|
1256
|
-
body = items.length ? (state.sourceId ? renderTree(items) : renderInventoryGroups(items)) : emptyState(state.view)
|
|
1257
|
-
}
|
|
1258
|
-
const third = matches ? COPY.matches : state.view === "activity" ? COPY.last_scanned : COPY.status
|
|
1259
|
-
el("vault-table-wrap").innerHTML = `<div class="vault-table ${matches ? "matches" : ""}"><div class="vault-columns"><span>${esc(COPY.name)}</span><span>${esc(COPY.size)}</span><span>${esc(third)}</span><span>${esc(COPY.space)}</span><span></span></div>${body}</div>`
|
|
1260
|
-
}
|
|
1261
|
-
|
|
1262
|
-
const renderOverview = () => {
|
|
1263
|
-
const data = state.data
|
|
1264
|
-
const last = data.last_scan
|
|
1265
|
-
const folderMetric = last ? `<div class="vault-metric"><span class="vault-metric-value">${fmt(last.home_bytes_total == null ? last.bytes_total : last.home_bytes_total)}</span><span class="vault-metric-label">${esc(COPY.pinokio_folder)}</span></div>` : ""
|
|
1266
|
-
el("vault-metrics").innerHTML = `${folderMetric}
|
|
1267
|
-
<div class="vault-metric" title="${attr(`${fmt(data.bytes_without_sharing)} ${COPY.without_sharing}`)}"><span class="vault-metric-value">${fmt(data.bytes_on_disk)}</span><span class="vault-metric-label">${esc(COPY.on_disk)}</span></div>
|
|
1268
|
-
<div class="vault-metric" title="${attr(COPY.already_shared_help)}"><span class="vault-metric-value">${fmt(data.saved_by_sharing)}</span><span class="vault-metric-label">${esc(COPY.already_shared)}</span></div>
|
|
1269
|
-
<div class="vault-metric" title="${attr(COPY.saved_by_vault_help)}"><span class="vault-metric-value">${fmt(data.lifetime_bytes_saved)}</span><span class="vault-metric-label">${esc(COPY.saved_by_vault)}</span></div>
|
|
1270
|
-
<button class="vault-metric" type="button" id="btn-review-metric"><span class="vault-metric-value">${fmt(data.pending_bytes)}</span><span class="vault-metric-label">${esc(COPY.can_save)}</span></button>
|
|
1271
|
-
<div class="vault-metric"><span class="vault-metric-value">${esc(timeAgo(last && last.ts))}</span><span class="vault-metric-label">${esc(COPY.last_scanned)}</span></div>`
|
|
1272
|
-
const scanning = data.scan && (data.scan.active || data.scan.queued > 0)
|
|
1273
|
-
el("btn-scan").innerHTML = scanning ? `<i class="fa-solid fa-circle-notch fa-spin"></i>${esc(COPY.scanning)}` : `<i class="fa-solid fa-rotate"></i>${esc(last ? COPY.scan_again : COPY.scan)}`
|
|
1274
|
-
el("btn-scan").disabled = !!scanning
|
|
1275
|
-
el("btn-vault-options").setAttribute("aria-label", COPY.vault_options)
|
|
1276
|
-
el("vault-repair-title").textContent = COPY.repair_index
|
|
1277
|
-
el("vault-repair-description").textContent = COPY.repair_description
|
|
1278
|
-
el("btn-repair").textContent = COPY.repair_action
|
|
1279
|
-
el("btn-repair").disabled = !!scanning
|
|
1280
|
-
const scanState = el("vault-scan-state")
|
|
1281
|
-
if (scanning) {
|
|
1282
|
-
const scan = data.scan
|
|
1283
|
-
const walking = scan.walk_duration_ms == null
|
|
1284
|
-
const hashTotal = scan.hash_total || 0
|
|
1285
|
-
const hashDone = Math.min(scan.hashed || 0, hashTotal)
|
|
1286
|
-
const hashRatio = hashTotal ? Math.min(1, hashDone / hashTotal) : 1
|
|
1287
|
-
const phase = walking ? COPY.scan_progress : hashTotal ? COPY.scan_analyzing : COPY.scan_finishing
|
|
1288
|
-
const hasEstimate = Number.isFinite(scan.estimated_files) && scan.estimated_files > 0
|
|
1289
|
-
const walkWeight = Number.isFinite(scan.estimated_walk_weight) ? scan.estimated_walk_weight : 0.8
|
|
1290
|
-
const walkRatio = hasEstimate ? Math.min(1, (scan.files || 0) / scan.estimated_files) : null
|
|
1291
|
-
const progressRatio = walking
|
|
1292
|
-
? (hasEstimate ? walkRatio * walkWeight : null)
|
|
1293
|
-
: (hasEstimate ? walkWeight + ((1 - walkWeight) * hashRatio) : hashRatio)
|
|
1294
|
-
const progressValue = progressRatio == null ? null : Math.max(0, Math.min(100, Math.round(progressRatio * 100)))
|
|
1295
|
-
const details = [`${scan.dirs || 0} ${COPY.scan_folders}`, `${scan.files || 0} ${COPY.scan_files}`, fmt(scan.bytes_total || 0)]
|
|
1296
|
-
if (scan.current_file) details.push(`${COPY.analyzing} ${scan.current_file}`)
|
|
1297
|
-
if (walking && scan.queued > 1) details.push(`${scan.queued} ${COPY.waiting}`)
|
|
1298
|
-
const estimated = hasEstimate && progressValue < 100
|
|
1299
|
-
const percent = progressValue == null ? "" : `<span class="vault-scan-percent"${estimated ? ` title="${attr(COPY.scan_estimate_help)}"` : ""}>${estimated ? "~" : ""}${progressValue}%</span>`
|
|
1300
|
-
const progressAttrs = progressValue == null
|
|
1301
|
-
? `role="progressbar" aria-label="${attr(phase)}"`
|
|
1302
|
-
: `role="progressbar" aria-label="${attr(phase)}" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${progressValue}"${estimated ? ` aria-valuetext="${attr(`About ${progressValue} percent. ${COPY.scan_estimate_help}.`)}"` : ""}`
|
|
1303
|
-
const progressStyle = progressValue == null ? "" : ` style="--vault-progress:${progressRatio}"`
|
|
1304
|
-
scanState.classList.add("show")
|
|
1305
|
-
scanState.innerHTML = `<i class="fa-solid fa-circle-notch fa-spin"></i><strong>${esc(phase)}</strong><span class="vault-scan-detail">${esc(details.join(" · "))}</span>${percent}<span class="vault-progress-track" ${progressAttrs}><span class="vault-progress-bar ${progressValue == null ? "indeterminate" : "determinate"}"${progressStyle}></span></span>`
|
|
1306
|
-
} else {
|
|
1307
|
-
scanState.classList.remove("show")
|
|
1308
|
-
scanState.innerHTML = ""
|
|
1309
|
-
}
|
|
1310
|
-
}
|
|
1311
|
-
|
|
1312
|
-
const renderResult = () => {
|
|
1313
|
-
const result = el("vault-result")
|
|
1314
|
-
if (!state.scanResult) {
|
|
1315
|
-
result.classList.remove("show")
|
|
1316
|
-
result.innerHTML = ""
|
|
1317
|
-
return
|
|
1318
|
-
}
|
|
1319
|
-
const info = state.scanResult
|
|
1320
|
-
result.classList.add("show")
|
|
1321
|
-
const duplicateLabel = countLabel(info.count, COPY.duplicate.toLowerCase(), COPY.duplicates.toLowerCase())
|
|
1322
|
-
result.innerHTML = `<i class="fa-regular fa-circle-check"></i><strong>${esc(COPY.scan_complete)}</strong><span class="vault-result-detail">${duplicateLabel} ${esc(COPY.found_in)} ${countLabel(info.locations, COPY.location, COPY.locations_lower)} · ${fmt(info.bytes)} ${esc(COPY.can_be_saved)}</span><button class="vault-button" type="button" id="btn-review-result">${esc(COPY.review)} ${duplicateLabel}<i class="fa-solid fa-chevron-right"></i></button>`
|
|
1323
|
-
}
|
|
1324
|
-
const renderFeedback = () => {
|
|
1325
|
-
const feedback = el("vault-feedback")
|
|
1326
|
-
if (!state.feedback) {
|
|
1327
|
-
feedback.className = "vault-feedback"
|
|
1328
|
-
feedback.innerHTML = ""
|
|
1329
|
-
return
|
|
1330
|
-
}
|
|
1331
|
-
feedback.className = `vault-feedback show ${state.feedback.error ? "error" : ""}`
|
|
1332
|
-
feedback.innerHTML = `<i class="fa-solid fa-${state.feedback.error ? "triangle-exclamation" : "circle-check"}"></i><span>${esc(state.feedback.message)}</span>`
|
|
1333
|
-
}
|
|
1334
|
-
|
|
1335
|
-
const render = () => {
|
|
1336
|
-
if (!state.data) return
|
|
1337
|
-
renderOverview()
|
|
1338
|
-
renderResult()
|
|
1339
|
-
renderFeedback()
|
|
1340
|
-
if (!state.data.enabled) {
|
|
1341
|
-
el("vault-explorer").style.display = "none"
|
|
1342
|
-
return
|
|
1343
|
-
}
|
|
1344
|
-
el("vault-explorer").style.display = "grid"
|
|
1345
|
-
const items = buildItems()
|
|
1346
|
-
renderViews(items)
|
|
1347
|
-
renderLocations(items)
|
|
1348
|
-
const visible = activeItems(items)
|
|
1349
|
-
renderToolbar(visible)
|
|
1350
|
-
renderTable(visible)
|
|
1351
|
-
el("vault-pane-footer").textContent = state.view === "duplicates" ? COPY.duplicate_note : COPY.tracked_note
|
|
1352
|
-
}
|
|
1353
|
-
|
|
1354
|
-
const scanActive = (scan) => !!(scan && (scan.active || scan.queued > 0))
|
|
1355
|
-
const fetchJson = async (url) => {
|
|
1356
|
-
const response = await fetch(url)
|
|
1357
|
-
if (!response.ok) throw new Error(`Vault status request failed (${response.status})`)
|
|
1358
|
-
return response.json()
|
|
1359
|
-
}
|
|
1360
|
-
const applyFullData = (data) => {
|
|
1361
|
-
const scanning = scanActive(data.scan)
|
|
1362
|
-
const completed = state.scanRequested && !scanning && data.last_scan && data.last_scan.ts !== state.scanBaseline
|
|
1363
|
-
state.data = data
|
|
1364
|
-
if (completed) {
|
|
1365
|
-
state.scanRequested = false
|
|
1366
|
-
const shareable = data.duplicates.filter((item) => item.shareable !== false)
|
|
1367
|
-
state.scanResult = {
|
|
1368
|
-
count: shareable.length,
|
|
1369
|
-
locations: new Set(shareable.map((item) => item.source_id).filter(Boolean)).size,
|
|
1370
|
-
bytes: shareable.reduce((sum, item) => sum + item.size, 0)
|
|
1371
|
-
}
|
|
1372
|
-
}
|
|
1373
|
-
render()
|
|
1374
|
-
return scanning
|
|
1375
|
-
}
|
|
1376
|
-
const refresh = async () => {
|
|
1377
|
-
let delay = 15000
|
|
1378
|
-
try {
|
|
1379
|
-
const progressOnly = !!(state.data && scanActive(state.data.scan))
|
|
1380
|
-
if (progressOnly) {
|
|
1381
|
-
const progress = await fetchJson("/info/dedup?progress=1")
|
|
1382
|
-
state.data.scan = progress.scan
|
|
1383
|
-
state.data.last_scan = progress.last_scan
|
|
1384
|
-
if (scanActive(progress.scan)) {
|
|
1385
|
-
delay = 1500
|
|
1386
|
-
renderOverview()
|
|
1387
|
-
renderFeedback()
|
|
1388
|
-
} else {
|
|
1389
|
-
delay = applyFullData(await fetchJson("/info/dedup")) ? 1500 : 15000
|
|
1390
|
-
}
|
|
1391
|
-
} else {
|
|
1392
|
-
delay = applyFullData(await fetchJson("/info/dedup")) ? 1500 : 15000
|
|
1393
|
-
}
|
|
1394
|
-
} catch (error) {
|
|
1395
|
-
state.feedback = { error: true, message: error && error.message ? error.message : String(error) }
|
|
1396
|
-
renderFeedback()
|
|
1397
|
-
delay = 5000
|
|
1398
|
-
} finally {
|
|
1399
|
-
clearTimeout(window.__vaultRefresh)
|
|
1400
|
-
window.__vaultRefresh = setTimeout(refresh, delay)
|
|
1401
|
-
}
|
|
1402
|
-
}
|
|
1403
|
-
|
|
1404
|
-
const runAction = async (payload, success) => {
|
|
1405
|
-
state.feedback = null
|
|
1406
|
-
renderFeedback()
|
|
1407
|
-
const result = await post(payload)
|
|
1408
|
-
if (result.error) state.feedback = { error: true, message: result.error }
|
|
1409
|
-
else state.feedback = { error: false, message: typeof success === "function" ? success(result) : success }
|
|
1410
|
-
await refresh()
|
|
1411
|
-
}
|
|
1412
|
-
|
|
1413
|
-
const chooseExternalFolder = () => new Promise((resolve, reject) => {
|
|
1414
|
-
const picker = new Socket()
|
|
1415
|
-
let settled = false
|
|
1416
|
-
const finish = (value) => {
|
|
1417
|
-
if (settled) return
|
|
1418
|
-
settled = true
|
|
1419
|
-
resolve(value)
|
|
1420
|
-
}
|
|
1421
|
-
const fail = (error) => {
|
|
1422
|
-
if (settled) return
|
|
1423
|
-
settled = true
|
|
1424
|
-
reject(error)
|
|
1425
|
-
}
|
|
1426
|
-
picker.run({
|
|
1427
|
-
method: "kernel.bin.filepicker",
|
|
1428
|
-
params: { title: COPY.add_external_folder, type: "folder" }
|
|
1429
|
-
}, (packet) => {
|
|
1430
|
-
if (packet.type === "result") {
|
|
1431
|
-
const paths = packet.data && Array.isArray(packet.data.paths) ? packet.data.paths : []
|
|
1432
|
-
finish(paths[0] || null)
|
|
1433
|
-
picker.close()
|
|
1434
|
-
} else if (packet.type === "error") {
|
|
1435
|
-
const message = packet.data && packet.data.message ? packet.data.message : "The folder picker could not be opened."
|
|
1436
|
-
fail(new Error(message))
|
|
1437
|
-
picker.close()
|
|
1438
|
-
}
|
|
1439
|
-
}).then(() => finish(null)).catch(fail)
|
|
1440
|
-
})
|
|
1441
|
-
|
|
1442
|
-
document.addEventListener("click", async (event) => {
|
|
1443
|
-
const advanced = el("vault-advanced")
|
|
1444
|
-
if (advanced.open && !advanced.contains(event.target)) advanced.open = false
|
|
1445
|
-
const target = event.target.closest("button")
|
|
1446
|
-
if (!target) return
|
|
1447
|
-
if (target.dataset.view) {
|
|
1448
|
-
state.view = target.dataset.view
|
|
1449
|
-
state.sourceId = null
|
|
1450
|
-
state.query = ""
|
|
1451
|
-
state.statusFilter = "all"
|
|
1452
|
-
if (state.view === "duplicates") state.scanResult = null
|
|
1453
|
-
render()
|
|
1454
|
-
} else if (target.dataset.source) {
|
|
1455
|
-
state.sourceId = state.sourceId === target.dataset.source ? null : target.dataset.source
|
|
1456
|
-
render()
|
|
1457
|
-
} else if (target.dataset.toggleSource) {
|
|
1458
|
-
const id = target.dataset.toggleSource
|
|
1459
|
-
if (state.collapsedSources.has(id)) state.collapsedSources.delete(id)
|
|
1460
|
-
else state.collapsedSources.add(id)
|
|
1461
|
-
renderLocations(buildItems())
|
|
1462
|
-
} else if (target.dataset.toggleDir) {
|
|
1463
|
-
const key = target.dataset.toggleDir
|
|
1464
|
-
if (state.collapsedDirs.has(key)) state.collapsedDirs.delete(key)
|
|
1465
|
-
else state.collapsedDirs.add(key)
|
|
1466
|
-
render()
|
|
1467
|
-
} else if (target.dataset.expandFile) {
|
|
1468
|
-
const file = target.dataset.expandFile
|
|
1469
|
-
if (state.expandedFiles.has(file)) state.expandedFiles.delete(file)
|
|
1470
|
-
else state.expandedFiles.add(file)
|
|
1471
|
-
render()
|
|
1472
|
-
} else if (target.id === "btn-add-source") {
|
|
1473
|
-
target.disabled = true
|
|
1474
|
-
try {
|
|
1475
|
-
const folderPath = await chooseExternalFolder()
|
|
1476
|
-
if (!folderPath) return
|
|
1477
|
-
const result = await post({ action: "add_source", path: folderPath })
|
|
1478
|
-
if (result.error) {
|
|
1479
|
-
state.feedback = { error: true, message: result.error }
|
|
1480
|
-
} else {
|
|
1481
|
-
state.view = "all"
|
|
1482
|
-
state.sourceId = result.source && result.source.id ? result.source.id : null
|
|
1483
|
-
state.query = ""
|
|
1484
|
-
state.feedback = {
|
|
1485
|
-
error: false,
|
|
1486
|
-
message: result.created === false
|
|
1487
|
-
? COPY.external_exists
|
|
1488
|
-
: result.source && result.source.shareable === false
|
|
1489
|
-
? COPY.external_other_disk
|
|
1490
|
-
: COPY.external_added
|
|
1491
|
-
}
|
|
1492
|
-
}
|
|
1493
|
-
await refresh()
|
|
1494
|
-
} catch (error) {
|
|
1495
|
-
state.feedback = { error: true, message: error && error.message ? error.message : String(error) }
|
|
1496
|
-
renderFeedback()
|
|
1497
|
-
} finally {
|
|
1498
|
-
target.disabled = false
|
|
1499
|
-
}
|
|
1500
|
-
} else if (target.id === "btn-scan" || target.id === "btn-empty-scan") {
|
|
1501
|
-
state.scanRequested = true
|
|
1502
|
-
state.scanBaseline = state.data.last_scan ? state.data.last_scan.ts : null
|
|
1503
|
-
state.scanResult = null
|
|
1504
|
-
state.feedback = null
|
|
1505
|
-
await post({ action: "scan" })
|
|
1506
|
-
await refresh()
|
|
1507
|
-
} else if (target.id === "btn-repair") {
|
|
1508
|
-
target.disabled = true
|
|
1509
|
-
target.textContent = COPY.repairing
|
|
1510
|
-
await runAction({ action: "repair" }, COPY.repair_done)
|
|
1511
|
-
advanced.open = false
|
|
1512
|
-
} else if (target.id === "btn-review-result" || target.id === "btn-review-metric") {
|
|
1513
|
-
state.view = "duplicates"
|
|
1514
|
-
state.sourceId = null
|
|
1515
|
-
state.query = ""
|
|
1516
|
-
state.scanResult = null
|
|
1517
|
-
render()
|
|
1518
|
-
} else if (target.dataset.deduplicateScope) {
|
|
1519
|
-
await runAction({ action: "deduplicate", scope_id: target.dataset.deduplicateScope }, (result) => `${COPY.converted}: ${fmt(result.bytes_saved || 0)}`)
|
|
1520
|
-
} else if (target.dataset.detach) {
|
|
1521
|
-
await runAction({ action: "detach", path: target.dataset.detach }, COPY.ignored)
|
|
1522
|
-
} else if (target.dataset.reshare) {
|
|
1523
|
-
await runAction({ action: "reshare", path: target.dataset.reshare }, COPY.allowed)
|
|
1524
|
-
} else if (target.dataset.reclaim) {
|
|
1525
|
-
await runAction({ action: "reclaim", hash: target.dataset.reclaim }, (result) => `${COPY.reclaimed}: ${fmt(result.bytes_freed || 0)}`)
|
|
1526
|
-
} else if (target.id === "btn-reclaim-all") {
|
|
1527
|
-
await runAction({ action: "reclaim_all" }, (result) => `${COPY.reclaimed}: ${fmt(result.bytes_freed || 0)}`)
|
|
1528
|
-
} else if (target.dataset.undo) {
|
|
1529
|
-
await runAction({ action: "undo", batch_id: target.dataset.undo }, (result) => `${COPY.event_undo} (${result.undone || 0})`)
|
|
1530
|
-
}
|
|
1531
|
-
})
|
|
1532
|
-
|
|
1533
|
-
document.addEventListener("input", (event) => {
|
|
1534
|
-
if (event.target.id !== "vault-search") return
|
|
1535
|
-
state.query = event.target.value
|
|
1536
|
-
const items = activeItems(buildItems())
|
|
1537
|
-
updateToolbarSummary(items)
|
|
1538
|
-
renderTable(items)
|
|
1539
|
-
})
|
|
1540
|
-
document.addEventListener("change", (event) => {
|
|
1541
|
-
if (event.target.id !== "vault-status-filter") return
|
|
1542
|
-
state.statusFilter = event.target.value
|
|
1543
|
-
render()
|
|
1544
|
-
})
|
|
1545
|
-
|
|
1546
|
-
el("btn-scan").textContent = COPY.scan
|
|
1547
|
-
el("btn-add-source").setAttribute("aria-label", COPY.add_external_folder)
|
|
1548
|
-
el("btn-add-source").setAttribute("title", COPY.add_external_folder)
|
|
1549
|
-
el("btn-vault-options").setAttribute("aria-label", COPY.vault_options)
|
|
1550
|
-
el("vault-repair-title").textContent = COPY.repair_index
|
|
1551
|
-
el("vault-repair-description").textContent = COPY.repair_description
|
|
1552
|
-
el("btn-repair").textContent = COPY.repair_action
|
|
1553
|
-
refresh()
|
|
1554
|
-
</script>
|
|
26
|
+
<script src="/storage-size.js"></script>
|
|
27
|
+
<script src="/vault.js"></script>
|
|
1555
28
|
</body>
|
|
1556
29
|
</html>
|