pinokiod 8.0.47 → 8.0.49
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/vault/constants.js +2 -2
- package/kernel/vault/index.js +18 -0
- package/package.json +1 -1
- package/server/public/vault.css +33 -2
- package/server/public/vault.js +194 -68
- package/server/views/partials/vault_workspace.ejs +1 -0
- package/test/vault-engine.test.js +21 -0
- package/test/vault-ui.test.js +484 -14
package/server/public/vault.js
CHANGED
|
@@ -10,8 +10,9 @@ const COPY = {
|
|
|
10
10
|
all_description: "Every scanned file and its current deduplication status.",
|
|
11
11
|
duplicates_description: "Identical files waiting to be deduplicated or kept separate.",
|
|
12
12
|
shared_description: "Files that share disk storage across multiple locations.",
|
|
13
|
+
tracked_description: "Files with no duplicate action required.",
|
|
13
14
|
independent_description: "Duplicate files that remain as separate copies.",
|
|
14
|
-
reclaimable_description: "
|
|
15
|
+
reclaimable_description: "Private links no longer used by any linked file.",
|
|
15
16
|
activity_description: "A history of scans and changes made by Save space.",
|
|
16
17
|
add_external_folder: "Add external folder",
|
|
17
18
|
files_region: "Files",
|
|
@@ -24,7 +25,7 @@ const COPY = {
|
|
|
24
25
|
disk_space_saved: "of disk space saved",
|
|
25
26
|
saved_for_app: "{size} saved for this app",
|
|
26
27
|
before: "Before",
|
|
27
|
-
before_help: "Estimated
|
|
28
|
+
before_help: "Estimated size of every scanned location if each app stored its own copy. File Explorer may count deduplicated files differently.",
|
|
28
29
|
after: "After",
|
|
29
30
|
effective_help: "For deduplicated files, disk usage is divided evenly among every location using them.",
|
|
30
31
|
nothing_more_to_save: "Nothing else to save",
|
|
@@ -86,6 +87,7 @@ const COPY = {
|
|
|
86
87
|
search_all: "Search files",
|
|
87
88
|
search_duplicates: "Search duplicates",
|
|
88
89
|
search_shared: "Search deduplicated files",
|
|
90
|
+
search_tracked: "Search files with no action needed",
|
|
89
91
|
search_skipped: "Search files kept separate",
|
|
90
92
|
search_activity: "Search activity",
|
|
91
93
|
search_in: "Search in {location}",
|
|
@@ -103,6 +105,9 @@ const COPY = {
|
|
|
103
105
|
display_mode: "Display mode",
|
|
104
106
|
sorted_largest: "sorted largest first",
|
|
105
107
|
sorted_smallest: "sorted smallest first",
|
|
108
|
+
previous: "Previous",
|
|
109
|
+
next: "Next",
|
|
110
|
+
file_pages: "File pages",
|
|
106
111
|
status: "Deduplication status",
|
|
107
112
|
matches: "Matches",
|
|
108
113
|
can_save: "Can save",
|
|
@@ -149,8 +154,13 @@ const COPY = {
|
|
|
149
154
|
keep_separate: "Keep separate",
|
|
150
155
|
kept_separate: "Kept separate",
|
|
151
156
|
make_separate: "Make separate",
|
|
152
|
-
reclaim: "
|
|
153
|
-
reclaim_all: "
|
|
157
|
+
reclaim: "Clean up",
|
|
158
|
+
reclaim_all: "Clean up all",
|
|
159
|
+
cleanup_ready: "{size} ready to clean up",
|
|
160
|
+
cleanup_ready_detail: "{count} left after their linked files were deleted.",
|
|
161
|
+
review_cleanup: "Review cleanup",
|
|
162
|
+
private_link: "private link",
|
|
163
|
+
private_links: "private links",
|
|
154
164
|
undo: "Undo",
|
|
155
165
|
identical_contents_at: "Identical contents at",
|
|
156
166
|
no_files: "No files found",
|
|
@@ -161,26 +171,29 @@ const COPY = {
|
|
|
161
171
|
no_duplicates_hint: "There are no files waiting for your review.",
|
|
162
172
|
no_shared: "Nothing deduplicated yet",
|
|
163
173
|
no_shared_hint: "Deduplicated files will appear here after you review duplicates.",
|
|
174
|
+
no_tracked: "No files with no action needed",
|
|
175
|
+
no_tracked_hint: "Files without a duplicate action will appear here after a scan.",
|
|
164
176
|
no_skipped: "Nothing kept separate",
|
|
165
177
|
no_skipped_hint: "Files you keep separate will appear here.",
|
|
166
|
-
no_reclaimable: "No
|
|
167
|
-
no_reclaimable_hint: "
|
|
178
|
+
no_reclaimable: "No cleanup needed",
|
|
179
|
+
no_reclaimable_hint: "Private links with no remaining linked files will appear here.",
|
|
168
180
|
no_activity: "No activity yet",
|
|
169
181
|
no_activity_hint: "Scans and actions will be recorded here.",
|
|
170
182
|
view_all: "View all files",
|
|
171
183
|
show_all_locations: "Show all locations",
|
|
172
184
|
tracked_note: "Only files {size} and larger appear here. Files keep their current locations.",
|
|
173
185
|
duplicate_note: "Only files waiting for review are shown.",
|
|
174
|
-
reclaimable_note: "These
|
|
186
|
+
reclaimable_note: "These private links have no remaining linked files. Cleaning them up frees disk space.",
|
|
187
|
+
activity_note: "Recent scans and Save space actions.",
|
|
175
188
|
converted: "Deduplicated",
|
|
176
189
|
files_left_separate: "{count} remained separate",
|
|
177
190
|
skipped_action: "Kept separate",
|
|
178
191
|
separated: "Separated",
|
|
179
|
-
reclaimed: "
|
|
192
|
+
reclaimed: "Cleaned up",
|
|
180
193
|
event_convert: "Deduplicated",
|
|
181
194
|
event_found: "Duplicate found",
|
|
182
195
|
event_adopt: "Added",
|
|
183
|
-
event_reclaim: "
|
|
196
|
+
event_reclaim: "Cleaned up unused link",
|
|
184
197
|
event_undo: "Undid deduplication",
|
|
185
198
|
event_diverged: "Changed by an app — no longer deduplicated",
|
|
186
199
|
event_detach: "Separated",
|
|
@@ -200,9 +213,10 @@ const statusUrl = (progress = false) => {
|
|
|
200
213
|
const reviewedScanKey = `pinokio:vault:reviewed-scan:${SCOPE_ID || "global"}`
|
|
201
214
|
const candidateSizeKey = "pinokio:vault:candidate-size"
|
|
202
215
|
const candidateSizeBase = document.body.dataset.platform === "win32" ? 1024 : 1000
|
|
203
|
-
const candidateSizeOptions = [10, 50, 100, 500]
|
|
216
|
+
const candidateSizeOptions = [1, 10, 50, 100, 500]
|
|
204
217
|
.map((value) => value * candidateSizeBase ** 2)
|
|
205
218
|
.concat(candidateSizeBase ** 3)
|
|
219
|
+
const PAGE_SIZE = 500
|
|
206
220
|
|
|
207
221
|
const state = {
|
|
208
222
|
data: null,
|
|
@@ -221,7 +235,9 @@ const state = {
|
|
|
221
235
|
scanProblemsOpen: false,
|
|
222
236
|
feedback: null,
|
|
223
237
|
actionProgress: null,
|
|
224
|
-
actionRequest: false
|
|
238
|
+
actionRequest: false,
|
|
239
|
+
page: 0,
|
|
240
|
+
pageContext: null
|
|
225
241
|
}
|
|
226
242
|
|
|
227
243
|
const el = (id) => document.getElementById(id)
|
|
@@ -232,7 +248,7 @@ const attr = esc
|
|
|
232
248
|
const fmt = window.PinokioFormatStorageSize
|
|
233
249
|
const candidateSize = () => {
|
|
234
250
|
const value = Number(el("vault-candidate-size").value)
|
|
235
|
-
return candidateSizeOptions.includes(value) ? value : candidateSizeOptions[
|
|
251
|
+
return candidateSizeOptions.includes(value) ? value : candidateSizeOptions[3]
|
|
236
252
|
}
|
|
237
253
|
const countLabel = (count, singular = COPY.file, plural = COPY.files) => `${count} ${count === 1 ? singular : plural}`
|
|
238
254
|
const basename = (value) => String(value || "").split(/[\\/]/).filter(Boolean).pop() || ""
|
|
@@ -274,6 +290,16 @@ const sourcePath = (source) => {
|
|
|
274
290
|
if (source.kind === "app") return IS_APP_MODE ? (source.display_path || source.root || "") : ""
|
|
275
291
|
return source.display_path || source.root || ""
|
|
276
292
|
}
|
|
293
|
+
const externalLocation = (item) => {
|
|
294
|
+
const source = sourceById(item.source_id)
|
|
295
|
+
if (!source || source.kind !== "external") return ""
|
|
296
|
+
const base = sourcePath(source).replace(/[\\/]+$/, "")
|
|
297
|
+
const relative = String(item.relative_path || "").replace(/^[\\/]+/, "")
|
|
298
|
+
if (!base) return relative
|
|
299
|
+
if (!relative) return base
|
|
300
|
+
const separator = base.includes("\\") && !base.includes("/") ? "\\" : "/"
|
|
301
|
+
return `${base}${separator}${separator === "\\" ? relative.replace(/\//g, "\\") : relative}`
|
|
302
|
+
}
|
|
277
303
|
const isDescendantSource = (candidateId, parentId) => {
|
|
278
304
|
if (!parentId) return true
|
|
279
305
|
if (candidateId === parentId) return true
|
|
@@ -331,9 +357,10 @@ const getCounts = (items) => ({
|
|
|
331
357
|
all: items.length,
|
|
332
358
|
duplicates: items.filter((item) => item.status === "duplicate").length,
|
|
333
359
|
shared: items.filter((item) => item.status === "shared").length,
|
|
360
|
+
tracked: items.filter((item) => item.status === "tracked").length,
|
|
334
361
|
independent: items.filter((item) => item.status === "independent").length,
|
|
335
362
|
reclaimable: state.data.blobs.filter((blob) => blob.orphan).length,
|
|
336
|
-
activity:
|
|
363
|
+
activity: activityItems("").length
|
|
337
364
|
})
|
|
338
365
|
|
|
339
366
|
const sourceCounts = (items) => {
|
|
@@ -356,6 +383,7 @@ const activeItems = (items) => {
|
|
|
356
383
|
let result = items
|
|
357
384
|
if (state.view === "duplicates") result = result.filter((item) => item.status === "duplicate")
|
|
358
385
|
if (state.view === "shared") result = result.filter((item) => item.status === "shared")
|
|
386
|
+
if (state.view === "tracked") result = result.filter((item) => item.status === "tracked")
|
|
359
387
|
if (state.view === "independent") result = result.filter((item) => item.status === "independent")
|
|
360
388
|
if (state.sourceId) result = result.filter((item) => isDescendantSource(item.source_id, state.sourceId))
|
|
361
389
|
if (state.view === "all" && state.statusFilter !== "all") result = result.filter((item) => item.status === state.statusFilter)
|
|
@@ -368,6 +396,7 @@ const viewIcon = {
|
|
|
368
396
|
all: "fa-regular fa-file-lines",
|
|
369
397
|
duplicates: "fa-regular fa-copy",
|
|
370
398
|
shared: "fa-solid fa-link",
|
|
399
|
+
tracked: "fa-regular fa-circle-check",
|
|
371
400
|
independent: "fa-solid fa-circle-minus",
|
|
372
401
|
reclaimable: "fa-regular fa-trash-can",
|
|
373
402
|
activity: "fa-solid fa-wave-square"
|
|
@@ -376,6 +405,7 @@ const viewLabel = {
|
|
|
376
405
|
all: COPY.all,
|
|
377
406
|
duplicates: COPY.duplicates,
|
|
378
407
|
shared: COPY.shared,
|
|
408
|
+
tracked: COPY.tracked,
|
|
379
409
|
independent: COPY.skipped,
|
|
380
410
|
reclaimable: COPY.reclaimable,
|
|
381
411
|
activity: COPY.activity
|
|
@@ -391,18 +421,30 @@ const eventLabels = {
|
|
|
391
421
|
skip: COPY.event_skip,
|
|
392
422
|
reshare: COPY.event_reshare
|
|
393
423
|
}
|
|
394
|
-
const activityItems = () => {
|
|
395
|
-
const query =
|
|
396
|
-
|
|
424
|
+
const activityItems = (queryText = state.query) => {
|
|
425
|
+
const query = queryText.trim().toLowerCase()
|
|
426
|
+
const events = state.data.events.filter((event) => !query ||
|
|
397
427
|
`${eventLabels[event.kind] || event.kind} ${event.path || ""}`.toLowerCase().includes(query))
|
|
428
|
+
const seenBatches = new Set()
|
|
429
|
+
const eventItems = events.map((event) => {
|
|
430
|
+
const showUndo = event.kind === "convert" && event.batch_id &&
|
|
431
|
+
event.undoable !== false && !seenBatches.has(event.batch_id)
|
|
432
|
+
if (showUndo) seenBatches.add(event.batch_id)
|
|
433
|
+
return Object.assign({}, event, { activity_type: "event", show_undo: showUndo })
|
|
434
|
+
})
|
|
435
|
+
const batchItems = (Array.isArray(state.data.undo_batches) ? state.data.undo_batches : [])
|
|
436
|
+
.filter((batch) => !seenBatches.has(batch.batch_id) && (!query ||
|
|
437
|
+
`${COPY.event_convert} ${batch.files || 0} ${COPY.files}`.toLowerCase().includes(query)))
|
|
438
|
+
.map((batch) => Object.assign({}, batch, { activity_type: "batch" }))
|
|
439
|
+
return batchItems.concat(eventItems)
|
|
398
440
|
}
|
|
399
441
|
|
|
400
442
|
const renderViews = (items) => {
|
|
401
443
|
const counts = getCounts(items)
|
|
402
444
|
el("views-label").textContent = COPY.views
|
|
403
445
|
const views = IS_APP_MODE
|
|
404
|
-
? ["all", "duplicates", "shared", "independent", "activity"]
|
|
405
|
-
: ["all", "duplicates", "shared", "independent", "reclaimable", "activity"]
|
|
446
|
+
? ["all", "duplicates", "shared", "tracked", "independent", "activity"]
|
|
447
|
+
: ["all", "duplicates", "shared", "tracked", "independent", "reclaimable", "activity"]
|
|
406
448
|
el("vault-views").innerHTML = views.map((view) => `
|
|
407
449
|
<button class="vault-nav-row ${state.view === view ? "selected" : ""}" type="button" data-view="${view}" ${state.view === view ? 'aria-current="page"' : ""}>
|
|
408
450
|
<i class="${viewIcon[view]}"></i>
|
|
@@ -510,12 +552,14 @@ const updateToolbarSummary = (visibleItems) => {
|
|
|
510
552
|
const searchPlaceholder = () => {
|
|
511
553
|
if (state.view === "duplicates") return COPY.search_duplicates
|
|
512
554
|
if (state.view === "shared") return COPY.search_shared
|
|
555
|
+
if (state.view === "tracked") return COPY.search_tracked
|
|
513
556
|
if (state.view === "independent") return COPY.search_skipped
|
|
514
557
|
if (state.view === "activity") return COPY.search_activity
|
|
515
558
|
const source = selectedSource()
|
|
516
559
|
return source && source.kind === "app" ? COPY.search_in.replace("{location}", source.label) : COPY.search_all
|
|
517
560
|
}
|
|
518
|
-
const supportsDisplayMode = () => state.view === "all" || state.view === "shared" ||
|
|
561
|
+
const supportsDisplayMode = () => state.view === "all" || state.view === "shared" ||
|
|
562
|
+
state.view === "tracked" || state.view === "independent"
|
|
519
563
|
const displayModeControl = () => supportsDisplayMode() ? `<div class="vault-display-mode" role="group" aria-label="${attr(COPY.display_mode)}">
|
|
520
564
|
<button type="button" data-display-mode="folders" aria-pressed="${state.displayMode === "folders"}" class="${state.displayMode === "folders" ? "selected" : ""}">${esc(COPY.folders)}</button>
|
|
521
565
|
<button type="button" data-display-mode="files" aria-pressed="${state.displayMode === "files"}" class="${state.displayMode === "files" ? "selected" : ""}">${esc(COPY.files_mode)}</button>
|
|
@@ -583,7 +627,7 @@ const sharingControl = (item) => {
|
|
|
583
627
|
const fileDetail = (item) => {
|
|
584
628
|
if (!state.expandedFiles.has(item.path) || !item.locations || item.locations.length < 2) return ""
|
|
585
629
|
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) => `
|
|
586
|
-
<div class="vault-location-detail"><i class="fa-regular fa-file"></i><span>${esc(location
|
|
630
|
+
<div class="vault-location-detail"><i class="fa-regular fa-file"></i><span>${esc(externalLocation(location) || [location.source_label, location.relative_path].filter(Boolean).join(" / "))}</span></div>`).join("")}</div>`
|
|
587
631
|
}
|
|
588
632
|
|
|
589
633
|
const renderFileRow = (item, depth = 0, showMatch = false) => {
|
|
@@ -680,7 +724,8 @@ const sourceSort = (a, b) => {
|
|
|
680
724
|
const rank = (source) => source && source.kind === "external" ? 1 : source ? 0 : 2
|
|
681
725
|
return rank(first) - rank(second) || groupTitle(first).localeCompare(groupTitle(second))
|
|
682
726
|
}
|
|
683
|
-
const flatLocation = (item) =>
|
|
727
|
+
const flatLocation = (item) => externalLocation(item) ||
|
|
728
|
+
[groupTitle(sourceById(item.source_id)), item.relative_path].filter(Boolean).join(" / ")
|
|
684
729
|
const renderFlatFiles = (items) => [...items]
|
|
685
730
|
.sort((a, b) => compareRows(a, b, flatLocation))
|
|
686
731
|
.map((item) => {
|
|
@@ -706,8 +751,9 @@ const renderDuplicateGroups = (items) => {
|
|
|
706
751
|
return [...groups.entries()].sort(sourceSort).map(([sourceId, group]) => {
|
|
707
752
|
const source = sourceById(sourceId)
|
|
708
753
|
const bytes = group.filter((item) => item.shareable).reduce((sum, item) => sum + item.size, 0)
|
|
709
|
-
const
|
|
710
|
-
|
|
754
|
+
const allShareable = scopeDuplicates(sourceId).filter((item) => item.shareable !== false)
|
|
755
|
+
const action = source && source.shareable && allShareable.length
|
|
756
|
+
? `<button class="vault-button" type="button" data-deduplicate-scope="${attr(sourceId)}">${esc(COPY.deduplicate)} ${countLabel(allShareable.length)}</button>`
|
|
711
757
|
: `<span class="vault-unavailable">${esc(COPY.sharing_unavailable)}</span>`
|
|
712
758
|
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) => compareRows(a, b, (item) => item.relative_path)).map((item) => renderFileRow(item, 0, true)).join("")}`
|
|
713
759
|
}).join("")
|
|
@@ -743,6 +789,7 @@ const emptyState = (view) => {
|
|
|
743
789
|
: [COPY.no_files, COPY.no_files_hint, "fa-regular fa-folder-open"],
|
|
744
790
|
duplicates: [COPY.no_duplicates, COPY.no_duplicates_hint, "fa-regular fa-circle-check"],
|
|
745
791
|
shared: [COPY.no_shared, COPY.no_shared_hint, "fa-solid fa-link"],
|
|
792
|
+
tracked: [COPY.no_tracked, COPY.no_tracked_hint, "fa-regular fa-circle-check"],
|
|
746
793
|
independent: [COPY.no_skipped, COPY.no_skipped_hint, "fa-solid fa-circle-minus"],
|
|
747
794
|
reclaimable: [COPY.no_reclaimable, COPY.no_reclaimable_hint, "fa-regular fa-circle-check"],
|
|
748
795
|
activity: [COPY.no_activity, COPY.no_activity_hint, "fa-solid fa-wave-square"]
|
|
@@ -751,36 +798,26 @@ const emptyState = (view) => {
|
|
|
751
798
|
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" && !activeScan ? `<button class="vault-button" type="button" id="btn-empty-scan">${esc(scanLabel)}</button>` : ""}</div></div>`
|
|
752
799
|
}
|
|
753
800
|
|
|
754
|
-
const renderReclaimable = () => {
|
|
755
|
-
const blobs = state.data.blobs.filter((blob) => blob.orphan)
|
|
801
|
+
const renderReclaimable = (blobs) => {
|
|
756
802
|
if (!blobs.length) return emptyState("reclaimable")
|
|
757
|
-
return
|
|
803
|
+
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 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("")
|
|
758
804
|
}
|
|
759
805
|
|
|
760
|
-
const renderActivity = () => {
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
`${COPY.event_convert} ${batch.files || 0} ${COPY.files}`.toLowerCase().includes(query))
|
|
766
|
-
: []
|
|
767
|
-
if (!events.length && !undoBatches.length) return emptyState("activity")
|
|
768
|
-
const seenBatches = new Set()
|
|
769
|
-
const eventRows = events.map((event) => {
|
|
770
|
-
let undo = ""
|
|
771
|
-
if (event.kind === "convert" && event.batch_id && event.undoable !== false && !seenBatches.has(event.batch_id)) {
|
|
772
|
-
seenBatches.add(event.batch_id)
|
|
773
|
-
undo = `<button class="vault-text-button" type="button" data-undo="${attr(event.batch_id)}">${esc(COPY.undo)}</button>`
|
|
806
|
+
const renderActivity = (items) => {
|
|
807
|
+
if (!items.length) return emptyState("activity")
|
|
808
|
+
return items.map((item) => {
|
|
809
|
+
if (item.activity_type === "batch") {
|
|
810
|
+
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(COPY.event_convert)}</span><span class="vault-file-path">${esc(countLabel(item.files || 0))}</span></span></div><span class="vault-size">${fmt(item.bytes || 0)}</span><span class="vault-event-time">${item.ts ? esc(new Date(item.ts).toLocaleString()) : "—"}</span><span class="vault-row-action"><button class="vault-text-button" type="button" data-undo="${attr(item.batch_id)}">${esc(COPY.undo)}</button></span></div>`
|
|
774
811
|
}
|
|
812
|
+
const event = item
|
|
813
|
+
const undo = event.show_undo
|
|
814
|
+
? `<button class="vault-text-button" type="button" data-undo="${attr(event.batch_id)}">${esc(COPY.undo)}</button>`
|
|
815
|
+
: ""
|
|
775
816
|
const eventPath = event.path
|
|
776
817
|
? `${event.source_label ? `${event.source_label} / ` : ""}${event.relative_path || event.path}`
|
|
777
818
|
: (event.hash || "").slice(0, 12)
|
|
778
819
|
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(eventLabels[event.kind] || event.kind)}</span><span class="vault-file-path">${esc(eventPath)}</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 class="vault-row-action">${undo}</span></div>`
|
|
779
820
|
}).join("")
|
|
780
|
-
const batchRows = undoBatches.filter((batch) => !seenBatches.has(batch.batch_id)).map((batch) =>
|
|
781
|
-
`<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(COPY.event_convert)}</span><span class="vault-file-path">${esc(countLabel(batch.files || 0))}</span></span></div><span class="vault-size">${fmt(batch.bytes || 0)}</span><span class="vault-event-time">${batch.ts ? esc(new Date(batch.ts).toLocaleString()) : "—"}</span><span class="vault-row-action"><button class="vault-text-button" type="button" data-undo="${attr(batch.batch_id)}">${esc(COPY.undo)}</button></span></div>`
|
|
782
|
-
).join("")
|
|
783
|
-
return batchRows + eventRows
|
|
784
821
|
}
|
|
785
822
|
|
|
786
823
|
const renderTable = (items) => {
|
|
@@ -794,11 +831,11 @@ const renderTable = (items) => {
|
|
|
794
831
|
} else if (state.view === "reclaimable") {
|
|
795
832
|
tableClass = "reclaimable"
|
|
796
833
|
headers = [COPY.name, COPY.size, COPY.can_free, ""]
|
|
797
|
-
body = renderReclaimable()
|
|
834
|
+
body = renderReclaimable(items)
|
|
798
835
|
} else if (state.view === "activity") {
|
|
799
836
|
tableClass = "activity"
|
|
800
837
|
headers = [COPY.name, COPY.size, COPY.last_scanned, ""]
|
|
801
|
-
body = renderActivity()
|
|
838
|
+
body = renderActivity(items)
|
|
802
839
|
} else if (state.displayMode === "files" && supportsDisplayMode()) {
|
|
803
840
|
tableClass = "flat"
|
|
804
841
|
headers = [COPY.name, COPY.location_column, COPY.size, COPY.status]
|
|
@@ -815,6 +852,71 @@ const renderTable = (items) => {
|
|
|
815
852
|
el("vault-table-wrap").innerHTML = `<div class="vault-table ${tableClass}"><div class="vault-columns">${headerMarkup}</div>${body}</div>`
|
|
816
853
|
}
|
|
817
854
|
|
|
855
|
+
const orderedItems = (items) => {
|
|
856
|
+
if (state.view === "activity") return items
|
|
857
|
+
if (state.view === "reclaimable") {
|
|
858
|
+
return [...items].sort((a, b) => compareRows(a, b,
|
|
859
|
+
(blob) => blob.names[0] ? blob.names[0].path : blob.hash))
|
|
860
|
+
}
|
|
861
|
+
if (state.displayMode === "files" && supportsDisplayMode()) {
|
|
862
|
+
return [...items].sort((a, b) => compareRows(a, b, flatLocation))
|
|
863
|
+
}
|
|
864
|
+
return [...items].sort((a, b) => {
|
|
865
|
+
const sourceOrder = state.sourceId ? 0 : sourceSort([a.source_id], [b.source_id])
|
|
866
|
+
return sourceOrder || compareRows(a, b, (item) => item.relative_path || item.path)
|
|
867
|
+
})
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
const pagedItems = (items) => {
|
|
871
|
+
const context = JSON.stringify([
|
|
872
|
+
state.view, state.sourceId, state.query, state.statusFilter,
|
|
873
|
+
state.displayMode, state.sizeSort
|
|
874
|
+
])
|
|
875
|
+
if (context !== state.pageContext) {
|
|
876
|
+
state.page = 0
|
|
877
|
+
state.pageContext = context
|
|
878
|
+
}
|
|
879
|
+
const ordered = orderedItems(items)
|
|
880
|
+
const pages = Math.max(1, Math.ceil(ordered.length / PAGE_SIZE))
|
|
881
|
+
state.page = Math.max(0, Math.min(state.page, pages - 1))
|
|
882
|
+
const start = state.page * PAGE_SIZE
|
|
883
|
+
const end = Math.min(start + PAGE_SIZE, ordered.length)
|
|
884
|
+
return { items: ordered.slice(start, end), start, end, total: ordered.length, pages }
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
const paneFooterText = (items) => {
|
|
888
|
+
if (state.displayMode === "files" && supportsDisplayMode()) {
|
|
889
|
+
const count = state.view === "shared"
|
|
890
|
+
? countLabel(items.length, "deduplicated file", "deduplicated files")
|
|
891
|
+
: state.view === "independent"
|
|
892
|
+
? countLabel(items.length, "file kept separate", "files kept separate")
|
|
893
|
+
: countLabel(items.length)
|
|
894
|
+
const order = state.sizeSort === "desc"
|
|
895
|
+
? COPY.sorted_largest
|
|
896
|
+
: state.sizeSort === "asc" ? COPY.sorted_smallest : ""
|
|
897
|
+
return `${count}${order ? ` · ${order}` : ""}`
|
|
898
|
+
}
|
|
899
|
+
if (state.view === "duplicates") return COPY.duplicate_note
|
|
900
|
+
if (state.view === "reclaimable") return COPY.reclaimable_note
|
|
901
|
+
if (state.view === "activity") return COPY.activity_note
|
|
902
|
+
return COPY.tracked_note.replace("{size}", fmt(candidateSize()))
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
const renderPaneFooter = (items, page) => {
|
|
906
|
+
const footer = el("vault-pane-footer")
|
|
907
|
+
const message = paneFooterText(items)
|
|
908
|
+
if (page.pages === 1) {
|
|
909
|
+
footer.textContent = message
|
|
910
|
+
return
|
|
911
|
+
}
|
|
912
|
+
footer.innerHTML = `<span>${esc(message)}</span>
|
|
913
|
+
<span class="vault-pagination" role="navigation" aria-label="${attr(COPY.file_pages)}">
|
|
914
|
+
<button class="vault-text-button" type="button" data-page="previous" ${state.page === 0 ? "disabled" : ""}>${esc(COPY.previous)}</button>
|
|
915
|
+
<span class="vault-page-range">${page.start + 1}–${page.end} of ${page.total}</span>
|
|
916
|
+
<button class="vault-text-button" type="button" data-page="next" ${state.page === page.pages - 1 ? "disabled" : ""}>${esc(COPY.next)}</button>
|
|
917
|
+
</span>`
|
|
918
|
+
}
|
|
919
|
+
|
|
818
920
|
const renderOverview = () => {
|
|
819
921
|
const data = state.data
|
|
820
922
|
const last = data.last_scan
|
|
@@ -1115,12 +1217,34 @@ const renderCloudWarning = () => {
|
|
|
1115
1217
|
warning.innerHTML = `<i class="fa-solid fa-triangle-exclamation"></i><span>${esc(message)}</span><button class="vault-text-button" id="btn-dismiss-cloud" type="button" data-warning-key="${attr(key)}">${esc(COPY.dismiss)}</button>`
|
|
1116
1218
|
}
|
|
1117
1219
|
|
|
1220
|
+
const renderCleanupNotice = () => {
|
|
1221
|
+
const notice = el("vault-cleanup-notice")
|
|
1222
|
+
const blobs = state.data && Array.isArray(state.data.blobs) ? state.data.blobs : []
|
|
1223
|
+
const unused = !IS_APP_MODE && state.data && state.data.enabled
|
|
1224
|
+
? blobs.filter((blob) => blob.orphan)
|
|
1225
|
+
: []
|
|
1226
|
+
if (!unused.length || state.view === "reclaimable") {
|
|
1227
|
+
notice.className = "vault-cleanup-notice"
|
|
1228
|
+
notice.innerHTML = ""
|
|
1229
|
+
return
|
|
1230
|
+
}
|
|
1231
|
+
const bytes = unused.reduce((sum, blob) => sum + (Number(blob.size) || 0), 0)
|
|
1232
|
+
const title = COPY.cleanup_ready.replace("{size}", fmt(bytes))
|
|
1233
|
+
const detail = COPY.cleanup_ready_detail.replace(
|
|
1234
|
+
"{count}",
|
|
1235
|
+
countLabel(unused.length, COPY.private_link, COPY.private_links)
|
|
1236
|
+
)
|
|
1237
|
+
notice.className = "vault-cleanup-notice show"
|
|
1238
|
+
notice.innerHTML = `<i class="fa-solid fa-broom" aria-hidden="true"></i><strong>${esc(title)}</strong><span class="vault-cleanup-notice-detail">${esc(detail)}</span><button class="vault-button" id="btn-review-cleanup" type="button">${esc(COPY.review_cleanup)}<i class="fa-solid fa-chevron-right" aria-hidden="true"></i></button>`
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1118
1241
|
const render = () => {
|
|
1119
1242
|
if (!state.data) return
|
|
1120
1243
|
renderOverview()
|
|
1121
1244
|
renderResult()
|
|
1122
1245
|
renderFeedback()
|
|
1123
1246
|
renderCloudWarning()
|
|
1247
|
+
renderCleanupNotice()
|
|
1124
1248
|
if (!state.data.enabled) {
|
|
1125
1249
|
el("vault-explorer").style.display = "none"
|
|
1126
1250
|
return
|
|
@@ -1131,23 +1255,10 @@ const render = () => {
|
|
|
1131
1255
|
renderLocations(items)
|
|
1132
1256
|
const visible = activeItems(items)
|
|
1133
1257
|
renderToolbar(visible, items)
|
|
1134
|
-
|
|
1258
|
+
const page = pagedItems(visible)
|
|
1259
|
+
renderTable(page.items)
|
|
1135
1260
|
renderActionProgress()
|
|
1136
|
-
|
|
1137
|
-
const count = state.view === "shared"
|
|
1138
|
-
? countLabel(visible.length, "deduplicated file", "deduplicated files")
|
|
1139
|
-
: state.view === "independent"
|
|
1140
|
-
? countLabel(visible.length, "file kept separate", "files kept separate")
|
|
1141
|
-
: countLabel(visible.length)
|
|
1142
|
-
const order = state.sizeSort === "desc" ? COPY.sorted_largest : state.sizeSort === "asc" ? COPY.sorted_smallest : ""
|
|
1143
|
-
el("vault-pane-footer").textContent = `${count}${order ? ` · ${order}` : ""}`
|
|
1144
|
-
} else {
|
|
1145
|
-
el("vault-pane-footer").textContent = state.view === "duplicates"
|
|
1146
|
-
? COPY.duplicate_note
|
|
1147
|
-
: state.view === "reclaimable"
|
|
1148
|
-
? COPY.reclaimable_note
|
|
1149
|
-
: COPY.tracked_note.replace("{size}", fmt(candidateSize()))
|
|
1150
|
-
}
|
|
1261
|
+
renderPaneFooter(visible, page)
|
|
1151
1262
|
}
|
|
1152
1263
|
|
|
1153
1264
|
const scanActive = (scan) => !!(scan && (scan.pending || scan.active || scan.queued > 0))
|
|
@@ -1203,13 +1314,16 @@ const applyFullData = (data) => {
|
|
|
1203
1314
|
render()
|
|
1204
1315
|
return scanning || !!fileAction
|
|
1205
1316
|
}
|
|
1317
|
+
let refreshSequence = 0
|
|
1206
1318
|
const refresh = async (forceFull = false) => {
|
|
1319
|
+
const sequence = ++refreshSequence
|
|
1207
1320
|
let delay = null
|
|
1208
1321
|
try {
|
|
1209
1322
|
const progressOnly = !forceFull &&
|
|
1210
1323
|
!!(state.data && (scanActive(state.data.scan) || state.actionProgress))
|
|
1211
1324
|
if (progressOnly) {
|
|
1212
1325
|
const progress = await fetchJson(statusUrl(true))
|
|
1326
|
+
if (sequence !== refreshSequence) return
|
|
1213
1327
|
state.data.scan = progress.scan
|
|
1214
1328
|
state.data.last_scan = progress.last_scan
|
|
1215
1329
|
const fileAction = serverFileAction(progress.file_action)
|
|
@@ -1221,16 +1335,22 @@ const refresh = async (forceFull = false) => {
|
|
|
1221
1335
|
renderActionProgress()
|
|
1222
1336
|
renderFeedback()
|
|
1223
1337
|
} else {
|
|
1224
|
-
|
|
1338
|
+
const data = await fetchJson(statusUrl())
|
|
1339
|
+
if (sequence !== refreshSequence) return
|
|
1340
|
+
if (applyFullData(data) || state.scanRequested) delay = 1500
|
|
1225
1341
|
}
|
|
1226
1342
|
} else {
|
|
1227
|
-
|
|
1343
|
+
const data = await fetchJson(statusUrl())
|
|
1344
|
+
if (sequence !== refreshSequence) return
|
|
1345
|
+
if (applyFullData(data) || state.scanRequested) delay = 1500
|
|
1228
1346
|
}
|
|
1229
1347
|
} catch (error) {
|
|
1348
|
+
if (sequence !== refreshSequence) return
|
|
1230
1349
|
state.feedback = { error: true, message: error && error.message ? error.message : String(error) }
|
|
1231
1350
|
renderFeedback()
|
|
1232
1351
|
delay = 5000
|
|
1233
1352
|
} finally {
|
|
1353
|
+
if (sequence !== refreshSequence) return
|
|
1234
1354
|
clearTimeout(window.__vaultRefresh)
|
|
1235
1355
|
window.__vaultRefresh = delay == null ? null : setTimeout(refresh, delay)
|
|
1236
1356
|
}
|
|
@@ -1405,6 +1525,10 @@ document.addEventListener("click", async (event) => {
|
|
|
1405
1525
|
if (target.id === "btn-dismiss-cloud") {
|
|
1406
1526
|
try { localStorage.setItem(target.dataset.warningKey, "1") } catch (error) {}
|
|
1407
1527
|
renderCloudWarning()
|
|
1528
|
+
} else if (target.dataset.page) {
|
|
1529
|
+
state.page += target.dataset.page === "next" ? 1 : -1
|
|
1530
|
+
render()
|
|
1531
|
+
el("vault-table-wrap").scrollTop = 0
|
|
1408
1532
|
} else if (target.hasAttribute("data-sort-size")) {
|
|
1409
1533
|
state.sizeSort = state.sizeSort === "desc" ? "asc" : "desc"
|
|
1410
1534
|
render()
|
|
@@ -1412,8 +1536,8 @@ document.addEventListener("click", async (event) => {
|
|
|
1412
1536
|
state.displayMode = target.dataset.displayMode === "files" ? "files" : "folders"
|
|
1413
1537
|
state.sizeSort = state.displayMode === "files" ? "desc" : null
|
|
1414
1538
|
render()
|
|
1415
|
-
} else if (target.dataset.view) {
|
|
1416
|
-
state.view = target.dataset.view
|
|
1539
|
+
} else if (target.dataset.view || target.id === "btn-review-cleanup") {
|
|
1540
|
+
state.view = target.dataset.view || "reclaimable"
|
|
1417
1541
|
state.sourceId = SCOPE_ID
|
|
1418
1542
|
state.query = ""
|
|
1419
1543
|
state.statusFilter = "all"
|
|
@@ -1597,7 +1721,9 @@ document.addEventListener("input", (event) => {
|
|
|
1597
1721
|
state.query = event.target.value
|
|
1598
1722
|
const items = activeItems(buildItems())
|
|
1599
1723
|
updateToolbarSummary(items)
|
|
1600
|
-
|
|
1724
|
+
const page = pagedItems(items)
|
|
1725
|
+
renderTable(page.items)
|
|
1726
|
+
renderPaneFooter(items, page)
|
|
1601
1727
|
renderActionProgress()
|
|
1602
1728
|
})
|
|
1603
1729
|
document.addEventListener("change", (event) => {
|
|
@@ -1617,7 +1743,7 @@ candidateSizeSelect.setAttribute("aria-label", COPY.minimum_file_size)
|
|
|
1617
1743
|
candidateSizeSelect.innerHTML = candidateSizeOptions
|
|
1618
1744
|
.map((size) => `<option value="${size}">${fmt(size)}+</option>`)
|
|
1619
1745
|
.join("")
|
|
1620
|
-
candidateSizeSelect.value = String(candidateSizeOptions[
|
|
1746
|
+
candidateSizeSelect.value = String(candidateSizeOptions[3])
|
|
1621
1747
|
try {
|
|
1622
1748
|
const storedCandidateSize = Number(localStorage.getItem(candidateSizeKey))
|
|
1623
1749
|
if (candidateSizeOptions.includes(storedCandidateSize)) {
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
<div class='vault-result' id='vault-result' role='status' aria-live='polite'></div>
|
|
26
26
|
<div class='vault-feedback' id='vault-feedback' role='status' aria-live='polite'></div>
|
|
27
27
|
<div class='vault-cloud-warning' id='vault-cloud-warning' role='status'></div>
|
|
28
|
+
<div class='vault-cleanup-notice' id='vault-cleanup-notice' role='status'></div>
|
|
28
29
|
<section class='vault-explorer' id='vault-explorer'>
|
|
29
30
|
<aside class='vault-rail'>
|
|
30
31
|
<div class='vault-rail-section'>
|
|
@@ -97,6 +97,27 @@ describe('vault engine (phase 1)', () => {
|
|
|
97
97
|
assert.strictEqual(fs.existsSync(path.resolve(h, 'vault', 'registry.json')), true)
|
|
98
98
|
})
|
|
99
99
|
|
|
100
|
+
test('startup verification reports unused private links without deleting them', async () => {
|
|
101
|
+
const h = await home()
|
|
102
|
+
const vault = await makeVault(h)
|
|
103
|
+
const content = crypto.randomBytes(4096)
|
|
104
|
+
const hash = sha256(content)
|
|
105
|
+
const file = await writeFile(path.resolve(h, 'api', 'appA', 'unused.bin'), content)
|
|
106
|
+
await vault.adopt(file, hash, { app: 'appA' })
|
|
107
|
+
await vault.registry.flush()
|
|
108
|
+
await fs.promises.unlink(file)
|
|
109
|
+
|
|
110
|
+
const fresh = new Vault(fakeKernel(h))
|
|
111
|
+
await fresh.init({ existingOnly: true })
|
|
112
|
+
fresh.verificationPending = true
|
|
113
|
+
await fresh.ensureInitialized()
|
|
114
|
+
|
|
115
|
+
assert.strictEqual(fs.existsSync(fresh.storePathFor(hash)), true)
|
|
116
|
+
assert.strictEqual(fresh.registry.links.has(file), false)
|
|
117
|
+
assert.strictEqual(fresh.registry.blobs.get(hash).orphan, true)
|
|
118
|
+
assert.strictEqual((await fresh.status()).reclaimable, content.length)
|
|
119
|
+
})
|
|
120
|
+
|
|
100
121
|
test('adopt: metadata-only, store name shares the inode', async () => {
|
|
101
122
|
const h = await home()
|
|
102
123
|
const vault = await makeVault(h)
|