@glw907/cairn-cms 0.58.0 → 0.59.0

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.
@@ -13,8 +13,16 @@ interface Props {
13
13
  * shows where each one is used, edits its name and default alt, and deletes it safely. The resting
14
14
  * surface is a visual contact-sheet grid (a roving-tabindex listbox of tiles), with a list-density
15
15
  * toggle that flips to an enriched sortable table. One toolbar row carries search, a pick-one triage
16
- * radiogroup (All, Needs alt, Unused), and the density toggle. Filtering, sorting, and a growing
17
- * client window all run over the full loaded set in component state.
16
+ * radiogroup (All, Needs alt, No references found), and the density toggle. Filtering, sorting, and a
17
+ * growing client window all run over the full loaded set in component state.
18
+ *
19
+ * Multi-select rides a Set of selected hashes, decoupled from the slide-over's single asset and from
20
+ * roving focus. The grid is an APG multiselectable listbox (aria-multiselectable, real cell focus):
21
+ * Space toggles the focused tile, Shift+Arrow extends a range, Ctrl/Cmd+A selects every visible asset,
22
+ * and Escape clears. The list density is a plain selectable table whose leading native-checkbox column
23
+ * is the selection signal (no grid role, since it has no grid keyboard model). A sticky action bar
24
+ * appears on the first selection with a live count, the scope, Select all in view, Clear, and the
25
+ * reversible bulk Delete.
18
26
  *
19
27
  * Activating a tile or row opens a NON-MODAL detail slide-over from the right (the established
20
28
  * details-slide-over recipe): no scrim, the library stays live and in the a11y tree behind it, Escape
@@ -27,3 +27,4 @@ export { default as RefreshCwIcon } from '@lucide/svelte/icons/refresh-cw';
27
27
  export { default as GitBranchIcon } from '@lucide/svelte/icons/git-branch';
28
28
  export { default as ArrowRightIcon } from '@lucide/svelte/icons/arrow-right';
29
29
  export { default as MegaphoneIcon } from '@lucide/svelte/icons/megaphone';
30
+ export { default as DatabaseIcon } from '@lucide/svelte/icons/database';
@@ -29,3 +29,4 @@ export { default as RefreshCwIcon } from '@lucide/svelte/icons/refresh-cw';
29
29
  export { default as GitBranchIcon } from '@lucide/svelte/icons/git-branch';
30
30
  export { default as ArrowRightIcon } from '@lucide/svelte/icons/arrow-right';
31
31
  export { default as MegaphoneIcon } from '@lucide/svelte/icons/megaphone';
32
+ export { default as DatabaseIcon } from '@lucide/svelte/icons/database';
@@ -80,6 +80,7 @@
80
80
  --container-md: 28rem;
81
81
  --container-lg: 32rem;
82
82
  --container-xl: 36rem;
83
+ --container-2xl: 42rem;
83
84
  --container-3xl: 48rem;
84
85
  --container-5xl: 64rem;
85
86
  --text-xs: .75rem;
@@ -3046,6 +3047,14 @@
3046
3047
  bottom: calc(var(--spacing) * 0);
3047
3048
  }
3048
3049
 
3050
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .bottom-3\.5 {
3051
+ bottom: calc(var(--spacing) * 3.5);
3052
+ }
3053
+
3054
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .left-2 {
3055
+ left: calc(var(--spacing) * 2);
3056
+ }
3057
+
3049
3058
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .join {
3050
3059
  --join-ss: 0;
3051
3060
  --join-se: 0;
@@ -3348,6 +3357,10 @@
3348
3357
  z-index: 10;
3349
3358
  }
3350
3359
 
3360
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .z-20 {
3361
+ z-index: 20;
3362
+ }
3363
+
3351
3364
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .z-30 {
3352
3365
  z-index: 30;
3353
3366
  }
@@ -4309,10 +4322,22 @@
4309
4322
  height: 1px;
4310
4323
  }
4311
4324
 
4325
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .max-h-36 {
4326
+ max-height: calc(var(--spacing) * 36);
4327
+ }
4328
+
4329
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .max-h-40 {
4330
+ max-height: calc(var(--spacing) * 40);
4331
+ }
4332
+
4312
4333
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .max-h-44 {
4313
4334
  max-height: calc(var(--spacing) * 44);
4314
4335
  }
4315
4336
 
4337
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .max-h-52 {
4338
+ max-height: calc(var(--spacing) * 52);
4339
+ }
4340
+
4316
4341
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .max-h-56 {
4317
4342
  max-height: calc(var(--spacing) * 56);
4318
4343
  }
@@ -4513,6 +4538,10 @@
4513
4538
  max-width: calc(var(--spacing) * 0);
4514
4539
  }
4515
4540
 
4541
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .max-w-2xl {
4542
+ max-width: var(--container-2xl);
4543
+ }
4544
+
4516
4545
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .max-w-3xl {
4517
4546
  max-width: var(--container-3xl);
4518
4547
  }
@@ -4541,6 +4570,10 @@
4541
4570
  max-width: 72ch;
4542
4571
  }
4543
4572
 
4573
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .max-w-\[640px\] {
4574
+ max-width: 640px;
4575
+ }
4576
+
4544
4577
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .max-w-full {
4545
4578
  max-width: 100%;
4546
4579
  }
@@ -4695,6 +4728,10 @@
4695
4728
  grid-template-columns: repeat(2, minmax(0, 1fr));
4696
4729
  }
4697
4730
 
4731
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .grid-cols-3 {
4732
+ grid-template-columns: repeat(3, minmax(0, 1fr));
4733
+ }
4734
+
4698
4735
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .grid-cols-\[1fr_auto_1fr\] {
4699
4736
  grid-template-columns: 1fr auto 1fr;
4700
4737
  }
@@ -4871,6 +4908,10 @@
4871
4908
  border-radius: .25rem;
4872
4909
  }
4873
4910
 
4911
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .rounded-2xl {
4912
+ border-radius: var(--radius-2xl);
4913
+ }
4914
+
4874
4915
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .rounded-\[0\.55rem\] {
4875
4916
  border-radius: .55rem;
4876
4917
  }
@@ -5001,6 +5042,26 @@
5001
5042
  }
5002
5043
  }
5003
5044
 
5045
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .border-\[color-mix\(in_oklab\,var\(--cairn-card-border\)_70\%\,transparent\)\] {
5046
+ border-color: var(--cairn-card-border);
5047
+ }
5048
+
5049
+ @supports (color: color-mix(in lab, red, red)) {
5050
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .border-\[color-mix\(in_oklab\,var\(--cairn-card-border\)_70\%\,transparent\)\] {
5051
+ border-color: color-mix(in oklab,var(--cairn-card-border) 70%,transparent);
5052
+ }
5053
+ }
5054
+
5055
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .border-\[color-mix\(in_oklab\,var\(--cairn-error-border\)_70\%\,transparent\)\] {
5056
+ border-color: var(--cairn-error-border);
5057
+ }
5058
+
5059
+ @supports (color: color-mix(in lab, red, red)) {
5060
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .border-\[color-mix\(in_oklab\,var\(--cairn-error-border\)_70\%\,transparent\)\] {
5061
+ border-color: color-mix(in oklab,var(--cairn-error-border) 70%,transparent);
5062
+ }
5063
+ }
5064
+
5004
5065
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .border-\[color-mix\(in_oklab\,var\(--color-error\)_35\%\,var\(--cairn-card-border\)\)\] {
5005
5066
  border-color: var(--color-error);
5006
5067
  }
@@ -5105,6 +5166,16 @@
5105
5166
  border: none;
5106
5167
  }
5107
5168
 
5169
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .bg-\[color-mix\(in_oklab\,var\(--cairn-warning-ink\)_8\%\,var\(--color-base-100\)\)\] {
5170
+ background-color: var(--cairn-warning-ink);
5171
+ }
5172
+
5173
+ @supports (color: color-mix(in lab, red, red)) {
5174
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .bg-\[color-mix\(in_oklab\,var\(--cairn-warning-ink\)_8\%\,var\(--color-base-100\)\)\] {
5175
+ background-color: color-mix(in oklab,var(--cairn-warning-ink) 8%,var(--color-base-100));
5176
+ }
5177
+ }
5178
+
5108
5179
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .bg-\[color-mix\(in_oklab\,var\(--color-error\)_5\%\,transparent\)\] {
5109
5180
  background-color: var(--color-error);
5110
5181
  }
@@ -5157,6 +5228,14 @@
5157
5228
  }
5158
5229
  }
5159
5230
 
5231
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .bg-\[var\(--color-error\)\] {
5232
+ background-color: var(--color-error);
5233
+ }
5234
+
5235
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .bg-\[var\(--color-positive-tint\,var\(--cairn-card-border\)\)\] {
5236
+ background-color: var(--color-positive-tint, var(--cairn-card-border));
5237
+ }
5238
+
5160
5239
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .bg-base-100 {
5161
5240
  background-color: var(--color-base-100);
5162
5241
  }
@@ -5273,6 +5352,16 @@
5273
5352
  }
5274
5353
  }
5275
5354
 
5355
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .bg-primary\/\[0\.03\] {
5356
+ background-color: var(--color-primary);
5357
+ }
5358
+
5359
+ @supports (color: color-mix(in lab, red, red)) {
5360
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .bg-primary\/\[0\.03\] {
5361
+ background-color: color-mix(in oklab, var(--color-primary) 3%, transparent);
5362
+ }
5363
+ }
5364
+
5276
5365
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .bg-primary\/\[0\.05\] {
5277
5366
  background-color: var(--color-primary);
5278
5367
  }
@@ -5305,12 +5394,30 @@
5305
5394
  background-color: var(--color-warning);
5306
5395
  }
5307
5396
 
5397
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .\[background-image\:linear-gradient\(45deg\,color-mix\(in_oklab\,var\(--color-base-content\)_7\%\,transparent\)_25\%\,transparent_25\%\,transparent_75\%\,color-mix\(in_oklab\,var\(--color-base-content\)_7\%\,transparent\)_75\%\)\,linear-gradient\(45deg\,color-mix\(in_oklab\,var\(--color-base-content\)_7\%\,transparent\)_25\%\,transparent_25\%\,transparent_75\%\,color-mix\(in_oklab\,var\(--color-base-content\)_7\%\,transparent\)_75\%\)\] {
5398
+ background-image: linear-gradient(45deg,var(--color-base-content) 25%,transparent 25%,transparent 75%,var(--color-base-content) 75%),linear-gradient(45deg,var(--color-base-content) 25%,transparent 25%,transparent 75%,var(--color-base-content) 75%);
5399
+ }
5400
+
5401
+ @supports (color: color-mix(in lab, red, red)) {
5402
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .\[background-image\:linear-gradient\(45deg\,color-mix\(in_oklab\,var\(--color-base-content\)_7\%\,transparent\)_25\%\,transparent_25\%\,transparent_75\%\,color-mix\(in_oklab\,var\(--color-base-content\)_7\%\,transparent\)_75\%\)\,linear-gradient\(45deg\,color-mix\(in_oklab\,var\(--color-base-content\)_7\%\,transparent\)_25\%\,transparent_25\%\,transparent_75\%\,color-mix\(in_oklab\,var\(--color-base-content\)_7\%\,transparent\)_75\%\)\] {
5403
+ background-image: linear-gradient(45deg,color-mix(in oklab,var(--color-base-content) 7%,transparent) 25%,transparent 25%,transparent 75%,color-mix(in oklab,var(--color-base-content) 7%,transparent) 75%),linear-gradient(45deg,color-mix(in oklab,var(--color-base-content) 7%,transparent) 25%,transparent 25%,transparent 75%,color-mix(in oklab,var(--color-base-content) 7%,transparent) 75%);
5404
+ }
5405
+ }
5406
+
5308
5407
  @layer daisyui.l1.l2 {
5309
5408
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .loading-spinner {
5310
5409
  mask-image: url("data:image/svg+xml,%3Csvg width='24' height='24' stroke='black' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cg transform-origin='center'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' stroke-linecap='round'%3E%3CanimateTransform attributeName='transform' type='rotate' from='0 12 12' to='360 12 12' dur='2s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dasharray' values='0,150;42,150;42,150' keyTimes='0;0.475;1' dur='1.5s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dashoffset' values='0;-16;-59' keyTimes='0;0.475;1' dur='1.5s' repeatCount='indefinite'/%3E%3C/circle%3E%3C/g%3E%3C/svg%3E");
5311
5410
  }
5312
5411
  }
5313
5412
 
5413
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .\[background-size\:8px_8px\] {
5414
+ background-size: 8px 8px;
5415
+ }
5416
+
5417
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .\[background-position\:0_0\,4px_4px\] {
5418
+ background-position: 0 0, 4px 4px;
5419
+ }
5420
+
5314
5421
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .object-contain {
5315
5422
  object-fit: contain;
5316
5423
  }
@@ -5464,6 +5571,10 @@
5464
5571
  padding-inline: calc(var(--spacing) * 3);
5465
5572
  }
5466
5573
 
5574
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .px-3\.5 {
5575
+ padding-inline: calc(var(--spacing) * 3.5);
5576
+ }
5577
+
5467
5578
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .px-4 {
5468
5579
  padding-inline: calc(var(--spacing) * 4);
5469
5580
  }
@@ -5786,6 +5897,10 @@
5786
5897
  word-break: break-all;
5787
5898
  }
5788
5899
 
5900
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .whitespace-nowrap {
5901
+ white-space: nowrap;
5902
+ }
5903
+
5789
5904
  @layer daisyui.l1.l2 {
5790
5905
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .alert-error {
5791
5906
  color: var(--color-error-content);
@@ -5822,6 +5937,10 @@
5822
5937
  color: var(--color-accent);
5823
5938
  }
5824
5939
 
5940
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .text-\[var\(--color-error-content\)\] {
5941
+ color: var(--color-error-content);
5942
+ }
5943
+
5825
5944
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .text-\[var\(--color-muted\)\] {
5826
5945
  color: var(--color-muted);
5827
5946
  }
@@ -6005,6 +6124,16 @@
6005
6124
  box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
6006
6125
  }
6007
6126
 
6127
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .ring-primary\/40 {
6128
+ --tw-ring-color: var(--color-primary);
6129
+ }
6130
+
6131
+ @supports (color: color-mix(in lab, red, red)) {
6132
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .ring-primary\/40 {
6133
+ --tw-ring-color: color-mix(in oklab, var(--color-primary) 40%, transparent);
6134
+ }
6135
+ }
6136
+
6008
6137
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .ring-primary\/70 {
6009
6138
  --tw-ring-color: var(--color-primary);
6010
6139
  }
@@ -6343,6 +6472,24 @@
6343
6472
  }
6344
6473
  }
6345
6474
 
6475
+ @media (hover: hover) {
6476
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .hover\:bg-\[var\(--cairn-error-tint\)\]:hover {
6477
+ background-color: var(--cairn-error-tint);
6478
+ }
6479
+ }
6480
+
6481
+ @media (hover: hover) {
6482
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .hover\:bg-\[var\(--color-error\)\]\/90:hover {
6483
+ background-color: var(--color-error);
6484
+ }
6485
+
6486
+ @supports (color: color-mix(in lab, red, red)) {
6487
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .hover\:bg-\[var\(--color-error\)\]\/90:hover {
6488
+ background-color: color-mix(in oklab, var(--color-error) 90%, transparent);
6489
+ }
6490
+ }
6491
+ }
6492
+
6346
6493
  @media (hover: hover) {
6347
6494
  :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .hover\:bg-base-200:hover {
6348
6495
  background-color: var(--color-base-200);
@@ -1 +1 @@
1
- export type CairnLogEvent = 'auth.link.requested' | 'auth.link.send_failed' | 'auth.token.minted' | 'auth.token.confirmed' | 'auth.session.created' | 'auth.session.destroyed' | 'commit.succeeded' | 'commit.failed' | 'config.invalid' | 'entry.published' | 'entry.discarded' | 'publish.failed' | 'github.unreachable' | 'guard.rejected' | 'media.uploaded' | 'media.upload_failed' | 'media.delivery_failed' | 'media.orphan_reconcile' | 'media.resolve_missing' | 'media.deleted' | 'media.delete_blocked' | 'media.replaced' | 'media.replace_blocked' | 'media.alt_propagated';
1
+ export type CairnLogEvent = 'auth.link.requested' | 'auth.link.send_failed' | 'auth.token.minted' | 'auth.token.confirmed' | 'auth.session.created' | 'auth.session.destroyed' | 'commit.succeeded' | 'commit.failed' | 'config.invalid' | 'entry.published' | 'entry.discarded' | 'publish.failed' | 'github.unreachable' | 'guard.rejected' | 'media.uploaded' | 'media.upload_failed' | 'media.delivery_failed' | 'media.orphan_reconcile' | 'media.resolve_missing' | 'media.deleted' | 'media.delete_blocked' | 'media.bulk_deleted' | 'media.orphans_purged' | 'media.replaced' | 'media.replace_blocked' | 'media.alt_propagated';
@@ -0,0 +1,24 @@
1
+ import type { UsageEntry, UsageIndex } from './usage.js';
2
+ import type { MediaManifest } from './manifest.js';
3
+ /** One selected hash that is not deleted, with why and (for the where-used) its usage rows. The rows
4
+ * are present only for 'still-referenced'; an 'uncommitted' skip carries an empty list. */
5
+ export interface BulkDeleteSkip {
6
+ hash: string;
7
+ reason: 'still-referenced' | 'uncommitted';
8
+ usage: UsageEntry[];
9
+ }
10
+ /** The partitioned selection: the hashes safe to purge and the hashes held back. Both arrays keep the
11
+ * input order of `selected` so the screen reports them in the order the user picked. */
12
+ export interface BulkDeletePlan {
13
+ deletable: string[];
14
+ skipped: BulkDeleteSkip[];
15
+ }
16
+ /**
17
+ * Partition `selected` against a strict usage index and the media manifest.
18
+ *
19
+ * A hash with one or more usage rows is skipped 'still-referenced', carrying those rows for the
20
+ * where-used. A hash with no usage row and no committed manifest row is skipped 'uncommitted', since
21
+ * there is nothing committed to delete. A hash with no usage row and a committed manifest row is
22
+ * deletable. The input order of `selected` is preserved in both output arrays.
23
+ */
24
+ export declare function planBulkDelete(selected: string[], index: UsageIndex, manifest: MediaManifest): BulkDeletePlan;
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Partition `selected` against a strict usage index and the media manifest.
3
+ *
4
+ * A hash with one or more usage rows is skipped 'still-referenced', carrying those rows for the
5
+ * where-used. A hash with no usage row and no committed manifest row is skipped 'uncommitted', since
6
+ * there is nothing committed to delete. A hash with no usage row and a committed manifest row is
7
+ * deletable. The input order of `selected` is preserved in both output arrays.
8
+ */
9
+ export function planBulkDelete(selected, index, manifest) {
10
+ const deletable = [];
11
+ const skipped = [];
12
+ for (const hash of selected) {
13
+ const usage = index.get(hash);
14
+ if (usage && usage.length > 0) {
15
+ skipped.push({ hash, reason: 'still-referenced', usage });
16
+ }
17
+ else if (manifest[hash]) {
18
+ deletable.push(hash);
19
+ }
20
+ else {
21
+ skipped.push({ hash, reason: 'uncommitted', usage: [] });
22
+ }
23
+ }
24
+ return { deletable, skipped };
25
+ }
@@ -0,0 +1,37 @@
1
+ import { type ReconcileResult } from './reconcile.js';
2
+ import type { MediaManifest } from './manifest.js';
3
+ import type { UsageEntry, UsageIndex } from './usage.js';
4
+ /** A purgeable orphan: a stored R2 key with no manifest row, plus the 16-hex hash parsed from it. */
5
+ export interface OrphanByteRow {
6
+ /** The full R2 object key, e.g. "media/ff/ffffffffffffffff.webp". */
7
+ key: string;
8
+ /** The 16-hex content hash parsed from the key. */
9
+ hash: string;
10
+ }
11
+ /** A broken reference: a manifest row whose bytes are gone. Read-only, since purging it would drop a
12
+ * still-referenced asset's record; the screen shows where it is used so an operator can re-ingest. */
13
+ export interface BrokenRefRow {
14
+ /** The 16-hex content hash of the manifest row whose bytes are missing. */
15
+ hash: string;
16
+ /** The manifest row's display slug, or '' when the row is somehow absent. */
17
+ slug: string;
18
+ /** Where the asset is referenced, from the usage index. Empty when no reference was found. */
19
+ usage: UsageEntry[];
20
+ }
21
+ /** The scan surface model: the two row sets the Library renders. */
22
+ export interface OrphanScan {
23
+ orphanedBytes: OrphanByteRow[];
24
+ brokenRefs: BrokenRefRow[];
25
+ }
26
+ /**
27
+ * Project a reconcile read plus the usage index into the scan surface model.
28
+ *
29
+ * `orphanedBytes` come from `reconcile.orphanedObjects`: each key is parsed to its hash via the
30
+ * shared media-key grammar, and a key that does not match (so it is not a content-addressed media
31
+ * object) is skipped. A key whose hash the usage index references is also skipped: it is referenced
32
+ * on main or some open branch, so its bytes are in use, not orphaned. `brokenRefs` come from
33
+ * `reconcile.missingObjects`: each hash carries its
34
+ * manifest slug (falling back to '' when the row is absent) and its where-used rows from the index
35
+ * (an empty list when no reference was found). Both directions keep their input order.
36
+ */
37
+ export declare function buildOrphanScan(reconcile: ReconcileResult, manifest: MediaManifest, index: UsageIndex): OrphanScan;
@@ -0,0 +1,42 @@
1
+ // cairn-cms: the orphan-scan projection, the pure model behind the admin Media Library's scan
2
+ // surface. It folds reconcileMedia's two directions together with the usage index into the two rows
3
+ // the screen renders: the purgeable byte-rows and the read-only broken-reference rows (manifest rows
4
+ // whose bytes are gone). It only projects; no path here reads R2, the manifest, or git. The module
5
+ // is engine-internal and on no public subpath.
6
+ //
7
+ // An orphaned byte is a stored R2 object whose hash has NO manifest row AND appears in NO usage row,
8
+ // so it is referenced nowhere across main and every open branch. Reconcile only checks main's
9
+ // manifest, so a branch-only upload (bytes in R2, manifest row only on the open cairn/* branch) gets
10
+ // flagged as an orphaned object even though a colleague's in-progress draft references it. The byte
11
+ // purge is irreversible, so we intersect reconcile's verdict with the strict cross-branch usage
12
+ // index here: any hash the index references is in use and is dropped from orphanedBytes, which keeps
13
+ // a live draft's bytes from ever reaching the purge surface.
14
+ import { MEDIA_KEY_RE } from './reconcile.js';
15
+ /**
16
+ * Project a reconcile read plus the usage index into the scan surface model.
17
+ *
18
+ * `orphanedBytes` come from `reconcile.orphanedObjects`: each key is parsed to its hash via the
19
+ * shared media-key grammar, and a key that does not match (so it is not a content-addressed media
20
+ * object) is skipped. A key whose hash the usage index references is also skipped: it is referenced
21
+ * on main or some open branch, so its bytes are in use, not orphaned. `brokenRefs` come from
22
+ * `reconcile.missingObjects`: each hash carries its
23
+ * manifest slug (falling back to '' when the row is absent) and its where-used rows from the index
24
+ * (an empty list when no reference was found). Both directions keep their input order.
25
+ */
26
+ export function buildOrphanScan(reconcile, manifest, index) {
27
+ const orphanedBytes = [];
28
+ for (const key of reconcile.orphanedObjects) {
29
+ const hash = MEDIA_KEY_RE.exec(key)?.[1];
30
+ if (hash === undefined)
31
+ continue;
32
+ if (index.has(hash))
33
+ continue;
34
+ orphanedBytes.push({ key, hash });
35
+ }
36
+ const brokenRefs = reconcile.missingObjects.map((hash) => ({
37
+ hash,
38
+ slug: manifest[hash]?.slug ?? '',
39
+ usage: index.get(hash) ?? [],
40
+ }));
41
+ return { orphanedBytes, brokenRefs };
42
+ }
@@ -1,4 +1,7 @@
1
1
  import type { MediaManifest } from './manifest.js';
2
+ /** A stored media object key parses to its short hash via `media/<aa>/<shortHash>.<ext>`. Exported so
3
+ * the orphan-scan projection derives the same hash from an orphaned key without a second grammar. */
4
+ export declare const MEDIA_KEY_RE: RegExp;
2
5
  /** What a reconcile read found in either direction. `orphanedObjects` are stored R2 keys whose hash
3
6
  * has no manifest row; `missingObjects` are manifest hashes with no stored object. */
4
7
  export interface ReconcileResult {
@@ -1,6 +1,7 @@
1
1
  import { log } from '../log/index.js';
2
- /** A stored media object key parses to its short hash via `media/<aa>/<shortHash>.<ext>`. */
3
- const MEDIA_KEY_RE = /^media\/[0-9a-f]{2}\/([0-9a-f]{16})\.[a-z0-9]{1,5}$/;
2
+ /** A stored media object key parses to its short hash via `media/<aa>/<shortHash>.<ext>`. Exported so
3
+ * the orphan-scan projection derives the same hash from an orphaned key without a second grammar. */
4
+ export const MEDIA_KEY_RE = /^media\/[0-9a-f]{2}\/([0-9a-f]{16})\.[a-z0-9]{1,5}$/;
4
5
  /** The pure core: compare the stored R2 keys against the manifest's content-hash keys and report
5
6
  * both orphan directions. A stored key that does not match the media-key grammar is ignored, since
6
7
  * it is not a content-addressed media object this reconcile owns. */
@@ -86,6 +86,9 @@ export declare function createCairnAdmin(runtime: CairnRuntime, deps?: CairnAdmi
86
86
  mediaReplace: (event: AdminEvent) => Promise<import("@sveltejs/kit").ActionFailure<unknown>>;
87
87
  mediaAltPreview: (event: AdminEvent) => Promise<import("./content-routes.js").MediaAltPreviewPlan | import("@sveltejs/kit").ActionFailure<unknown>>;
88
88
  mediaAltPropagate: (event: AdminEvent) => Promise<import("@sveltejs/kit").ActionFailure<unknown>>;
89
+ mediaBulkDelete: (event: AdminEvent) => Promise<import("./content-routes.js").MediaBulkDeleteResult | import("@sveltejs/kit").ActionFailure<unknown>>;
90
+ mediaOrphanScan: (event: AdminEvent) => Promise<import("../media/orphan-scan.js").OrphanScan | import("@sveltejs/kit").ActionFailure<unknown>>;
91
+ mediaPurge: (event: AdminEvent) => Promise<import("./content-routes.js").MediaOrphanPurgeResult | import("@sveltejs/kit").ActionFailure<unknown>>;
89
92
  publishAll: (event: AdminEvent) => Promise<never>;
90
93
  addEditor: (event: AdminEvent) => Promise<import("@sveltejs/kit").ActionFailure<{
91
94
  error: string;
@@ -134,6 +134,12 @@ export function createCairnAdmin(runtime, deps = {}) {
134
134
  mediaReplace: viewAction(['media'], (event) => content.mediaReplaceApply(contentEvent(event, {}))),
135
135
  mediaAltPreview: viewAction(['media'], (event) => content.mediaAltPreview(contentEvent(event, {}))),
136
136
  mediaAltPropagate: viewAction(['media'], (event) => content.mediaAltApply(contentEvent(event, {}))),
137
+ // Pass C library actions: a multi-select bulk delete, the on-demand orphan scan, and the
138
+ // irreversible byte purge. The component posts to `?/mediaBulkDelete`, `?/mediaOrphanScan`, and
139
+ // `?/mediaPurge` (the purge key is short of its content method name). All gate on the media view.
140
+ mediaBulkDelete: viewAction(['media'], (event) => content.mediaBulkDelete(contentEvent(event, {}))),
141
+ mediaOrphanScan: viewAction(['media'], (event) => content.mediaOrphanScan(contentEvent(event, {}))),
142
+ mediaPurge: viewAction(['media'], (event) => content.mediaPurgeOrphans(contentEvent(event, {}))),
137
143
  publishAll: viewAction(authedViews, (event) => content.publishAllAction(contentEvent(event, {}))),
138
144
  addEditor: viewAction(['editors'], (event) => editors.addEditorAction(event)),
139
145
  removeEditor: viewAction(['editors'], (event) => editors.removeEditorAction(event)),
@@ -4,8 +4,10 @@ import { type LinkTarget, type InboundLink } from '../content/manifest.js';
4
4
  import type { MediaEntry } from '../media/manifest.js';
5
5
  import type { MediaLibrary, MediaLibraryEntry } from '../media/library-entry.js';
6
6
  import type { UsageEntry } from '../media/usage.js';
7
+ import { type OrphanScan } from '../media/orphan-scan.js';
7
8
  import type { RepointPlacement, AltPlacement } from '../content/media-rewrite.js';
8
9
  import type { BranchRef } from '../media/rewrite-plan.js';
10
+ import type { BulkDeleteSkip } from '../media/bulk-delete-plan.js';
9
11
  import type { CookieJar, EventBase } from './types.js';
10
12
  import type { CairnRuntime, FrontmatterField, ResolvedPreview } from '../content/types.js';
11
13
  import type { Role } from '../auth/types.js';
@@ -135,9 +137,10 @@ export interface MediaLibraryData {
135
137
  * redirected commit conflict never overwrite each other. */
136
138
  error: string | null;
137
139
  /** The success flash a redirected action carries: `deleted` from `?deleted=1`, `updated` from
138
- * `?updated=1`, `replaced` from `?replaced=1`, `altPropagated` from `?altPropagated=1`, null
139
- * otherwise. The component renders a polite success strip for each. */
140
- flash: 'deleted' | 'updated' | 'replaced' | 'altPropagated' | null;
140
+ * `?updated=1`, `replaced` from `?replaced=1`, `altPropagated` from `?altPropagated=1`,
141
+ * `bulkDeleted` from `?bulkDeleted=1`, `orphansPurged` from `?orphansPurged=1`, null otherwise.
142
+ * The component renders a polite success strip for each. */
143
+ flash: 'deleted' | 'updated' | 'replaced' | 'altPropagated' | 'bulkDeleted' | 'orphansPurged' | null;
141
144
  /** A redirected action's conflict error read from `?error=` (a commit-conflict bounce). Kept in
142
145
  * its own slot rather than the degraded-load `error` above, so the two never collide. */
143
146
  flashError: string | null;
@@ -212,6 +215,33 @@ export interface MediaReplaceFailure {
212
215
  export interface MediaAltPropagateFailure {
213
216
  error: string;
214
217
  }
218
+ /** A refused media bulk delete or orphan purge: `fail(503)` for the fail-closed strict-usage refusal
219
+ * (the whole batch refuses) or media-off / a missing bucket binding. The per-item outcomes ride the
220
+ * returned summary, not a fail. */
221
+ export interface MediaBulkFailure {
222
+ error: string;
223
+ }
224
+ /** The bulk-delete outcome the component renders: the deleted hashes, the skipped rows from the
225
+ * partition (with their reason and where-used), and any per-object R2 delete failure. Admin-internal,
226
+ * not on the package subpath, so no reference page. */
227
+ export interface MediaBulkDeleteResult {
228
+ deleted: string[];
229
+ skipped: BulkDeleteSkip[];
230
+ failed: {
231
+ hash: string;
232
+ error: string;
233
+ }[];
234
+ }
235
+ /** The orphan-purge outcome: the purged R2 keys, the keys skipped because their hash was claimed by a
236
+ * manifest row since the scan, and any per-object delete failure. Admin-internal, no reference page. */
237
+ export interface MediaOrphanPurgeResult {
238
+ purged: string[];
239
+ skippedClaimed: string[];
240
+ failed: {
241
+ key: string;
242
+ error: string;
243
+ }[];
244
+ }
215
245
  /** One entry the replace preview will rewrite, enriched with its display title and permalink from the
216
246
  * content manifest (the planner's PlannedEntry carries neither). The screen lists these as the
217
247
  * confirm dialog's where-touched preview, and the apply re-derives its own plan rather than trusting
@@ -275,7 +305,7 @@ export interface MediaAltPreviewPlan {
275
305
  * keys identify which guard refused. The media refusals ride here too, so the Media Library's one
276
306
  * `form` prop carries a `?/mediaDelete`, `?/mediaUpdate`, `?/mediaReplace`, or `?/mediaAltPropagate`
277
307
  * refusal without a second type. */
278
- export type ContentFormFailure = Partial<SaveFailure & DeleteRefusal & RenameFailure & MediaDeleteRefusal & MediaUpdateFailure & MediaReplaceFailure & MediaAltPropagateFailure>;
308
+ export type ContentFormFailure = Partial<SaveFailure & DeleteRefusal & RenameFailure & MediaDeleteRefusal & MediaUpdateFailure & MediaReplaceFailure & MediaAltPropagateFailure & MediaBulkFailure>;
279
309
  /** The successful upload's response (`uploadAction`). The server-owned `record` rides the editor's
280
310
  * optimistic client state and commits with the entry at Save (the upload itself commits nothing).
281
311
  * `reused` is true when identical bytes were already stored, so the second upload did no second put;
@@ -302,6 +332,9 @@ export declare function createContentRoutes(runtime: CairnRuntime, deps?: Conten
302
332
  renameAction: (event: ContentEvent) => Promise<ReturnType<typeof fail> | never>;
303
333
  uploadAction: (event: ContentEvent) => Promise<ReturnType<typeof fail> | UploadResult>;
304
334
  mediaDeleteAction: (event: ContentEvent) => Promise<ReturnType<typeof fail> | never>;
335
+ mediaBulkDelete: (event: ContentEvent) => Promise<ReturnType<typeof fail> | MediaBulkDeleteResult>;
336
+ mediaOrphanScan: (event: ContentEvent) => Promise<ReturnType<typeof fail> | OrphanScan>;
337
+ mediaPurgeOrphans: (event: ContentEvent) => Promise<ReturnType<typeof fail> | MediaOrphanPurgeResult>;
305
338
  mediaUpdateAction: (event: ContentEvent) => Promise<ReturnType<typeof fail> | never>;
306
339
  mediaReplacePreview: (event: ContentEvent) => Promise<ReturnType<typeof fail> | MediaReplacePreviewPlan>;
307
340
  mediaReplaceApply: (event: ContentEvent) => Promise<ReturnType<typeof fail> | never>;