pinokiod 8.0.110 → 8.0.112

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.
@@ -150,6 +150,15 @@ const sameFileMetadata = (left, right) => {
150
150
  }
151
151
 
152
152
  const sourceId = (kind, name) => `${kind}:${encodeURIComponent(name)}`
153
+ const commonDirectory = (left, right) => {
154
+ let index = 0
155
+ while (index < left.length &&
156
+ index < right.length &&
157
+ left[index] === right[index]) index += 1
158
+ const shared = left.slice(0, index)
159
+ const cut = shared.lastIndexOf("/")
160
+ return cut < 0 ? "" : shared.slice(0, cut)
161
+ }
153
162
  const boundedInteger = (value, fallback, minimum, maximum) => {
154
163
  const parsed = Number(value)
155
164
  if (!Number.isSafeInteger(parsed)) return fallback
@@ -1227,6 +1236,19 @@ class Vault {
1227
1236
  }
1228
1237
  }
1229
1238
 
1239
+ sourceLabelChain(source) {
1240
+ if (!source) return ""
1241
+ const parts = [source.label]
1242
+ const seen = new Set([source.id])
1243
+ let current = source
1244
+ while (current && current.parent_id && !seen.has(current.parent_id)) {
1245
+ seen.add(current.parent_id)
1246
+ current = this._sourcesById.get(current.parent_id)
1247
+ if (current) parts.unshift(current.label)
1248
+ }
1249
+ return parts.join(" / ")
1250
+ }
1251
+
1230
1252
  sourceIsWithinScope(source, scopeId) {
1231
1253
  if (!scopeId) return true
1232
1254
  const seen = new Set()
@@ -2025,7 +2047,10 @@ class Vault {
2025
2047
  const selection = this.separateSelection(payload)
2026
2048
  if (selection.error) return { error: selection.error }
2027
2049
  const total = await this.registry.matchingFileSummary(
2028
- "linked", selection.sourceIds, selection.query)
2050
+ "linked",
2051
+ selection.sourceIds,
2052
+ selection.query,
2053
+ selection.nameOnly)
2029
2054
  if (!total.count) {
2030
2055
  return { error: "No matching deduplicated files remain." }
2031
2056
  }
@@ -2586,7 +2611,10 @@ class Vault {
2586
2611
  }
2587
2612
  return {
2588
2613
  sourceIds,
2589
- query: String(payload.query || "").slice(0, 500).trim()
2614
+ query: String(payload.query || "").slice(0, 500).trim(),
2615
+ // Separating "all matching" has to mean the rows the search listed, so
2616
+ // it repeats the filter the view was using.
2617
+ nameOnly: payload.display_mode === "files"
2590
2618
  }
2591
2619
  }
2592
2620
 
@@ -2954,7 +2982,8 @@ class Vault {
2954
2982
  selection.sourceIds,
2955
2983
  cursor,
2956
2984
  100,
2957
- selection.query
2985
+ selection.query,
2986
+ selection.nameOnly
2958
2987
  )
2959
2988
  if (!rows.length) break
2960
2989
  const affectedHashes = new Set()
@@ -3303,6 +3332,188 @@ class Vault {
3303
3332
  }, this.locationForPath(row.path, row.source_id)))
3304
3333
  }
3305
3334
 
3335
+ // One level of the folder tree. Subfolders come back with the totals for
3336
+ // everything inside them, which is what makes sorting a level by size mean
3337
+ // anything; files sitting directly in the folder come back as normal rows.
3338
+ async treeEntries(scopeId, options = {}) {
3339
+ if (!this.enabled || !this.registry) return { enabled: false }
3340
+ const locationId = typeof options.location_id === "string" &&
3341
+ options.location_id
3342
+ ? options.location_id
3343
+ : null
3344
+ const view = STATUS_VIEWS.has(options.view) ? options.view : "all"
3345
+ const statusFilter = STATUS_FILTERS.has(options.status_filter)
3346
+ ? options.status_filter
3347
+ : "all"
3348
+ const query = String(options.query || "").slice(0, 500).trim()
3349
+ const sizeSort = options.size_sort === "asc" || options.size_sort === "desc"
3350
+ ? options.size_sort
3351
+ : null
3352
+ const nameSort = options.name_sort === "desc" ? "desc" : "asc"
3353
+ // The authorised set, not the requested id: a scoped page must not be able
3354
+ // to read another location by naming it, and a rail entry standing for a
3355
+ // group of locations -- Pinokio, Apps -- owns no files of its own, so it
3356
+ // resolves to its descendants rather than to an empty tree.
3357
+ const sourceIds = this.scopeSourceIds(scopeId, locationId)
3358
+ const source = sourceIds.length === 1
3359
+ ? this._sourcesById.get(sourceIds[0])
3360
+ : null
3361
+ // A group of locations, or none chosen at all, is a tree of locations.
3362
+ if (!source || !source.root) {
3363
+ const rows = await this.registry.treeLocationRows({
3364
+ sourceIds,
3365
+ unrestricted: !scopeId && !locationId,
3366
+ externalSourceIds: this.configuredExternalSourceIds(),
3367
+ view,
3368
+ statusFilter,
3369
+ query,
3370
+ nameOnly: false
3371
+ })
3372
+ const items = rows
3373
+ .map((row) => {
3374
+ const entry = this._sourcesById.get(row.source_id)
3375
+ if (!entry) return null
3376
+ return {
3377
+ kind: "location",
3378
+ name: entry.label,
3379
+ label: this.sourceLabelChain(entry),
3380
+ relative_path: "",
3381
+ location_id: entry.id,
3382
+ source_id: entry.id,
3383
+ size: Number(row.bytes) || 0,
3384
+ file_count: Number(row.file_count) || 0,
3385
+ duplicate_count: Number(row.duplicate_count) || 0,
3386
+ unavailable_count: Number(row.unavailable_count) || 0,
3387
+ linked_count: Number(row.linked_count) || 0
3388
+ }
3389
+ })
3390
+ .filter(Boolean)
3391
+ items.sort((left, right) => {
3392
+ if (sizeSort === "asc" && left.size !== right.size) return left.size - right.size
3393
+ if (sizeSort === "desc" && left.size !== right.size) return right.size - left.size
3394
+ const order = left.label.localeCompare(right.label)
3395
+ return nameSort === "desc" ? -order : order
3396
+ })
3397
+ return {
3398
+ enabled: true,
3399
+ parent: "",
3400
+ source_id: null,
3401
+ items,
3402
+ has_next: false,
3403
+ next_cursor: null
3404
+ }
3405
+ }
3406
+ const parent = String(options.parent || "")
3407
+ .replace(/^[\\/]+/, "")
3408
+ .replace(/[\\/]+$/, "")
3409
+ .slice(0, 4096)
3410
+ const root = source.root.replace(/[\\/]+$/, "")
3411
+ // Every source root is produced by path.resolve, so it is already native
3412
+ // and the platform separator is the right one -- no need to infer it from
3413
+ // the string, which a directory named with a backslash would fool.
3414
+ const separator = path.sep
3415
+ const prefix = parent
3416
+ ? `${root}${separator}${parent.split("/").join(separator)}${separator}`
3417
+ : `${root}${separator}`
3418
+ const pageSize = boundedInteger(
3419
+ options.page_size, STATUS_PAGE_SIZE, 1, STATUS_PAGE_SIZE)
3420
+ const directoryOffset = boundedInteger(
3421
+ options.directory_offset, 0, 0, Number.MAX_SAFE_INTEGER)
3422
+ const level = await this.registry.treeLevel({
3423
+ sourceId: source.id,
3424
+ prefix,
3425
+ separator,
3426
+ view,
3427
+ statusFilter,
3428
+ query,
3429
+ nameOnly: false,
3430
+ sizeSort,
3431
+ nameSort,
3432
+ cursor: typeof options.cursor === "string"
3433
+ ? options.cursor.slice(0, 2048)
3434
+ : "",
3435
+ pageSize,
3436
+ // One extra of each so the merge can tell whether a side has more.
3437
+ directoryLimit: pageSize + 1,
3438
+ directoryOffset,
3439
+ externalSourceIds: this.configuredExternalSourceIds()
3440
+ })
3441
+ const directories = (level.directories || []).map((row) => {
3442
+ // A run of folders holding nothing but one another reads as one row.
3443
+ // The shared prefix of the first and last path underneath is exactly
3444
+ // that run, because the two are the extremes of a sorted list.
3445
+ const native = (value) => separator === "/"
3446
+ ? String(value || "")
3447
+ : String(value || "").split(separator).join("/")
3448
+ const shared = commonDirectory(native(row.lo), native(row.hi))
3449
+ const label = shared && shared.startsWith(`${row.name}/`)
3450
+ ? shared
3451
+ : row.name
3452
+ return {
3453
+ kind: "directory",
3454
+ name: row.name,
3455
+ label,
3456
+ relative_path: parent ? `${parent}/${label}` : label,
3457
+ source_id: source.id,
3458
+ size: Number(row.bytes) || 0,
3459
+ file_count: Number(row.file_count) || 0,
3460
+ duplicate_count: Number(row.duplicate_count) || 0,
3461
+ unavailable_count: Number(row.unavailable_count) || 0,
3462
+ linked_count: Number(row.linked_count) || 0
3463
+ }
3464
+ })
3465
+ const files = this.publicFileItems(level.files)
3466
+ .map((item) => Object.assign({ kind: "file" }, item))
3467
+ const fileCursors = (level.files && level.files.rowCursors) || []
3468
+ // Both sides arrive sorted the same way, so walking them together gives the
3469
+ // page its true order. Sorting by size interleaves folders and files: the
3470
+ // question on screen is "what is biggest", and a large loose file buried
3471
+ // under small folders is the wrong answer to it. Sorting by name keeps the
3472
+ // familiar folders-first grouping.
3473
+ const ahead = (left, right) => {
3474
+ if (sizeSort) {
3475
+ if (left.size !== right.size) {
3476
+ return sizeSort === "asc"
3477
+ ? left.size < right.size
3478
+ : left.size > right.size
3479
+ }
3480
+ } else if ((left.kind === "directory") !== (right.kind === "directory")) {
3481
+ return left.kind === "directory"
3482
+ }
3483
+ const order = String(left.label || left.relative_path || "")
3484
+ .localeCompare(String(right.label || right.relative_path || ""))
3485
+ return nameSort === "desc" ? order > 0 : order < 0
3486
+ }
3487
+ const items = []
3488
+ let directoryIndex = 0
3489
+ let fileIndex = 0
3490
+ while (items.length < pageSize &&
3491
+ (directoryIndex < directories.length || fileIndex < files.length)) {
3492
+ const directory = directories[directoryIndex]
3493
+ const file = files[fileIndex]
3494
+ if (directory && (!file || ahead(directory, file))) {
3495
+ items.push(directory)
3496
+ directoryIndex += 1
3497
+ } else if (file) {
3498
+ items.push(file)
3499
+ fileIndex += 1
3500
+ } else break
3501
+ }
3502
+ return {
3503
+ enabled: true,
3504
+ parent,
3505
+ source_id: source.id,
3506
+ items,
3507
+ has_next: directoryIndex < directories.length ||
3508
+ fileIndex < files.length ||
3509
+ !!(level.files && level.files.nextCursor),
3510
+ next_directory_offset: directoryOffset + directoryIndex,
3511
+ next_cursor: fileIndex > 0
3512
+ ? fileCursors[fileIndex - 1] || null
3513
+ : (typeof options.cursor === "string" ? options.cursor : null)
3514
+ }
3515
+ }
3516
+
3306
3517
  async duplicateGroupChildren(scopeId, hash, options = {}) {
3307
3518
  if (typeof hash !== "string" || !SHA256_RE.test(hash)) {
3308
3519
  throw new TypeError("Invalid vault content identifier.")
@@ -3326,7 +3537,9 @@ class Vault {
3326
3537
  cursor,
3327
3538
  pageSize,
3328
3539
  unrestricted: !scopeId,
3329
- activeUnrestricted: !scopeId && !locationId
3540
+ activeUnrestricted: !scopeId && !locationId,
3541
+ // Content groups are only ever listed by the flat file view.
3542
+ nameOnly: true
3330
3543
  })
3331
3544
  return {
3332
3545
  hash,
@@ -3389,7 +3602,8 @@ class Vault {
3389
3602
  sourceIds: this.scopeSourceIds(scopeId, locationId),
3390
3603
  externalSourceIds: this.configuredExternalSourceIds(),
3391
3604
  query: String(options.query || "").slice(0, 500).trim(),
3392
- unrestricted: !scopeId && !locationId
3605
+ unrestricted: !scopeId && !locationId,
3606
+ nameOnly: true
3393
3607
  })
3394
3608
  return {
3395
3609
  hash,
@@ -3416,7 +3630,8 @@ class Vault {
3416
3630
  options.page_size, STATUS_PAGE_SIZE, 1, STATUS_PAGE_SIZE),
3417
3631
  sizeSort: options.size_sort || "desc",
3418
3632
  unrestricted: !scopeId && !locationId,
3419
- authorizedUnrestricted: !scopeId
3633
+ authorizedUnrestricted: !scopeId,
3634
+ nameOnly: true
3420
3635
  })
3421
3636
  return {
3422
3637
  items: result.items || [],
@@ -3429,6 +3644,9 @@ class Vault {
3429
3644
  const view = STATUS_VIEWS.has(options.view) ? options.view : "all"
3430
3645
  const groupDuplicates =
3431
3646
  view === "duplicates" && options.display_mode === "files"
3647
+ // The flat list shows a file name where the tree shows a folder path, so
3648
+ // the search has to match what is on screen. See queryClause().
3649
+ const filesMode = options.display_mode === "files"
3432
3650
  const statusFilter = STATUS_FILTERS.has(options.status_filter)
3433
3651
  ? options.status_filter
3434
3652
  : "all"
@@ -3464,6 +3682,8 @@ class Vault {
3464
3682
  ? options.size_sort || "desc"
3465
3683
  : options.size_sort,
3466
3684
  groupDuplicates,
3685
+ nameOnly: filesMode,
3686
+ nameSort: options.name_sort === "desc" ? "desc" : "asc",
3467
3687
  cursor
3468
3688
  })
3469
3689
  let items
@@ -210,6 +210,8 @@ for (const method of [
210
210
  "duplicateGroupSelection",
211
211
  "duplicateGroupPageSelection",
212
212
  "statusSnapshot",
213
+ "treeLevel",
214
+ "treeLocationRows",
213
215
  "clearFiles"
214
216
  ]) {
215
217
  Registry.prototype[method] = function (...args) {