pinokiod 8.0.47 → 8.0.48

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.
@@ -1,10 +1,10 @@
1
1
  const CANDIDATE_SIZE_BASE = process.platform === "win32" ? 1024 : 1000
2
- const CANDIDATE_SIZE_OPTIONS = [10, 50, 100, 500]
2
+ const CANDIDATE_SIZE_OPTIONS = [1, 10, 50, 100, 500]
3
3
  .map((value) => value * CANDIDATE_SIZE_BASE ** 2)
4
4
  .concat(CANDIDATE_SIZE_BASE ** 3)
5
5
 
6
6
  module.exports = {
7
- SIZE_THRESHOLD: CANDIDATE_SIZE_OPTIONS[2],
7
+ SIZE_THRESHOLD: CANDIDATE_SIZE_OPTIONS[3],
8
8
  CANDIDATE_SIZE_OPTIONS,
9
9
  TMP_SUFFIX: ".pinokio-dedup-tmp",
10
10
  SHA256_RE: /^[0-9a-f]{64}$/,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pinokiod",
3
- "version": "8.0.47",
3
+ "version": "8.0.48",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -912,10 +912,17 @@ body.dark .vault-row-action .vault-text-button:hover:not(:disabled) {
912
912
  .vault-empty p { margin: 0; color: var(--task-muted); font-size: 12px; line-height: 1.55; }
913
913
  .vault-empty .vault-button { margin-top: 10px; }
914
914
  .vault-pane-footer {
915
+ display: flex;
916
+ align-items: center;
917
+ justify-content: space-between;
918
+ gap: 12px;
915
919
  padding: 9px var(--vault-inline) 11px;
916
920
  color: var(--task-muted);
917
921
  font-size: 10.5px;
918
922
  }
923
+ .vault-pagination { display: inline-flex; align-items: center; gap: 10px; white-space: nowrap; }
924
+ .vault-pagination .vault-text-button:disabled { cursor: default; }
925
+ .vault-page-range { font-variant-numeric: tabular-nums; }
919
926
  .vault-event-kind { font-weight: 570; }
920
927
  .vault-event-time { color: var(--task-muted); font-size: 10.5px; }
921
928
  .vault-unavailable { color: var(--task-muted); font-size: 11px; }
@@ -943,6 +950,7 @@ body.dark .vault-row-action .vault-text-button:hover:not(:disabled) {
943
950
  .vault-result-message { grid-template-columns: 16px minmax(0, 1fr); padding: 9px 0; }
944
951
  .vault-result-heading { align-items: flex-start; flex-direction: column; gap: 2px; }
945
952
  .vault-result-actions { grid-column: 2; }
953
+ .vault-pane-footer { align-items: flex-start; flex-direction: column; }
946
954
  .vault-result.incomplete .vault-button { margin: 0; }
947
955
  }
948
956
  @media (pointer: coarse) {
@@ -103,6 +103,9 @@ const COPY = {
103
103
  display_mode: "Display mode",
104
104
  sorted_largest: "sorted largest first",
105
105
  sorted_smallest: "sorted smallest first",
106
+ previous: "Previous",
107
+ next: "Next",
108
+ file_pages: "File pages",
106
109
  status: "Deduplication status",
107
110
  matches: "Matches",
108
111
  can_save: "Can save",
@@ -200,9 +203,10 @@ const statusUrl = (progress = false) => {
200
203
  const reviewedScanKey = `pinokio:vault:reviewed-scan:${SCOPE_ID || "global"}`
201
204
  const candidateSizeKey = "pinokio:vault:candidate-size"
202
205
  const candidateSizeBase = document.body.dataset.platform === "win32" ? 1024 : 1000
203
- const candidateSizeOptions = [10, 50, 100, 500]
206
+ const candidateSizeOptions = [1, 10, 50, 100, 500]
204
207
  .map((value) => value * candidateSizeBase ** 2)
205
208
  .concat(candidateSizeBase ** 3)
209
+ const PAGE_SIZE = 500
206
210
 
207
211
  const state = {
208
212
  data: null,
@@ -221,7 +225,9 @@ const state = {
221
225
  scanProblemsOpen: false,
222
226
  feedback: null,
223
227
  actionProgress: null,
224
- actionRequest: false
228
+ actionRequest: false,
229
+ page: 0,
230
+ pageContext: null
225
231
  }
226
232
 
227
233
  const el = (id) => document.getElementById(id)
@@ -232,7 +238,7 @@ const attr = esc
232
238
  const fmt = window.PinokioFormatStorageSize
233
239
  const candidateSize = () => {
234
240
  const value = Number(el("vault-candidate-size").value)
235
- return candidateSizeOptions.includes(value) ? value : candidateSizeOptions[2]
241
+ return candidateSizeOptions.includes(value) ? value : candidateSizeOptions[3]
236
242
  }
237
243
  const countLabel = (count, singular = COPY.file, plural = COPY.files) => `${count} ${count === 1 ? singular : plural}`
238
244
  const basename = (value) => String(value || "").split(/[\\/]/).filter(Boolean).pop() || ""
@@ -706,8 +712,9 @@ const renderDuplicateGroups = (items) => {
706
712
  return [...groups.entries()].sort(sourceSort).map(([sourceId, group]) => {
707
713
  const source = sourceById(sourceId)
708
714
  const bytes = group.filter((item) => item.shareable).reduce((sum, item) => sum + item.size, 0)
709
- const action = source && source.shareable && group.some((item) => item.shareable)
710
- ? `<button class="vault-button" type="button" data-deduplicate-scope="${attr(sourceId)}">${esc(COPY.deduplicate)} ${countLabel(group.filter((item) => item.shareable).length)}</button>`
715
+ const allShareable = scopeDuplicates(sourceId).filter((item) => item.shareable !== false)
716
+ const action = source && source.shareable && allShareable.length
717
+ ? `<button class="vault-button" type="button" data-deduplicate-scope="${attr(sourceId)}">${esc(COPY.deduplicate)} ${countLabel(allShareable.length)}</button>`
711
718
  : `<span class="vault-unavailable">${esc(COPY.sharing_unavailable)}</span>`
712
719
  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
720
  }).join("")
@@ -751,10 +758,9 @@ const emptyState = (view) => {
751
758
  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
759
  }
753
760
 
754
- const renderReclaimable = () => {
755
- const blobs = state.data.blobs.filter((blob) => blob.orphan)
761
+ const renderReclaimable = (blobs) => {
756
762
  if (!blobs.length) return emptyState("reclaimable")
757
- return [...blobs].sort((a, b) => compareRows(a, b, (blob) => blob.names[0] ? blob.names[0].path : blob.hash)).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("")
763
+ 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
764
  }
759
765
 
760
766
  const renderActivity = () => {
@@ -794,7 +800,7 @@ const renderTable = (items) => {
794
800
  } else if (state.view === "reclaimable") {
795
801
  tableClass = "reclaimable"
796
802
  headers = [COPY.name, COPY.size, COPY.can_free, ""]
797
- body = renderReclaimable()
803
+ body = renderReclaimable(items)
798
804
  } else if (state.view === "activity") {
799
805
  tableClass = "activity"
800
806
  headers = [COPY.name, COPY.size, COPY.last_scanned, ""]
@@ -815,6 +821,73 @@ const renderTable = (items) => {
815
821
  el("vault-table-wrap").innerHTML = `<div class="vault-table ${tableClass}"><div class="vault-columns">${headerMarkup}</div>${body}</div>`
816
822
  }
817
823
 
824
+ const orderedItems = (items) => {
825
+ if (state.view === "activity") return items
826
+ if (state.view === "reclaimable") {
827
+ return [...items].sort((a, b) => compareRows(a, b,
828
+ (blob) => blob.names[0] ? blob.names[0].path : blob.hash))
829
+ }
830
+ if (state.displayMode === "files" && supportsDisplayMode()) {
831
+ return [...items].sort((a, b) => compareRows(a, b, flatLocation))
832
+ }
833
+ return [...items].sort((a, b) => {
834
+ const sourceOrder = state.sourceId ? 0 : sourceSort([a.source_id], [b.source_id])
835
+ return sourceOrder || compareRows(a, b, (item) => item.relative_path || item.path)
836
+ })
837
+ }
838
+
839
+ const pagedItems = (items) => {
840
+ if (state.view === "activity") {
841
+ return { items, start: 0, end: items.length, total: items.length, pages: 1 }
842
+ }
843
+ const context = JSON.stringify([
844
+ state.view, state.sourceId, state.query, state.statusFilter,
845
+ state.displayMode, state.sizeSort
846
+ ])
847
+ if (context !== state.pageContext) {
848
+ state.page = 0
849
+ state.pageContext = context
850
+ }
851
+ const ordered = orderedItems(items)
852
+ const pages = Math.max(1, Math.ceil(ordered.length / PAGE_SIZE))
853
+ state.page = Math.max(0, Math.min(state.page, pages - 1))
854
+ const start = state.page * PAGE_SIZE
855
+ const end = Math.min(start + PAGE_SIZE, ordered.length)
856
+ return { items: ordered.slice(start, end), start, end, total: ordered.length, pages }
857
+ }
858
+
859
+ const paneFooterText = (items) => {
860
+ if (state.displayMode === "files" && supportsDisplayMode()) {
861
+ const count = state.view === "shared"
862
+ ? countLabel(items.length, "deduplicated file", "deduplicated files")
863
+ : state.view === "independent"
864
+ ? countLabel(items.length, "file kept separate", "files kept separate")
865
+ : countLabel(items.length)
866
+ const order = state.sizeSort === "desc"
867
+ ? COPY.sorted_largest
868
+ : state.sizeSort === "asc" ? COPY.sorted_smallest : ""
869
+ return `${count}${order ? ` · ${order}` : ""}`
870
+ }
871
+ if (state.view === "duplicates") return COPY.duplicate_note
872
+ if (state.view === "reclaimable") return COPY.reclaimable_note
873
+ return COPY.tracked_note.replace("{size}", fmt(candidateSize()))
874
+ }
875
+
876
+ const renderPaneFooter = (items, page) => {
877
+ const footer = el("vault-pane-footer")
878
+ const message = paneFooterText(items)
879
+ if (page.pages === 1) {
880
+ footer.textContent = message
881
+ return
882
+ }
883
+ footer.innerHTML = `<span>${esc(message)}</span>
884
+ <span class="vault-pagination" role="navigation" aria-label="${attr(COPY.file_pages)}">
885
+ <button class="vault-text-button" type="button" data-page="previous" ${state.page === 0 ? "disabled" : ""}>${esc(COPY.previous)}</button>
886
+ <span class="vault-page-range">${page.start + 1}–${page.end} of ${page.total}</span>
887
+ <button class="vault-text-button" type="button" data-page="next" ${state.page === page.pages - 1 ? "disabled" : ""}>${esc(COPY.next)}</button>
888
+ </span>`
889
+ }
890
+
818
891
  const renderOverview = () => {
819
892
  const data = state.data
820
893
  const last = data.last_scan
@@ -1131,23 +1204,10 @@ const render = () => {
1131
1204
  renderLocations(items)
1132
1205
  const visible = activeItems(items)
1133
1206
  renderToolbar(visible, items)
1134
- renderTable(visible)
1207
+ const page = pagedItems(visible)
1208
+ renderTable(page.items)
1135
1209
  renderActionProgress()
1136
- if (state.displayMode === "files" && supportsDisplayMode()) {
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
- }
1210
+ renderPaneFooter(visible, page)
1151
1211
  }
1152
1212
 
1153
1213
  const scanActive = (scan) => !!(scan && (scan.pending || scan.active || scan.queued > 0))
@@ -1405,6 +1465,10 @@ document.addEventListener("click", async (event) => {
1405
1465
  if (target.id === "btn-dismiss-cloud") {
1406
1466
  try { localStorage.setItem(target.dataset.warningKey, "1") } catch (error) {}
1407
1467
  renderCloudWarning()
1468
+ } else if (target.dataset.page) {
1469
+ state.page += target.dataset.page === "next" ? 1 : -1
1470
+ render()
1471
+ el("vault-table-wrap").scrollTop = 0
1408
1472
  } else if (target.hasAttribute("data-sort-size")) {
1409
1473
  state.sizeSort = state.sizeSort === "desc" ? "asc" : "desc"
1410
1474
  render()
@@ -1597,7 +1661,9 @@ document.addEventListener("input", (event) => {
1597
1661
  state.query = event.target.value
1598
1662
  const items = activeItems(buildItems())
1599
1663
  updateToolbarSummary(items)
1600
- renderTable(items)
1664
+ const page = pagedItems(items)
1665
+ renderTable(page.items)
1666
+ renderPaneFooter(items, page)
1601
1667
  renderActionProgress()
1602
1668
  })
1603
1669
  document.addEventListener("change", (event) => {
@@ -1617,7 +1683,7 @@ candidateSizeSelect.setAttribute("aria-label", COPY.minimum_file_size)
1617
1683
  candidateSizeSelect.innerHTML = candidateSizeOptions
1618
1684
  .map((size) => `<option value="${size}">${fmt(size)}+</option>`)
1619
1685
  .join("")
1620
- candidateSizeSelect.value = String(candidateSizeOptions[2])
1686
+ candidateSizeSelect.value = String(candidateSizeOptions[3])
1621
1687
  try {
1622
1688
  const storedCandidateSize = Number(localStorage.getItem(candidateSizeKey))
1623
1689
  if (candidateSizeOptions.includes(storedCandidateSize)) {
@@ -76,12 +76,12 @@ describe('vault dashboard backend (phase 4)', () => {
76
76
  }
77
77
 
78
78
  const accepted = await vault.perform('scan', {
79
- candidate_size: CANDIDATE_SIZE_OPTIONS[1]
79
+ candidate_size: CANDIDATE_SIZE_OPTIONS[0]
80
80
  })
81
81
  assert.deepStrictEqual(accepted, { started: true })
82
82
  assert.deepStrictEqual(startedWith, {
83
83
  scopeId: null,
84
- sizeThreshold: CANDIDATE_SIZE_OPTIONS[1]
84
+ sizeThreshold: CANDIDATE_SIZE_OPTIONS[0]
85
85
  })
86
86
 
87
87
  startedWith = null
@@ -1323,7 +1323,7 @@ describe('vault dashboard backend (phase 4)', () => {
1323
1323
  assert.match(dom.window.document.getElementById('btn-scan').textContent, /Scan this app/)
1324
1324
  const candidateSize = dom.window.document.getElementById('vault-candidate-size')
1325
1325
  assert.deepStrictEqual([...candidateSize.options].map((option) => option.textContent),
1326
- ['10 MB+', '50 MB+', '100 MB+', '500 MB+', '1 GB+'])
1326
+ ['1 MB+', '10 MB+', '50 MB+', '100 MB+', '500 MB+', '1 GB+'])
1327
1327
  assert.strictEqual(candidateSize.value, '100000000')
1328
1328
  candidateSize.value = '50000000'
1329
1329
  candidateSize.dispatchEvent(new dom.window.Event('change', { bubbles: true }))
@@ -1853,6 +1853,94 @@ describe('vault dashboard backend (phase 4)', () => {
1853
1853
  }
1854
1854
  })
1855
1855
 
1856
+ test('large file lists render bounded pages without narrowing Deduplicate all', async () => {
1857
+ const views = path.resolve(__dirname, '..', 'server', 'views')
1858
+ const html = await ejs.renderFile(path.resolve(views, 'vault_app.ejs'), {
1859
+ theme: 'light', platform: 'darwin', agent: 'electron', scope_id: 'app:appB'
1860
+ })
1861
+ const dom = new JSDOM(html, {
1862
+ url: 'http://localhost/vault/app/appB',
1863
+ runScripts: 'dangerously'
1864
+ })
1865
+ const actions = []
1866
+ const duplicates = Array.from({ length: 501 }, (_, index) => {
1867
+ const name = `file-${String(index).padStart(4, '0')}.bin`
1868
+ return {
1869
+ path: `/pinokio/api/appB/${name}`,
1870
+ relative_path: name,
1871
+ source_id: 'app:appB',
1872
+ source_label: 'appB',
1873
+ size: 1024 + index,
1874
+ shareable: true,
1875
+ match: { path: `/pinokio/api/appA/${name}` }
1876
+ }
1877
+ })
1878
+ const status = {
1879
+ enabled: true,
1880
+ mode: 'link',
1881
+ scan: { active: false, pending: false, queued: 0, phase: 'idle', scope_id: null },
1882
+ last_scan: { ts: Date.now(), bytes_total: 1024 * 501 },
1883
+ tracked_bytes: 1024 * 501,
1884
+ effective_bytes: 1024 * 501,
1885
+ shared_bytes: 0,
1886
+ pending_bytes: duplicates.reduce((sum, item) => sum + item.size, 0),
1887
+ activity_error: null,
1888
+ cloud_sync_warning: null,
1889
+ sources: [{
1890
+ id: 'app:appB', kind: 'app', label: 'appB', root: '/pinokio/api/appB',
1891
+ display_path: '/pinokio/api/appB', parent_id: null, available: true, shareable: true
1892
+ }],
1893
+ blobs: [],
1894
+ duplicates,
1895
+ excluded: [],
1896
+ events: [],
1897
+ undo_batches: []
1898
+ }
1899
+ dom.window.fetch = async (url, options = {}) => {
1900
+ if (options.method === 'POST') {
1901
+ actions.push(JSON.parse(options.body))
1902
+ return { ok: true, json: async () => ({ converted: 501, bytes_saved: status.pending_bytes }) }
1903
+ }
1904
+ return { ok: true, json: async () => status }
1905
+ }
1906
+ await runVaultScript(dom)
1907
+ await new Promise((resolve) => setTimeout(resolve, 25))
1908
+
1909
+ dom.window.document.querySelector('[data-view="duplicates"]').click()
1910
+ const rows = () => [...dom.window.document.querySelectorAll('.vault-table.matches .vault-file-row')]
1911
+ let bulk = dom.window.document.querySelector('[data-deduplicate-all="duplicates"]')
1912
+ assert.strictEqual(rows().length, 500)
1913
+ assert.strictEqual(bulk.textContent, 'Deduplicate 501 files')
1914
+ assert.strictEqual(dom.window.document.querySelector('.vault-page-range').textContent, '1–500 of 501')
1915
+ assert.strictEqual(dom.window.document.querySelector('[data-page="previous"]').disabled, true)
1916
+ assert.strictEqual(dom.window.document.querySelector('[data-page="next"]').disabled, false)
1917
+
1918
+ dom.window.document.querySelector('[data-page="next"]').click()
1919
+ assert.strictEqual(rows().length, 1)
1920
+ assert.strictEqual(rows()[0].querySelector('.vault-file-name').textContent, 'file-0500.bin')
1921
+ assert.strictEqual(dom.window.document.querySelector('.vault-page-range').textContent, '501–501 of 501')
1922
+ assert.strictEqual(dom.window.document.querySelector('[data-page="previous"]').disabled, false)
1923
+ assert.strictEqual(dom.window.document.querySelector('[data-page="next"]').disabled, true)
1924
+
1925
+ bulk = dom.window.document.querySelector('[data-deduplicate-all="duplicates"]')
1926
+ assert.strictEqual(bulk.textContent, 'Deduplicate 501 files')
1927
+ bulk.click()
1928
+ await new Promise((resolve) => setTimeout(resolve, 25))
1929
+ assert.deepStrictEqual(actions[0], {
1930
+ action: 'deduplicate',
1931
+ selection: 'duplicates',
1932
+ scope_id: 'app:appB'
1933
+ })
1934
+
1935
+ const search = dom.window.document.getElementById('vault-search')
1936
+ search.value = 'file-0001.bin'
1937
+ search.dispatchEvent(new dom.window.Event('input', { bubbles: true }))
1938
+ assert.strictEqual(rows().length, 1)
1939
+ assert.strictEqual(rows()[0].querySelector('.vault-file-name').textContent, 'file-0001.bin')
1940
+ assert.strictEqual(dom.window.document.querySelector('.vault-pagination'), null)
1941
+ dom.window.close()
1942
+ })
1943
+
1856
1944
  test('app inventory uses explicit sharing actions without ambiguous switches', async () => {
1857
1945
  const views = path.resolve(__dirname, '..', 'server', 'views')
1858
1946
  const html = await ejs.renderFile(path.resolve(views, 'vault_app.ejs'), {