pinokiod 8.0.102 → 8.0.105

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.
@@ -2854,6 +2854,9 @@ class Vault {
2854
2854
  ))
2855
2855
  await this.refreshInodeSnapshots(entry.hash, entry.dev, entry.ino)
2856
2856
  if (options.reclassify !== false) {
2857
+ // Free before reclassifying: classification must see the final state,
2858
+ // or it records a reference against an anchor about to be deleted.
2859
+ await this.freeUnusedAnchors([entry.hash])
2857
2860
  await this.reclassifyHashes([entry.hash])
2858
2861
  }
2859
2862
  if (options.changedHashes) options.changedHashes.add(entry.hash)
@@ -2915,6 +2918,7 @@ class Vault {
2915
2918
  }
2916
2919
  }
2917
2920
  if (affectedHashes.size) {
2921
+ await this.freeUnusedAnchors(affectedHashes)
2918
2922
  await this.reclassifyHashes(affectedHashes)
2919
2923
  }
2920
2924
  const event = {
@@ -2998,6 +3002,7 @@ class Vault {
2998
3002
  }
2999
3003
  }
3000
3004
  if (affectedHashes.size) {
3005
+ await this.freeUnusedAnchors(affectedHashes)
3001
3006
  await this.reclassifyHashes(affectedHashes)
3002
3007
  }
3003
3008
  if (changedHashes instanceof FileActionChanges) {
@@ -3074,6 +3079,27 @@ class Vault {
3074
3079
  return result
3075
3080
  }
3076
3081
 
3082
+ // Separating the last path to shared content leaves an anchor nothing
3083
+ // references. Freeing it here keeps that cleanup out of the user's hands.
3084
+ // reclaim() revalidates and refuses while any path still links, so a
3085
+ // partial separation frees nothing and a failure is never fatal.
3086
+ async freeUnusedAnchors(hashes) {
3087
+ for (const hash of hashes) {
3088
+ let anchors = []
3089
+ try {
3090
+ anchors = await this.registry.anchorsForHash(hash)
3091
+ } catch (error) {
3092
+ continue
3093
+ }
3094
+ for (const anchor of anchors) {
3095
+ if (anchor.nlink !== 1) continue
3096
+ try {
3097
+ await this.reclaim(hash, anchor.store_id)
3098
+ } catch (error) {}
3099
+ }
3100
+ }
3101
+ }
3102
+
3077
3103
  async reclaimAll() {
3078
3104
  const summary = { reclaimed: 0, bytes_freed: 0, failed: 0 }
3079
3105
  let cursor = { store_id: "", hash: "" }
@@ -3330,6 +3356,45 @@ class Vault {
3330
3356
  }
3331
3357
  }
3332
3358
 
3359
+ async fileLocations(scopeId, filePath, options = {}) {
3360
+ if (typeof filePath !== "string" || !filePath) {
3361
+ throw new TypeError("Invalid vault file path.")
3362
+ }
3363
+ const target = path.resolve(filePath)
3364
+ const entry = await this.registry.getFile(target)
3365
+ const sourceIds = this.scopeSourceIds(scopeId)
3366
+ if (!entry ||
3367
+ (scopeId && !sourceIds.includes(entry.source_id))) {
3368
+ return { path: target, items: [], total: 0, next_cursor: null }
3369
+ }
3370
+ const cursor = typeof options.cursor === "string"
3371
+ ? options.cursor.slice(0, 2048)
3372
+ : ""
3373
+ const pageSize = boundedInteger(
3374
+ options.page_size, 100, 1, STATUS_PAGE_SIZE)
3375
+ const identity = entry.status === "linked"
3376
+ ? { dev: entry.dev, ino: entry.ino }
3377
+ : { hash: entry.hash }
3378
+ if (!identity.hash && !Number.isFinite(identity.dev)) {
3379
+ return { path: target, items: [], total: 0, next_cursor: null }
3380
+ }
3381
+ // Every authorized location is listed whatever the scope. A scope decides
3382
+ // what can be selected and acted on, not what the user is told exists.
3383
+ const result = await this.registry.fileLocationChildren(Object.assign({
3384
+ externalSourceIds: this.configuredExternalSourceIds(),
3385
+ cursor,
3386
+ pageSize
3387
+ }, identity))
3388
+ return {
3389
+ path: target,
3390
+ items: (result.rows || []).map((row) => Object.assign({
3391
+ path: row.path
3392
+ }, this.locationForPath(row.path, row.source_id))),
3393
+ total: Number(result.total) || 0,
3394
+ next_cursor: result.nextCursor || null
3395
+ }
3396
+ }
3397
+
3333
3398
  async duplicateGroupSelection(scopeId, hash, options = {}) {
3334
3399
  if (typeof hash !== "string" || !SHA256_RE.test(hash)) {
3335
3400
  throw new TypeError("Invalid vault content identifier.")
@@ -206,6 +206,7 @@ for (const method of [
206
206
  "appsForHashes",
207
207
  "reclaimableBatch",
208
208
  "duplicateGroupChildren",
209
+ "fileLocationChildren",
209
210
  "duplicateGroupSelection",
210
211
  "duplicateGroupPageSelection",
211
212
  "statusSnapshot",
@@ -5633,6 +5633,77 @@ class RegistryCore {
5633
5633
  }
5634
5634
  }
5635
5635
 
5636
+ fileLocationChildren(options = {}) {
5637
+ const externalSourceIds = [...new Set(
5638
+ (options.externalSourceIds || []).filter(Boolean))]
5639
+ const pageSize = Math.max(
5640
+ 1, Math.min(500, Number(options.pageSize) || 100))
5641
+ const inode = Number.isFinite(options.dev) && Number.isFinite(options.ino)
5642
+ const identity = inode
5643
+ ? `${options.dev}:${options.ino}`
5644
+ : String(options.hash || "")
5645
+ const authorized = this.duplicateGroupFilter(
5646
+ [],
5647
+ "",
5648
+ true,
5649
+ "child",
5650
+ inode ? ["linked"] : [],
5651
+ externalSourceIds
5652
+ )
5653
+ const identityWhere = inode
5654
+ ? "child.dev = ? AND child.ino = ?"
5655
+ : "child.hash = ?"
5656
+ const identityValues = inode
5657
+ ? [options.dev, options.ino]
5658
+ : [options.hash]
5659
+ const decoded = this.decodeCursor(options.cursor)
5660
+ const cursorWhere = []
5661
+ const cursorValues = []
5662
+ if (decoded &&
5663
+ decoded.sort === "file-locations" &&
5664
+ decoded.identity === identity &&
5665
+ typeof decoded.path === "string") {
5666
+ cursorWhere.push("child.path > ?")
5667
+ cursorValues.push(decoded.path)
5668
+ }
5669
+ const rows = this.database.prepare(`
5670
+ SELECT child.*
5671
+ FROM files child
5672
+ WHERE ${identityWhere}
5673
+ AND ${authorized.where.join(" AND ")}
5674
+ ${cursorWhere.length
5675
+ ? `AND ${cursorWhere.join(" AND ")}`
5676
+ : ""}
5677
+ ORDER BY child.path
5678
+ LIMIT ?
5679
+ `).all(
5680
+ ...identityValues,
5681
+ ...authorized.values,
5682
+ ...cursorValues,
5683
+ pageSize + 1
5684
+ )
5685
+ const hasMore = rows.length > pageSize
5686
+ if (hasMore) rows.pop()
5687
+ const last = rows[rows.length - 1]
5688
+ const total = Number(this.database.prepare(`
5689
+ SELECT COUNT(*) AS count
5690
+ FROM files child
5691
+ WHERE ${identityWhere}
5692
+ AND ${authorized.where.join(" AND ")}
5693
+ `).get(...identityValues, ...authorized.values).count) || 0
5694
+ return {
5695
+ rows,
5696
+ total,
5697
+ nextCursor: hasMore && last
5698
+ ? this.encodeCursor({
5699
+ sort: "file-locations",
5700
+ identity,
5701
+ path: last.path
5702
+ })
5703
+ : null
5704
+ }
5705
+ }
5706
+
5636
5707
  duplicateGroupSelection(options = {}) {
5637
5708
  const sourceIds = [...new Set(
5638
5709
  (options.sourceIds || []).filter(Boolean))]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pinokiod",
3
- "version": "8.0.102",
3
+ "version": "8.0.105",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "scripts": {
package/server/index.js CHANGED
@@ -15883,6 +15883,17 @@ class Server {
15883
15883
  req.query.folder_discovery_child_page))
15884
15884
  return
15885
15885
  }
15886
+ const locationsPath = req.query &&
15887
+ typeof req.query.locations_path === "string"
15888
+ ? req.query.locations_path
15889
+ : null
15890
+ if (locationsPath) {
15891
+ res.json(await vault.fileLocations(scopeId, locationsPath, {
15892
+ cursor: req.query && req.query.cursor,
15893
+ page_size: req.query && req.query.page_size
15894
+ }))
15895
+ return
15896
+ }
15886
15897
  const groupHash = req.query &&
15887
15898
  typeof req.query.group_hash === "string"
15888
15899
  ? req.query.group_hash
@@ -5,14 +5,14 @@ const COPY = {
5
5
  duplicates: "Duplicates",
6
6
  cannot_deduplicate: "Cannot deduplicate",
7
7
  shared: "Deduplicated",
8
- reclaimable: "Unused files",
8
+ reclaimable: "Trash",
9
9
  activity: "Activity",
10
10
  all_description: "Every scanned file and its current deduplication status.",
11
11
  duplicates_description: "Identical files waiting to be deduplicated.",
12
12
  unavailable_description: "Identical files that cannot share storage.",
13
13
  shared_description: "Files currently sharing disk storage through hardlinks.",
14
14
  tracked_description: "Files with no duplicate action required.",
15
- reclaimable_description: "Private links no longer used by any linked file.",
15
+ reclaimable_description: "Spare copies no file is using.",
16
16
  activity_description: "A history of changes made by Disk Saver.",
17
17
  add_external_folder: "Add external folder",
18
18
  find_folders: "Find more savings",
@@ -236,16 +236,15 @@ const COPY = {
236
236
  selected_copy: "Selected",
237
237
  loading_copies: "Loading copies…",
238
238
  show_more_copies: "Show {count} more copies",
239
+ loading_locations: "Loading locations…",
240
+ show_more_locations: "Show {count} more locations",
239
241
  making_separate: "Making file separate",
240
242
  make_separate: "Make separate",
241
243
  try_again: "Try again",
242
- reclaim: "Clean up",
243
- reclaim_all: "Clean up all",
244
- cleanup_ready: "{size} ready to clean up",
245
- cleanup_ready_detail: "{count} left after their linked files were deleted.",
246
- review_cleanup: "Review cleanup",
247
- private_link: "private link",
248
- private_links: "private links",
244
+ reclaim: "Delete",
245
+ reclaim_all: "Empty Trash",
246
+ cleanup_ready: "{size} in the Trash",
247
+ review_cleanup: "Open Trash",
249
248
  make_file_separate: "Make file separate",
250
249
  make_files_separate: "Make {count} files separate",
251
250
  making_separate_selected: "Making files separate",
@@ -260,7 +259,6 @@ const COPY = {
260
259
  select_for_separation: "Select to make separate",
261
260
  select_all_on_page: "Select all on this page",
262
261
  identical_contents_at: "Identical contents at",
263
- locations_shown: "{shown} of {total} locations shown",
264
262
  no_files: "No files found",
265
263
  no_files_hint: "Run a scan to find files that can be deduplicated. Scanning never links files together or replaces them.",
266
264
  scan_waiting: "Waiting for scan results",
@@ -273,21 +271,20 @@ const COPY = {
273
271
  no_shared_hint: "Deduplicated files will appear here after you review duplicates.",
274
272
  no_tracked: "No files with no action needed",
275
273
  no_tracked_hint: "Files without a duplicate action will appear here after a scan.",
276
- no_reclaimable: "No cleanup needed",
277
- no_reclaimable_hint: "Private links with no remaining linked files will appear here.",
274
+ no_reclaimable: "Trash is empty",
275
+ no_reclaimable_hint: "Spare copies Pinokio no longer needs appear here.",
278
276
  no_activity: "No activity yet",
279
277
  no_activity_hint: "Changes made by Disk Saver will appear here.",
280
278
  view_all: "View all files",
281
279
  tracked_note: "Only files {size} and larger appear here. Files keep their current locations.",
282
280
  tracked_note_all: "All non-empty files appear here. Files keep their current locations.",
283
281
  duplicate_note: "Only files waiting for review are shown.",
284
- reclaimable_note: "These private links have no remaining linked files. Cleaning them up frees disk space.",
285
282
  activity_note: "Recent changes made by Disk Saver.",
286
283
  converted: "Deduplicated",
287
284
  separated: "Separated",
288
285
  reclaimed: "Cleaned up",
289
286
  event_convert: "Deduplicated",
290
- event_reclaim: "Cleaned up unused link",
287
+ event_reclaim: "Emptied from Trash",
291
288
  event_detach: "Separated",
292
289
  event_change: "File state changed"
293
290
  }
@@ -330,6 +327,14 @@ const folderDiscoveryChildrenUrl = (folder, page = 0) => {
330
327
  })
331
328
  return `/info/dedup?${query.toString()}`
332
329
  }
330
+ const fileLocationsUrl = (filePath, options = {}) => {
331
+ const query = new URLSearchParams()
332
+ if (SCOPE_ID) query.set("scope_id", SCOPE_ID)
333
+ query.set("locations_path", filePath)
334
+ if (options.cursor) query.set("cursor", options.cursor)
335
+ query.set("page_size", String(DUPLICATE_CHILD_PAGE_SIZE))
336
+ return `/info/dedup?${query.toString()}`
337
+ }
333
338
  const duplicateGroupUrl = (hash, options = {}) => {
334
339
  const query = new URLSearchParams()
335
340
  if (SCOPE_ID) query.set("scope_id", SCOPE_ID)
@@ -416,6 +421,8 @@ const state = {
416
421
  expandedFiles: new Set(),
417
422
  expandedDuplicateGroups: new Set(),
418
423
  duplicateGroupChildren: new Map(),
424
+ fileLocations: new Map(),
425
+ locationsScanTs: undefined,
419
426
  duplicateGroupGeneration: 0,
420
427
  selectedDuplicateFiles: new Map(),
421
428
  selectedSeparateFiles: new Set(),
@@ -454,11 +461,19 @@ const state = {
454
461
  pageCursors: [""]
455
462
  }
456
463
 
464
+ // Expanded location lists describe one grouping of one file. Anything that
465
+ // can change that grouping closes them instead of leaving stale paths open.
466
+ const closeFileLocations = () => {
467
+ state.expandedFiles.clear()
468
+ state.fileLocations.clear()
469
+ }
470
+
457
471
  const resetPage = () => {
458
472
  state.page = 0
459
473
  state.pageCursors = [""]
460
474
  state.expandedDuplicateGroups.clear()
461
475
  state.duplicateGroupChildren.clear()
476
+ closeFileLocations()
462
477
  state.duplicateGroupGeneration += 1
463
478
  }
464
479
 
@@ -1696,15 +1711,22 @@ const sharingControl = (item) => {
1696
1711
  }
1697
1712
 
1698
1713
  const fileDetail = (item) => {
1699
- if (!state.expandedFiles.has(item.path) || !item.locations || item.locations.length < 2) return ""
1700
- const total = Math.max(item.locations.length, Number(item.location_count) || 0)
1701
- const label = total > item.locations.length
1702
- ? COPY.locations_shown
1703
- .replace("{shown}", item.locations.length)
1704
- .replace("{total}", total)
1705
- : `${COPY.identical_contents_at} ${countLabel(total, COPY.location, COPY.locations_lower)}`
1706
- return `<div class="vault-detail"><div class="vault-detail-label">${esc(label)}</div>${item.locations.map((location) => `
1707
- <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>${revealButton(location.path, location.source_id, basename(location.relative_path))}</div>`).join("")}</div>`
1714
+ if (!state.expandedFiles.has(item.path)) return ""
1715
+ const locations = state.fileLocations.get(item.path)
1716
+ if (!locations || (locations.loading && !locations.loaded)) {
1717
+ return `<div class="vault-detail"><div class="vault-detail-label"><i class="fa-solid fa-circle-notch fa-spin"></i> ${esc(COPY.loading_locations)}</div></div>`
1718
+ }
1719
+ if (locations.error) {
1720
+ return `<div class="vault-detail"><div class="vault-detail-label error">${esc(locations.error)}</div></div>`
1721
+ }
1722
+ const total = Math.max(locations.items.length, Number(locations.total) || 0)
1723
+ const label = `${COPY.identical_contents_at} ${countLabel(total, COPY.location, COPY.locations_lower)}`
1724
+ const remaining = Math.max(0, total - locations.items.length)
1725
+ const more = locations.nextCursor
1726
+ ? `<div class="vault-location-detail"><button class="vault-text-button" type="button" data-more-file-locations="${attr(item.path)}" ${locations.loading ? "disabled" : ""}>${esc(COPY.show_more_locations.replace("{count}", Math.min(remaining, DUPLICATE_CHILD_PAGE_SIZE)))}</button></div>`
1727
+ : ""
1728
+ return `<div class="vault-detail"><div class="vault-detail-label">${esc(label)}</div>${locations.items.map((location) => `
1729
+ <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>${revealButton(location.path, location.source_id, basename(location.relative_path))}</div>`).join("")}${more}</div>`
1708
1730
  }
1709
1731
  const separateCheckbox = (item) => item.status === "shared"
1710
1732
  ? `<input class="vault-row-checkbox" type="checkbox" data-select-separate="${attr(item.path)}" aria-label="${attr(`${COPY.select_for_separation}: ${basename(item.relative_path)}`)}" ${state.separateAllMatching || state.selectedSeparateFiles.has(item.path) ? "checked" : ""} />`
@@ -1721,7 +1743,7 @@ const rowSelectionCheckbox = (item) =>
1721
1743
  const renderFileRow = (item, depth = 0, showMatch = false) => {
1722
1744
  const directoryPath = dirname(item.relative_path)
1723
1745
  const match = item.match
1724
- const expandable = item.locations && item.locations.length > 1
1746
+ const expandable = Number(item.location_count) > 1
1725
1747
  const rowTail = showMatch
1726
1748
  ? `<span>${match ? `<span class="vault-match-path">${esc(match.path)}</span>` : "—"}</span>
1727
1749
  <span class="vault-space">${esc(spaceMarkup(item))}</span>
@@ -1826,7 +1848,7 @@ const flatLocation = (item) => externalLocation(item) ||
1826
1848
  const renderFlatFiles = (items) => [...items]
1827
1849
  .sort((a, b) => compareRows(a, b, flatLocation))
1828
1850
  .map((item) => {
1829
- const expandable = item.locations && item.locations.length > 1
1851
+ const expandable = Number(item.location_count) > 1
1830
1852
  return `<div class="vault-file-row">
1831
1853
  <div class="vault-name-cell">
1832
1854
  ${separateCheckbox(item)}
@@ -2230,7 +2252,7 @@ const paneFooterText = () => {
2230
2252
  return `${count}${order ? ` · ${order}` : ""}`
2231
2253
  }
2232
2254
  if (state.view === "duplicates") return COPY.duplicate_note
2233
- if (state.view === "reclaimable") return COPY.reclaimable_note
2255
+ if (state.view === "reclaimable") return ""
2234
2256
  if (state.view === "activity") return COPY.activity_note
2235
2257
  const minimumSize = state.data.last_scan &&
2236
2258
  Number.isFinite(state.data.last_scan.candidate_min_bytes)
@@ -2754,12 +2776,8 @@ const renderCleanupNotice = () => {
2754
2776
  }
2755
2777
  const bytes = Number(state.data.reclaimable) || 0
2756
2778
  const title = COPY.cleanup_ready.replace("{size}", fmt(bytes))
2757
- const detail = COPY.cleanup_ready_detail.replace(
2758
- "{count}",
2759
- countLabel(unusedCount, COPY.private_link, COPY.private_links)
2760
- )
2761
2779
  notice.className = "vault-cleanup-notice show"
2762
- 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>`
2780
+ notice.innerHTML = `<i class="fa-regular fa-trash-can" aria-hidden="true"></i><strong>${esc(title)}</strong><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>`
2763
2781
  }
2764
2782
 
2765
2783
  const clearPanel = (id, className) => {
@@ -2846,9 +2864,9 @@ const fetchJson = async (url) => {
2846
2864
  if (!response.ok) throw new Error(COPY.status_request_failed.replace("{status}", response.status))
2847
2865
  return response.json()
2848
2866
  }
2849
- const loadDuplicateGroupChildren = async (hash, append = false) => {
2867
+ const loadPagedChildren = async (cache, key, buildUrl, append) => {
2850
2868
  const generation = state.duplicateGroupGeneration
2851
- const current = state.duplicateGroupChildren.get(hash) || {
2869
+ const current = cache.get(key) || {
2852
2870
  items: [],
2853
2871
  total: 0,
2854
2872
  nextCursor: null,
@@ -2859,12 +2877,11 @@ const loadDuplicateGroupChildren = async (hash, append = false) => {
2859
2877
  if (current.loading) return
2860
2878
  current.loading = true
2861
2879
  current.error = null
2862
- state.duplicateGroupChildren.set(hash, current)
2880
+ cache.set(key, current)
2863
2881
  render()
2864
2882
  try {
2865
- const result = await fetchJson(duplicateGroupUrl(hash, {
2866
- cursor: append ? current.nextCursor : null
2867
- }))
2883
+ const result = await fetchJson(
2884
+ buildUrl(append ? current.nextCursor : null))
2868
2885
  if (generation !== state.duplicateGroupGeneration) return
2869
2886
  const items = Array.isArray(result.items) ? result.items : []
2870
2887
  current.items = append ? current.items.concat(items) : items
@@ -2883,6 +2900,12 @@ const loadDuplicateGroupChildren = async (hash, append = false) => {
2883
2900
  }
2884
2901
  }
2885
2902
  }
2903
+ const loadDuplicateGroupChildren = (hash, append = false) =>
2904
+ loadPagedChildren(state.duplicateGroupChildren, hash, (cursor) =>
2905
+ duplicateGroupUrl(hash, { cursor }), append)
2906
+ const loadFileLocations = (filePath, append = false) =>
2907
+ loadPagedChildren(state.fileLocations, filePath, (cursor) =>
2908
+ fileLocationsUrl(filePath, { cursor }), append)
2886
2909
  const duplicateGroupSelectionPaths = async (hash) =>
2887
2910
  fetchJson(duplicateGroupUrl(hash, { select: true }))
2888
2911
  const duplicateGroupPageSelectionItems = async () =>
@@ -2919,6 +2942,11 @@ const applyFullData = (data) => {
2919
2942
  reviewedScan() !== String(data.last_scan.ts) &&
2920
2943
  (shareableDuplicateCount > 0 || data.last_scan.partial)
2921
2944
  settleFolderDiscoveryStart()
2945
+ const publishedScan = data.last_scan ? data.last_scan.ts : null
2946
+ if (publishedScan !== state.locationsScanTs) {
2947
+ state.locationsScanTs = publishedScan
2948
+ closeFileLocations()
2949
+ }
2922
2950
  state.data = data
2923
2951
  const fileAction = serverFileAction(data.file_action)
2924
2952
  if (fileAction) state.actionProgress = fileAction
@@ -3034,6 +3062,7 @@ const runAction = async (payload, success) => {
3034
3062
  } catch (error) {
3035
3063
  state.feedback = { error: true, message: error && error.message ? error.message : String(error) }
3036
3064
  }
3065
+ closeFileLocations()
3037
3066
  await refresh(true)
3038
3067
  }
3039
3068
 
@@ -3741,11 +3770,21 @@ document.addEventListener("click", async (event) => {
3741
3770
  target.dataset.moreDuplicateGroup,
3742
3771
  true
3743
3772
  )
3773
+ } else if (target.dataset.moreFileLocations) {
3774
+ await loadFileLocations(target.dataset.moreFileLocations, true)
3744
3775
  } else if (target.dataset.expandFile) {
3745
3776
  const file = target.dataset.expandFile
3746
- if (state.expandedFiles.has(file)) state.expandedFiles.delete(file)
3747
- else state.expandedFiles.add(file)
3748
- render()
3777
+ if (state.expandedFiles.has(file)) {
3778
+ state.expandedFiles.delete(file)
3779
+ render()
3780
+ } else {
3781
+ state.expandedFiles.add(file)
3782
+ render()
3783
+ const loaded = state.fileLocations.get(file)
3784
+ if (!loaded || (!loaded.loaded && !loaded.loading)) {
3785
+ await loadFileLocations(file)
3786
+ }
3787
+ }
3749
3788
  } else if (target.dataset.removeSource) {
3750
3789
  const source = sourceById(target.dataset.removeSource)
3751
3790
  if (!source) return
@@ -2447,6 +2447,159 @@ describe("Save Space engine", () => {
2447
2447
  await close(vault)
2448
2448
  })
2449
2449
 
2450
+ test("Make separate frees the anchor once nothing links to it", async () => {
2451
+ const { home, vault } = await makeVault()
2452
+ const pair = await duplicatePair(home)
2453
+ await vault.sweeper.scan()
2454
+ const duplicate = [...await vault.registry.files({
2455
+ statuses: ["duplicate"]
2456
+ })][0]
2457
+ await vault.perform("deduplicate", { path: duplicate.path })
2458
+ const hash = duplicate.hash
2459
+ const storePath = vault.storePathFor(hash)
2460
+ const linked = [...await vault.registry.files({ statuses: ["linked"] })]
2461
+ assert.equal(linked.length, 2)
2462
+
2463
+ assert.equal((await vault.perform("detach", {
2464
+ path: linked[0].path
2465
+ })).status, "detached")
2466
+ assert.equal(fs.existsSync(storePath), true)
2467
+ assert.equal((await fs.promises.stat(storePath)).nlink, 2)
2468
+ assert.equal((await vault.registry.anchorsForHash(hash)).length, 1)
2469
+
2470
+ assert.equal((await vault.perform("detach", {
2471
+ path: linked[1].path
2472
+ })).status, "detached")
2473
+ assert.equal(fs.existsSync(storePath), false)
2474
+ assert.equal((await vault.registry.anchorsForHash(hash)).length, 0)
2475
+
2476
+ for (const entry of linked) {
2477
+ await assertCandidateContents(entry.path, pair.contents, pair.size)
2478
+ }
2479
+ const activity = await vault.status(null, {
2480
+ view: "activity",
2481
+ page_size: 500
2482
+ })
2483
+ assert.equal(activity.items.some((item) => item.kind === "reclaim"), true)
2484
+ await close(vault)
2485
+ })
2486
+
2487
+ test("freeing the anchor leaves the registry ready to deduplicate again", async () => {
2488
+ const { home, vault } = await makeVault()
2489
+ await duplicatePair(home)
2490
+ await vault.sweeper.scan()
2491
+ const duplicate = [...await vault.registry.files({
2492
+ statuses: ["duplicate"]
2493
+ })][0]
2494
+ await vault.perform("deduplicate", { path: duplicate.path })
2495
+ for (const row of [...await vault.registry.files({
2496
+ statuses: ["linked"]
2497
+ })]) {
2498
+ assert.equal((await vault.perform("detach", {
2499
+ path: row.path
2500
+ })).status, "detached")
2501
+ }
2502
+
2503
+ const rows = [...await vault.registry.files({})]
2504
+ assert.equal(rows.filter((row) => row.status === "reference").length, 1)
2505
+ assert.equal(rows.filter((row) => row.status === "duplicate").length, 1)
2506
+ const target = rows.find((row) => row.status === "duplicate")
2507
+ assert.equal((await vault.perform("deduplicate", {
2508
+ path: target.path
2509
+ })).status, "converted")
2510
+ await close(vault)
2511
+ })
2512
+
2513
+ test("bulk Make separate frees only the anchors nothing links to", async () => {
2514
+ const { home, vault } = await makeVault()
2515
+ await duplicatePair(home, "kept.bin")
2516
+ const trio = crypto.randomBytes(4096)
2517
+ for (const name of ["one", "two", "three"]) {
2518
+ await writeCandidate(path.join(home, "api", name, "freed.bin"), trio)
2519
+ }
2520
+ await vault.sweeper.scan()
2521
+ const result = await vault.perform("deduplicate_files", {
2522
+ paths: [...await vault.registry.files({
2523
+ statuses: ["duplicate"]
2524
+ })].map((item) => item.path)
2525
+ })
2526
+ assert.equal(result.converted, 3)
2527
+
2528
+ const linked = [...await vault.registry.files({ statuses: ["linked"] })]
2529
+ const freedHash = linked.find((item) =>
2530
+ path.basename(item.path) === "freed.bin").hash
2531
+ const keptHash = linked.find((item) =>
2532
+ path.basename(item.path) === "kept.bin").hash
2533
+ const freedStore = vault.storePathFor(freedHash)
2534
+ const keptStore = vault.storePathFor(keptHash)
2535
+
2536
+ const separated = await vault.perform("separate_files", {
2537
+ paths: linked
2538
+ .filter((item) => path.basename(item.path) === "freed.bin")
2539
+ .map((item) => item.path)
2540
+ .concat(linked.find((item) =>
2541
+ path.basename(item.path) === "kept.bin").path)
2542
+ })
2543
+ assert.equal(separated.separated, 4)
2544
+
2545
+ assert.equal(fs.existsSync(freedStore), false)
2546
+ assert.equal((await vault.registry.anchorsForHash(freedHash)).length, 0)
2547
+ assert.equal(fs.existsSync(keptStore), true)
2548
+ assert.equal((await vault.registry.anchorsForHash(keptHash)).length, 1)
2549
+ await close(vault)
2550
+ })
2551
+
2552
+ test("Expanding a file lists every location for its own identity", async () => {
2553
+ const { home, vault } = await makeVault()
2554
+ const contents = crypto.randomBytes(4096)
2555
+ for (const name of ["one", "two", "three"]) {
2556
+ await writeCandidate(path.join(home, "api", name, "shared.bin"), contents)
2557
+ }
2558
+ await vault.sweeper.scan()
2559
+ const duplicate = [...await vault.registry.files({
2560
+ statuses: ["duplicate"]
2561
+ })][0]
2562
+
2563
+ const byHash = await vault.fileLocations(null, duplicate.path)
2564
+ assert.equal(byHash.total, 3)
2565
+ assert.equal(byHash.items.length, 3)
2566
+ assert.equal(byHash.items.some((entry) =>
2567
+ entry.path === duplicate.path), true)
2568
+
2569
+ await vault.perform("deduplicate", { path: duplicate.path })
2570
+ const byInode = await vault.fileLocations(null, duplicate.path)
2571
+ assert.equal(byInode.total, 2)
2572
+ assert.equal(byInode.items.length, 2)
2573
+ const untouched = [...await vault.registry.files({
2574
+ statuses: ["duplicate"]
2575
+ })][0]
2576
+ assert.equal(byInode.items.some((entry) =>
2577
+ entry.path === untouched.path), false)
2578
+ await close(vault)
2579
+ })
2580
+
2581
+ test("an app workspace lists every location but acts only on its own", async () => {
2582
+ const { home, vault } = await makeVault()
2583
+ const contents = crypto.randomBytes(4096)
2584
+ for (const app of ["one", "two", "three"]) {
2585
+ await writeCandidate(path.join(home, "api", app, "shared.bin"), contents)
2586
+ }
2587
+ await vault.sweeper.scan()
2588
+
2589
+ const scope = "app:two"
2590
+ const status = await vault.status(scope, { view: "all", page_size: 100 })
2591
+ assert.equal(status.items.length, 1)
2592
+ const row = status.items[0]
2593
+ assert.equal(row.location_count, 3)
2594
+
2595
+ const locations = await vault.fileLocations(scope, row.path)
2596
+ assert.equal(locations.total, 3)
2597
+ assert.equal(locations.items.length, 3)
2598
+ assert.equal(new Set(locations.items.map((item) =>
2599
+ item.source_id)).size, 3)
2600
+ await close(vault)
2601
+ })
2602
+
2450
2603
  test("invalid scoped actions cannot expand into a global mutation", async () => {
2451
2604
  const { home, vault } = await makeVault()
2452
2605
  await duplicatePair(home)
@@ -784,6 +784,110 @@ describe("Save Space interface", () => {
784
784
  dom.window.close()
785
785
  })
786
786
 
787
+ test("expanding a file loads its real locations instead of row samples", async () => {
788
+ const duplicate = item({
789
+ path: "/pinokio/api/app/models/duplicate.bin",
790
+ relative_path: "models/duplicate.bin",
791
+ status: "duplicate",
792
+ shareable: true,
793
+ location_count: 3,
794
+ locations: [{
795
+ path: "/pinokio/api/app/models/duplicate.bin",
796
+ source_id: "app:app",
797
+ source_label: "app",
798
+ relative_path: "models/duplicate.bin"
799
+ }, {
800
+ path: "/pinokio/api/app/models/sample.bin",
801
+ source_id: "app:app",
802
+ source_label: "app",
803
+ relative_path: "models/sample.bin"
804
+ }]
805
+ })
806
+ const base = fixture([duplicate])
807
+ base.inventory.source_counts.duplicates["app:app"] = 1
808
+ base.inventory.shareable_by_source["app:app"] = 1
809
+ const response = (url) => {
810
+ const parsed = new URL(url, "http://localhost")
811
+ if (parsed.searchParams.get("locations_path")) {
812
+ return {
813
+ path: parsed.searchParams.get("locations_path"),
814
+ items: ["first", "second", "third"].map((name) => ({
815
+ path: `/pinokio/api/app/models/${name}.bin`,
816
+ source_id: "app:app",
817
+ source_label: "app",
818
+ relative_path: `models/${name}.bin`
819
+ })),
820
+ total: 3,
821
+ next_cursor: null
822
+ }
823
+ }
824
+ const result = JSON.parse(JSON.stringify(base))
825
+ result.items = [duplicate]
826
+ return result
827
+ }
828
+ const { dom, getRequests } = await makePage(response)
829
+ const document = dom.window.document
830
+
831
+ document.querySelector('[data-view="duplicates"]').click()
832
+ await waitFor(() => document.querySelector(
833
+ '[data-view="duplicates"].selected'))
834
+ const disclosure = document.querySelector(
835
+ '[data-expand-file="/pinokio/api/app/models/duplicate.bin"]')
836
+ assert.ok(disclosure)
837
+ disclosure.click()
838
+
839
+ await waitFor(() => getRequests.some((url) =>
840
+ new URL(url, "http://localhost").searchParams
841
+ .get("locations_path") === "/pinokio/api/app/models/duplicate.bin"))
842
+ await waitFor(() => document.querySelector(".vault-detail") &&
843
+ document.querySelector(".vault-detail").textContent.includes("third.bin"))
844
+ const detail = document.querySelector(".vault-detail")
845
+ assert.match(detail.textContent, /Identical contents at 3 locations/)
846
+ assert.match(detail.textContent, /first\.bin/)
847
+ assert.doesNotMatch(detail.textContent, /sample\.bin/)
848
+ assert.doesNotMatch(detail.textContent, /of 3 locations shown/)
849
+ await settle()
850
+ dom.window.close()
851
+ })
852
+
853
+ test("leftover storage is presented as a Trash the user can empty", async () => {
854
+ const blob = {
855
+ store_id: "home",
856
+ hash: "b".repeat(64),
857
+ size: 11500000,
858
+ nlink: 1,
859
+ orphan: 1
860
+ }
861
+ const base = fixture([])
862
+ base.inventory.counts.reclaimable = 1
863
+ base.reclaimable = blob.size
864
+ base.enabled = true
865
+ const response = (url) => {
866
+ const view = new URL(url, "http://localhost")
867
+ .searchParams.get("view") || "all"
868
+ const result = JSON.parse(JSON.stringify(base))
869
+ result.inventory.view = view
870
+ result.items = view === "reclaimable" ? [blob] : []
871
+ return result
872
+ }
873
+ const { dom } = await makePage(response)
874
+ const document = dom.window.document
875
+
876
+ assert.match(document.querySelector("#vault-views").textContent, /Trash/)
877
+ document.querySelector('[data-view="reclaimable"]').click()
878
+ await waitFor(() => document.querySelector(
879
+ '[data-view="reclaimable"].selected'))
880
+ await waitFor(() => document.querySelector("[data-reclaim]"))
881
+
882
+ const body = document.body.textContent
883
+ assert.match(body, /Empty Trash/)
884
+ assert.doesNotMatch(body, /private link/i)
885
+ assert.doesNotMatch(body, /ready to clean up/i)
886
+ assert.doesNotMatch(body, /their linked files were deleted/i)
887
+ await settle()
888
+ dom.window.close()
889
+ })
890
+
787
891
  test("Cannot deduplicate is separate from actionable duplicates", async () => {
788
892
  const duplicate = item({
789
893
  path: "/pinokio/api/app/models/duplicate.bin",