pinokiod 8.0.57 → 8.0.60

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.
@@ -41,6 +41,7 @@ class AutomaticScans {
41
41
  this.waitingFor = null
42
42
  this.listeners = new Set()
43
43
  this.appTransitions = new Map()
44
+ this.globalScanReady = false
44
45
  }
45
46
 
46
47
  log(event, details = {}) {
@@ -134,7 +135,8 @@ class AutomaticScans {
134
135
  snapshot() {
135
136
  return {
136
137
  enabled: !!this.vault.enabled,
137
- rows: [...this.entries.values()]
138
+ global_scan_ready: this.globalScanReady,
139
+ rows: (this.globalScanReady ? [...this.entries.values()] : [])
138
140
  .filter((entry) => !entry.hidden)
139
141
  .sort((left, right) =>
140
142
  (Number(left.updated_at) || 0) -
@@ -147,13 +149,22 @@ class AutomaticScans {
147
149
  }
148
150
  }
149
151
 
152
+ async refreshGlobalScanReady() {
153
+ const ready = typeof this.vault.globalScanReady === "function" &&
154
+ !!await this.vault.globalScanReady()
155
+ const changed = ready !== this.globalScanReady
156
+ this.globalScanReady = ready
157
+ return changed
158
+ }
159
+
150
160
  modeFor(app) {
151
161
  const setting = this.settings.get(app)
152
162
  return setting && setting.mode === "manual" ? "manual" : "automatic"
153
163
  }
154
164
 
155
- broadcast() {
165
+ broadcast(completion = null) {
156
166
  const snapshot = this.snapshot()
167
+ if (completion) snapshot.completion = completion
157
168
  for (const listener of this.listeners) {
158
169
  try {
159
170
  listener(snapshot)
@@ -235,6 +246,10 @@ class AutomaticScans {
235
246
  }
236
247
 
237
248
  queueApp(app) {
249
+ if (!this.globalScanReady) {
250
+ this.log("queue-skipped", { app, reason: "global-scan-required" })
251
+ return false
252
+ }
238
253
  if (this.modeFor(app) === "manual") {
239
254
  this.log("queue-skipped", { app, reason: "manual" })
240
255
  return false
@@ -323,6 +338,12 @@ class AutomaticScans {
323
338
  this.log("preparing", { app })
324
339
  await this.vault.ensureRegistryInitialized()
325
340
  if (this.pendingStops.get(app) !== pending) return
341
+ await this.refreshGlobalScanReady()
342
+ if (!this.globalScanReady) {
343
+ this.pendingStops.delete(app)
344
+ this.log("check-skipped", { app, reason: "global-scan-required" })
345
+ return
346
+ }
326
347
  await this.hydrate()
327
348
  await this.withAppTransition(app, async () => {
328
349
  if (this.pendingStops.get(app) !== pending) return
@@ -394,6 +415,11 @@ class AutomaticScans {
394
415
  if (!this.hydrated && typeof this.vault.automaticScanStatus === "function") {
395
416
  await this.vault.automaticScanStatus()
396
417
  }
418
+ await this.refreshGlobalScanReady()
419
+ if (!this.globalScanReady) {
420
+ this.log("check-skipped", { app, reason: "global-scan-required" })
421
+ return
422
+ }
397
423
  if (this.modeFor(app) === "manual") {
398
424
  this.log("check-skipped", { app, reason: "manual" })
399
425
  return
@@ -546,14 +572,16 @@ class AutomaticScans {
546
572
 
547
573
  async drain() {
548
574
  if (!this.vault.enabled || !this.vault.registry || this.active) return
575
+ const entry = [...this.entries.values()].find((item) =>
576
+ item.state === "checking")
577
+ if (!entry) return
578
+ await this.refreshGlobalScanReady()
579
+ if (!this.globalScanReady) return
549
580
  if (this.manualDepth > 0 || this.currentBusyPromise() ||
550
581
  this.vault.fileActionProgress) {
551
582
  this.waitForBusyWork()
552
583
  return
553
584
  }
554
- const entry = [...this.entries.values()].find((item) =>
555
- item.state === "checking")
556
- if (!entry) return
557
585
  let appAvailable
558
586
  try {
559
587
  appAvailable = await this.appRootIsAvailable(entry.app)
@@ -617,6 +645,7 @@ class AutomaticScans {
617
645
 
618
646
  async precheckFinished(active, result, error) {
619
647
  const app = active.app
648
+ let completion = null
620
649
  try {
621
650
  await this.withAppTransition(app, async () => {
622
651
  let reason = active.reason
@@ -661,13 +690,20 @@ class AutomaticScans {
661
690
  ? Object.assign({}, this.settings.get(app))
662
691
  : null
663
692
  try {
664
- await this.publishResultNow(app, result)
693
+ const outcome = await this.publishResultNow(app, result)
694
+ if (outcome === "empty") {
695
+ completion = {
696
+ app,
697
+ outcome: "no_possible_duplicates"
698
+ }
699
+ }
665
700
  } catch (publicationError) {
666
701
  this.restorePrevious(app)
667
702
  throw publicationError
668
703
  }
669
704
  reason = active.reason
670
705
  if (reason) {
706
+ completion = null
671
707
  await this.restorePublishedState(app, entry, previousSetting)
672
708
  this.entries.set(app, entry)
673
709
  this.log("publication-reverted", { app, reason })
@@ -682,7 +718,7 @@ class AutomaticScans {
682
718
  })
683
719
  } finally {
684
720
  if (this.active === active) this.active = null
685
- this.broadcast()
721
+ this.broadcast(completion)
686
722
  this.schedule()
687
723
  }
688
724
  }
@@ -764,7 +800,7 @@ class AutomaticScans {
764
800
  possible_files: possibleFiles,
765
801
  acknowledged: acknowledged === result.signature
766
802
  })
767
- return
803
+ return "result"
768
804
  }
769
805
  const acknowledged = (this.settings.get(app) || {})
770
806
  .acknowledged_signature
@@ -777,6 +813,7 @@ class AutomaticScans {
777
813
  }
778
814
  this.entries.delete(app)
779
815
  this.log("no-possible-matches", { app })
816
+ return "empty"
780
817
  }
781
818
 
782
819
  async clearAutomaticState(apps, reason, options = {}) {
@@ -832,6 +869,7 @@ class AutomaticScans {
832
869
 
833
870
  async scanFinished({ scopeId, result, error }) {
834
871
  const complete = !error && result && COMPLETE_PHASES.has(result.outcome)
872
+ let readinessChanged = false
835
873
  try {
836
874
  if (complete) {
837
875
  await this.hydrate()
@@ -842,7 +880,9 @@ class AutomaticScans {
842
880
  { cancelPending: true, states: ["checking", "result"] }
843
881
  )
844
882
  }
883
+ if (!scopeId) readinessChanged = await this.refreshGlobalScanReady()
845
884
  } finally {
885
+ if (readinessChanged) this.broadcast()
846
886
  this.schedule()
847
887
  }
848
888
  }
@@ -887,6 +927,16 @@ class AutomaticScans {
887
927
  await this.persistMode(app, mode)
888
928
  const entry = this.entries.get(app)
889
929
  if (entry && entry.state === "paused") this.entries.delete(app)
930
+ await this.refreshGlobalScanReady()
931
+ if (!this.globalScanReady) {
932
+ this.log("mode-changed", {
933
+ app,
934
+ mode,
935
+ check: "global-scan-required"
936
+ })
937
+ this.broadcast()
938
+ return { app, mode }
939
+ }
890
940
  if (!this.appIsRunning(app)) {
891
941
  this.log("mode-changed", { app, mode })
892
942
  this.queueApp(app)
@@ -73,6 +73,9 @@ const STATUS_FILTERS = new Set([
73
73
  const FOLDER_DISCOVERY_COMPLETE_PHASES = new Set([
74
74
  "complete", "completed_with_exclusions"
75
75
  ])
76
+ const GLOBAL_SCAN_READY_OUTCOMES = new Set([
77
+ "complete", "completed_with_exclusions"
78
+ ])
76
79
 
77
80
  const isMissingError = (error) => !!(error &&
78
81
  (error.code === "ENOENT" || error.code === "ENOTDIR"))
@@ -672,9 +675,23 @@ class Vault {
672
675
  await this.ensureRegistryInitialized()
673
676
  }
674
677
  await this.automaticScans.hydrate()
678
+ await this.automaticScans.refreshGlobalScanReady()
675
679
  return this.automaticScans.snapshot()
676
680
  }
677
681
 
682
+ async globalScanReady() {
683
+ if (!this.registry) return false
684
+ const scan = await this.registry.scanFor()
685
+ if (scan) this.lastScanCache.set("", scan)
686
+ else this.lastScanCache.delete("")
687
+ return this.globalScanIsReady(scan)
688
+ }
689
+
690
+ globalScanIsReady(scan = this.lastScanCache.get("")) {
691
+ return !!(scan && scan.ts &&
692
+ GLOBAL_SCAN_READY_OUTCOMES.has(scan.outcome))
693
+ }
694
+
678
695
  async refreshAnchorStores() {
679
696
  let config = this.readConfig()
680
697
  const candidates = [
@@ -1790,9 +1807,19 @@ class Vault {
1790
1807
  return this.runMutation(() =>
1791
1808
  this.removeExternalSource(payload.source_id))
1792
1809
  case "scan": {
1793
- if (payload.scope_id && !this.scanSource(payload.scope_id)) {
1810
+ const scanSource = payload.scope_id
1811
+ ? this.scanSource(payload.scope_id)
1812
+ : null
1813
+ if (payload.scope_id && !scanSource) {
1794
1814
  return { error: "That scan location is no longer available." }
1795
1815
  }
1816
+ if (scanSource && scanSource.kind === "app" &&
1817
+ !await this.globalScanReady()) {
1818
+ return {
1819
+ error: "Run an initial scan before scanning individual apps.",
1820
+ code: "global_scan_required"
1821
+ }
1822
+ }
1796
1823
  let threshold = this.sizeThreshold
1797
1824
  if (payload.candidate_size != null) {
1798
1825
  if (!CANDIDATE_SIZE_OPTIONS.includes(payload.candidate_size)) {
@@ -3266,6 +3293,9 @@ class Vault {
3266
3293
  }
3267
3294
 
3268
3295
  const lastScan = await this.scanForScope(scopeId)
3296
+ const globalScanReady = scopeId
3297
+ ? await this.globalScanReady()
3298
+ : this.globalScanIsReady(lastScan)
3269
3299
  const before = lastScan && Number.isFinite(lastScan.bytes_total)
3270
3300
  ? lastScan.bytes_total
3271
3301
  : 0
@@ -3302,6 +3332,7 @@ class Vault {
3302
3332
  const sourceCounts = this.sourceCountMaps(snapshot.scopeRows)
3303
3333
  const result = {
3304
3334
  enabled: true,
3335
+ global_scan_ready: globalScanReady,
3305
3336
  mode: this.mode,
3306
3337
  scan: this.scanStatus(),
3307
3338
  last_scan: lastScan,
@@ -3402,6 +3433,7 @@ class Vault {
3402
3433
  async progressStatus(scopeId = null) {
3403
3434
  const result = {
3404
3435
  enabled: !!this.enabled,
3436
+ global_scan_ready: this.globalScanIsReady(),
3405
3437
  scan: this.scanStatus(),
3406
3438
  file_action: this.fileActionStatus(scopeId),
3407
3439
  last_scan: this.lastScanCache.get(scopeId || "") || null
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pinokiod",
3
- "version": "8.0.57",
3
+ "version": "8.0.60",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "scripts": {
package/server/index.js CHANGED
@@ -1994,9 +1994,12 @@ class Server {
1994
1994
  if (vault && vault.ready) await vault.ready
1995
1995
  result.vault_enabled = !!(vault && vault.enabled)
1996
1996
  result.vault_automatic_mode = "automatic"
1997
+ result.vault_global_scan_ready = false
1997
1998
  if (result.vault_enabled) {
1998
1999
  try {
1999
2000
  const snapshot = await vault.automaticScanStatus()
2001
+ result.vault_global_scan_ready =
2002
+ snapshot && snapshot.global_scan_ready === true
2000
2003
  const setting = Array.isArray(snapshot && snapshot.settings)
2001
2004
  ? snapshot.settings.find((item) =>
2002
2005
  item && item.app === name)
@@ -5,14 +5,22 @@
5
5
  const app = status.dataset.app || ""
6
6
  const tab = status.closest("#save-space-tab")
7
7
  const label = status.querySelector("[data-app-vault-mode-label]")
8
- const setMode = (value) => {
8
+ const setState = (value, ready) => {
9
9
  const mode = value === "manual" ? "manual" : "automatic"
10
+ const globalScanReady = ready === true
10
11
  status.dataset.mode = mode
12
+ status.dataset.ready = String(globalScanReady)
11
13
  status.hidden = false
12
- if (label) label.textContent = mode === "automatic" ? "Auto" : "Manual"
14
+ if (label) {
15
+ label.textContent = globalScanReady
16
+ ? (mode === "automatic" ? "Auto" : "Manual")
17
+ : "Set up"
18
+ }
13
19
  if (tab) {
14
20
  tab.setAttribute("aria-label",
15
- `Disk Saver — ${mode === "automatic" ? "Automatic" : "Manual"} checking`)
21
+ globalScanReady
22
+ ? `Disk Saver — ${mode === "automatic" ? "Automatic" : "Manual"} checking`
23
+ : "Disk Saver — Set up required")
16
24
  }
17
25
  }
18
26
  const applySnapshot = (snapshot) => {
@@ -20,10 +28,11 @@
20
28
  ? snapshot.settings
21
29
  : []
22
30
  const setting = settings.find((item) => item && item.app === app)
23
- setMode(setting && setting.mode)
31
+ setState(setting && setting.mode,
32
+ snapshot && snapshot.global_scan_ready === true)
24
33
  }
25
34
 
26
- setMode(status.dataset.mode)
35
+ setState(status.dataset.mode, status.dataset.ready === "true")
27
36
  if (!app || typeof window.EventSource !== "function") return
28
37
 
29
38
  const source = new window.EventSource(
@@ -866,8 +866,16 @@
866
866
  <circle cx="10" cy="10" r="7.5"></circle>
867
867
  <path d="M8 7.25v5.5M12 7.25v5.5"></path>
868
868
  </svg>`;
869
+ const COMPLETE_ICON = `
870
+ <svg viewBox="0 0 20 20" aria-hidden="true">
871
+ <circle cx="10" cy="10" r="7.5"></circle>
872
+ <path d="m6.75 10.1 2.1 2.1 4.6-4.65"></path>
873
+ </svg>`;
874
+ const COMPLETION_VISIBLE_MS = 4000;
869
875
 
870
876
  let eventSource = null;
877
+ let visibleCheckingApps = new Set();
878
+ const completions = new Map();
871
879
 
872
880
  function statusText(row) {
873
881
  if (row.state === 'paused') {
@@ -912,6 +920,97 @@
912
920
  tray.hidden = tray.childElementCount === 0;
913
921
  }
914
922
 
923
+ function removeCompletion(app) {
924
+ const completion = completions.get(app);
925
+ if (!completion) return;
926
+ if (completion.timer) window.clearTimeout(completion.timer);
927
+ completions.delete(app);
928
+ removeRow(completion.item);
929
+ }
930
+
931
+ function clearCompletions() {
932
+ [...completions.keys()].forEach(removeCompletion);
933
+ }
934
+
935
+ function scheduleCompletion(completion) {
936
+ if (completion.paused.size || completion.timer) return;
937
+ completion.startedAt = Date.now();
938
+ completion.timer = window.setTimeout(() => {
939
+ completion.timer = null;
940
+ removeCompletion(completion.app);
941
+ }, completion.remaining);
942
+ }
943
+
944
+ function setCompletionPaused(completion, reason, paused) {
945
+ if (paused) {
946
+ if (completion.paused.has(reason)) return;
947
+ if (!completion.paused.size && completion.timer) {
948
+ completion.remaining = Math.max(0,
949
+ completion.remaining - (Date.now() - completion.startedAt));
950
+ window.clearTimeout(completion.timer);
951
+ completion.timer = null;
952
+ }
953
+ completion.paused.add(reason);
954
+ return;
955
+ }
956
+ completion.paused.delete(reason);
957
+ if (!completion.paused.size) scheduleCompletion(completion);
958
+ }
959
+
960
+ function startCompletion(app) {
961
+ if (completions.has(app)) return;
962
+ const item = document.createElement('div');
963
+ item.className = 'vault-auto-scan-row';
964
+ item.dataset.state = 'complete';
965
+
966
+ const close = document.createElement('button');
967
+ close.type = 'button';
968
+ close.className = 'vault-auto-scan-close';
969
+ close.setAttribute('aria-label',
970
+ `Dismiss Disk Saver completion for ${app}`);
971
+ close.title = 'Dismiss';
972
+ close.textContent = '×';
973
+
974
+ const icon = document.createElement('span');
975
+ icon.className = 'vault-auto-scan-icon';
976
+ icon.innerHTML = COMPLETE_ICON;
977
+
978
+ const copy = document.createElement('span');
979
+ copy.className = 'vault-auto-scan-copy';
980
+ const appName = document.createElement('span');
981
+ appName.className = 'vault-auto-scan-app';
982
+ appName.textContent = app;
983
+ appName.title = app;
984
+ const status = document.createElement('span');
985
+ status.className = 'vault-auto-scan-status';
986
+ status.textContent = 'No possible duplicate files found';
987
+ copy.append(appName, status);
988
+ item.append(close, icon, copy);
989
+
990
+ const completion = {
991
+ app,
992
+ item,
993
+ timer: null,
994
+ remaining: COMPLETION_VISIBLE_MS,
995
+ startedAt: 0,
996
+ paused: new Set()
997
+ };
998
+ close.addEventListener('click', () => removeCompletion(app));
999
+ item.addEventListener('mouseenter', () =>
1000
+ setCompletionPaused(completion, 'pointer', true));
1001
+ item.addEventListener('mouseleave', () =>
1002
+ setCompletionPaused(completion, 'pointer', false));
1003
+ item.addEventListener('focusin', () =>
1004
+ setCompletionPaused(completion, 'focus', true));
1005
+ item.addEventListener('focusout', (event) => {
1006
+ if (!item.contains(event.relatedTarget)) {
1007
+ setCompletionPaused(completion, 'focus', false);
1008
+ }
1009
+ });
1010
+ completions.set(app, completion);
1011
+ scheduleCompletion(completion);
1012
+ }
1013
+
915
1014
  function automaticSettingsKey(app) {
916
1015
  return `pinokio:vault:auto-settings:${encodeURIComponent(app)}`;
917
1016
  }
@@ -968,15 +1067,41 @@
968
1067
  return opened;
969
1068
  }
970
1069
 
971
- function render(snapshot) {
1070
+ function render(snapshot, options = {}) {
1071
+ if (!snapshot || snapshot.global_scan_ready !== true) {
1072
+ [...completions.keys()].forEach(removeCompletion);
1073
+ visibleCheckingApps = new Set();
1074
+ tray.replaceChildren();
1075
+ tray.hidden = true;
1076
+ return;
1077
+ }
972
1078
  const rows = snapshot && Array.isArray(snapshot.rows)
973
1079
  ? snapshot.rows
974
1080
  : [];
1081
+ const validRows = rows.filter((row) =>
1082
+ row && typeof row.app === 'string' && row.app);
1083
+ const liveApps = new Set(validRows.map((row) => row.app));
1084
+ [...completions.keys()].forEach((app) => {
1085
+ if (liveApps.has(app)) removeCompletion(app);
1086
+ });
1087
+ const manualApps = new Set(snapshot && Array.isArray(snapshot.settings)
1088
+ ? snapshot.settings.filter((setting) =>
1089
+ setting && setting.mode === 'manual').map((setting) => setting.app)
1090
+ : []);
1091
+ manualApps.forEach(removeCompletion);
1092
+
1093
+ const completion = snapshot && snapshot.completion;
1094
+ if (options.acceptCompletion && completion &&
1095
+ completion.outcome === 'no_possible_duplicates' &&
1096
+ typeof completion.app === 'string' && completion.app &&
1097
+ visibleCheckingApps.has(completion.app) &&
1098
+ !liveApps.has(completion.app) &&
1099
+ !manualApps.has(completion.app)) {
1100
+ startCompletion(completion.app);
1101
+ }
1102
+
975
1103
  const fragment = document.createDocumentFragment();
976
- rows.forEach((row) => {
977
- if (!row || typeof row.app !== 'string' || !row.app) {
978
- return;
979
- }
1104
+ validRows.forEach((row) => {
980
1105
  const item = document.createElement('div');
981
1106
  item.className = 'vault-auto-scan-row';
982
1107
  item.dataset.state = row.state || 'checking';
@@ -988,6 +1113,7 @@
988
1113
  close.title = 'Dismiss';
989
1114
  close.textContent = '×';
990
1115
  close.addEventListener('click', async () => {
1116
+ if (row.state === 'checking') visibleCheckingApps.delete(row.app);
991
1117
  close.disabled = true;
992
1118
  try {
993
1119
  const result = await requestAction(
@@ -1000,6 +1126,9 @@
1000
1126
  removeRow(item);
1001
1127
  } catch (error) {
1002
1128
  console.warn('[Disk Saver] Automatic check dismissal failed', error);
1129
+ if (row.state === 'checking' && item.isConnected) {
1130
+ visibleCheckingApps.add(row.app);
1131
+ }
1003
1132
  close.disabled = false;
1004
1133
  }
1005
1134
  });
@@ -1088,8 +1217,16 @@
1088
1217
  item.append(close, icon, copy, controls);
1089
1218
  fragment.appendChild(item);
1090
1219
  });
1220
+ completions.forEach((completionRow) => {
1221
+ if (!liveApps.has(completionRow.app)) {
1222
+ fragment.appendChild(completionRow.item);
1223
+ }
1224
+ });
1091
1225
  tray.replaceChildren(fragment);
1092
1226
  tray.hidden = tray.childElementCount === 0;
1227
+ visibleCheckingApps = new Set(validRows
1228
+ .filter((row) => row.state === 'checking')
1229
+ .map((row) => row.app));
1093
1230
  }
1094
1231
 
1095
1232
  async function loadState() {
@@ -1119,12 +1256,13 @@
1119
1256
  '/info/vault/automatic-scans/events');
1120
1257
  eventSource.onmessage = (event) => {
1121
1258
  try {
1122
- render(JSON.parse(event.data));
1259
+ render(JSON.parse(event.data), { acceptCompletion: true });
1123
1260
  } catch (error) {
1124
1261
  console.debug('[Disk Saver] Invalid automatic check state', error);
1125
1262
  }
1126
1263
  };
1127
1264
  eventSource.onerror = () => {
1265
+ clearCompletions();
1128
1266
  loadState();
1129
1267
  };
1130
1268
  }
@@ -218,6 +218,31 @@ body[data-vault-mode="app"] .vault-overview {
218
218
  letter-spacing: -.025em;
219
219
  line-height: 1.15;
220
220
  }
221
+ .vault-setup-copy {
222
+ max-width: 560px;
223
+ margin: 8px 0 0;
224
+ color: var(--task-muted);
225
+ font-size: 12px;
226
+ line-height: 1.5;
227
+ }
228
+ .vault-body.setup-required .vault-overview {
229
+ min-height: 260px;
230
+ flex: 1 1 auto;
231
+ justify-content: center;
232
+ gap: 32px;
233
+ padding: 48px;
234
+ border-bottom: 0;
235
+ }
236
+ .vault-body.setup-required .vault-metrics.summary {
237
+ max-width: 620px;
238
+ flex: 0 1 620px;
239
+ grid-template-columns: minmax(0, 1fr);
240
+ }
241
+ .vault-body.setup-required .vault-summary-value {
242
+ margin-top: 8px;
243
+ font-size: 24px;
244
+ }
245
+ .vault-body.setup-required .vault-summary-side { display: none; }
221
246
  .vault-storage-chart {
222
247
  width: min(100%, 540px);
223
248
  min-width: 0;