@stacksjs/defaults 0.75.40 → 0.75.42
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.
- package/ai/skills/stacks-actions/SKILL.md +1 -1
- package/ai/skills/stacks-auto-imports/SKILL.md +1 -1
- package/ai/skills/stacks-chat/SKILL.md +43 -1
- package/ai/skills/stacks-cloud/SKILL.md +36 -0
- package/ai/skills/stacks-dashboard/SKILL.md +16 -2
- package/ai/skills/stacks-database/SKILL.md +24 -3
- package/ai/skills/stacks-deploy/SKILL.md +7 -1
- package/ai/skills/stacks-desktop/SKILL.md +22 -0
- package/ai/skills/stacks-development/SKILL.md +1 -1
- package/ai/skills/stacks-migrations/SKILL.md +2 -2
- package/ai/skills/stacks-models/SKILL.md +38 -2
- package/ai/skills/stacks-orm/SKILL.md +2 -2
- package/ai/skills/stacks-security/SKILL.md +28 -1
- package/app/Actions/Auth/DataExportAction.test.ts +84 -0
- package/app/Actions/Auth/DataExportAction.ts +40 -0
- package/app/Actions/Dashboard/Content/FileReprocessAction.ts +1 -1
- package/app/Actions/Dashboard/Content/file-manager.ts +120 -8
- package/app/Actions/Dashboard/Content/file-metadata.ts +108 -12
- package/app/Actions/Dashboard/Content/file-pipeline.test.ts +13 -9
- package/app/Actions/Dashboard/Content/file-preview.test.ts +487 -0
- package/app/Actions/Dashboard/Content/file-preview.ts +548 -0
- package/app/Actions/Payment/CreatePaymentIntentAction.ts +1 -1
- package/app/Actions/Payment/CreateSetupIntentAction.ts +1 -1
- package/app/Jobs/GenerateStoragePreviewJob.ts +47 -0
- package/app/Jobs/OptimizeStorageImageJob.ts +7 -3
- package/app/Jobs/PruneRetainedDataJob.ts +46 -0
- package/app/Models/Activity.ts +2 -0
- package/app/Models/CampaignSend.ts +2 -0
- package/app/Models/Card.ts +1 -0
- package/app/Models/CardComment.ts +1 -0
- package/app/Models/Comment.ts +5 -0
- package/app/Models/CommunicationSuppression.ts +2 -1
- package/app/Models/ConsentEvent.ts +4 -3
- package/app/Models/Content/Author.ts +5 -0
- package/app/Models/EmailListSubscriber.ts +1 -0
- package/app/Models/EmailSuppression.ts +2 -0
- package/app/Models/Forms/FormSubmission.ts +5 -0
- package/app/Models/GdprRequest.ts +77 -0
- package/app/Models/MagicLinkToken.ts +2 -0
- package/app/Models/Notification.ts +2 -0
- package/app/Models/NotificationDelivery.ts +4 -0
- package/app/Models/PaymentMethod.ts +5 -0
- package/app/Models/PaymentTransaction.ts +1 -0
- package/app/Models/PersonalAccessToken.ts +3 -0
- package/app/Models/Referral.ts +4 -1
- package/app/Models/ReferralCode.ts +4 -1
- package/app/Models/Request.ts +3 -0
- package/app/Models/SmsOptOut.ts +2 -0
- package/app/Models/SocialAccount.ts +3 -0
- package/app/Models/SocialPost.ts +2 -0
- package/app/Models/StorageItemTask.ts +14 -8
- package/app/Models/Subscriber.ts +2 -0
- package/app/Models/SubscriberEmail.ts +2 -0
- package/app/Models/Subscription.ts +1 -0
- package/app/Models/TeamInvitation.ts +2 -0
- package/app/Models/TeamMember.ts +1 -0
- package/app/Models/User.ts +5 -0
- package/app/Models/commerce/Bid.ts +3 -0
- package/app/Models/commerce/Cart.ts +2 -0
- package/app/Models/commerce/Courier.ts +7 -0
- package/app/Models/commerce/CourierPing.ts +3 -0
- package/app/Models/commerce/Customer.ts +5 -0
- package/app/Models/commerce/DeliveryStop.ts +7 -0
- package/app/Models/commerce/GiftCard.ts +4 -0
- package/app/Models/commerce/Order.ts +5 -0
- package/app/Models/commerce/OrderItem.ts +2 -0
- package/app/Models/commerce/Payment.ts +5 -0
- package/app/Models/commerce/Pledge.ts +3 -0
- package/app/Models/commerce/Transaction.ts +2 -0
- package/app/Models/commerce/WaitlistProduct.ts +5 -0
- package/app/Models/commerce/WaitlistRestaurant.ts +4 -0
- package/ide/vscode/.vscode/extensions.json +1 -1
- package/ide/vscode/.vscode/settings.json +8 -149
- package/ide/vscode/README.md +92 -22
- package/ide/vscode/build.ts +43 -0
- package/ide/vscode/package.json +368 -29
- package/ide/vscode/scripts/stx-contributes.ts +66 -0
- package/ide/vscode/src/env-file.ts +103 -0
- package/ide/vscode/src/env.ts +241 -0
- package/ide/vscode/src/extension.ts +153 -0
- package/ide/vscode/src/pickier-protocol.ts +51 -0
- package/ide/vscode/src/pickier-worker.ts +92 -0
- package/ide/vscode/src/pickier.ts +433 -0
- package/ide/vscode/src/project.ts +450 -0
- package/ide/vscode/src/stacks.ts +482 -0
- package/ide/vscode/tests/build.test.ts +58 -0
- package/ide/vscode/tests/env.test.ts +166 -0
- package/ide/vscode/tests/extension.test.ts +428 -0
- package/ide/vscode/tests/fake-vscode.ts +228 -0
- package/ide/vscode/tests/manifest.test.ts +88 -0
- package/ide/vscode/tests/pickier.test.ts +202 -0
- package/ide/vscode/tests/project.test.ts +268 -0
- package/ide/vscode/tsconfig.json +19 -0
- package/package.json +4 -4
- package/resources/components/Dashboard/Content/FileManagerDashboard.stx +2 -0
- package/resources/components/Dashboard/FileViewer/MediaCard.stx +14 -3
- package/routes/auth.ts +4 -0
- package/vcs/github/workflows/buddy-bot.yml +2 -2
- package/vcs/github/workflows/ci.yml +3 -3
- package/vcs/github/workflows/release.yml +1 -1
|
@@ -19,8 +19,10 @@ import {
|
|
|
19
19
|
STORAGE_TASK_KINDS,
|
|
20
20
|
sweepMetadata,
|
|
21
21
|
tasksForContentType,
|
|
22
|
+
variantsPathFor,
|
|
22
23
|
} from './file-metadata'
|
|
23
24
|
import { databaseMetadataStore } from './file-metadata-store'
|
|
25
|
+
import { claimedContentType, hasPreview, previewPathFor, previewPlanFor, previewUrlFor } from './file-preview'
|
|
24
26
|
|
|
25
27
|
const DEFAULT_DISK = 'public'
|
|
26
28
|
const DEFAULT_MAX_ENTRIES = 1000
|
|
@@ -41,6 +43,20 @@ export interface DashboardFileNode {
|
|
|
41
43
|
mime_type?: string
|
|
42
44
|
url?: string
|
|
43
45
|
thumbnail?: string
|
|
46
|
+
/**
|
|
47
|
+
* The URL of this file's generated preview image (stacksjs/stacks#308), when
|
|
48
|
+
* one was made and the disk can serve it. Absent for a file with no preview,
|
|
49
|
+
* and for every file on a private disk - which has no URL for the original
|
|
50
|
+
* either.
|
|
51
|
+
*/
|
|
52
|
+
preview?: string
|
|
53
|
+
/**
|
|
54
|
+
* The first lines of a text file, as plain text, when its preview was made.
|
|
55
|
+
* Text gets a snippet rather than an image: drawing text needs a font, and a
|
|
56
|
+
* card can show twelve lines as text. Read on any disk, private included,
|
|
57
|
+
* because the server reads it rather than the browser.
|
|
58
|
+
*/
|
|
59
|
+
previewText?: string
|
|
44
60
|
/**
|
|
45
61
|
* Whether somebody starred this, from `storage_items` rather than from the
|
|
46
62
|
* disk (stacksjs/stacks#2577). It was the literal `false` for as long as
|
|
@@ -372,6 +388,7 @@ export async function getDashboardFileSnapshot(
|
|
|
372
388
|
items: [],
|
|
373
389
|
}
|
|
374
390
|
const folders = new Map<string, DashboardFileNode>([['', root]])
|
|
391
|
+
const previewed: DashboardFileNode[] = []
|
|
375
392
|
const capacity = await diskCapacity(selected.name, selected.config)
|
|
376
393
|
const stats: DashboardFileStats = {
|
|
377
394
|
files: 0,
|
|
@@ -444,12 +461,37 @@ export async function getDashboardFileSnapshot(
|
|
|
444
461
|
...pick(entry.path),
|
|
445
462
|
shared: selected.public,
|
|
446
463
|
}
|
|
464
|
+
if (hasPreview(file.tasks))
|
|
465
|
+
previewed.push(file)
|
|
447
466
|
parent.items!.push(file)
|
|
448
467
|
stats.files++
|
|
449
468
|
stats.contentBytes += entry.size
|
|
450
469
|
stats.byType[categoryFor(entry)] += entry.size
|
|
451
470
|
}
|
|
452
471
|
|
|
472
|
+
// Previews resolve after the walk, in the same batches as the stats: an image
|
|
473
|
+
// preview is a URL and costs nothing, but a text preview is a read, and a
|
|
474
|
+
// folder of markdown is a read per file (stacksjs/stacks#308).
|
|
475
|
+
await mapInBatches(previewed, STAT_CONCURRENCY, async (file) => {
|
|
476
|
+
const plan = previewPlanFor(claimedContentType(file.path, file.mime_type))
|
|
477
|
+
if (!plan || !('renderer' in plan))
|
|
478
|
+
return
|
|
479
|
+
if (plan.format === 'png') {
|
|
480
|
+
file.preview = await previewUrlFor(selected.adapter, file.path, {
|
|
481
|
+
public: selected.public,
|
|
482
|
+
servesProjectPublic: selected.name === 'public' && selected.config.driver === 'local',
|
|
483
|
+
})
|
|
484
|
+
return
|
|
485
|
+
}
|
|
486
|
+
try {
|
|
487
|
+
file.previewText = await selected.adapter.readToString(previewPathFor(file.path, 'txt'))
|
|
488
|
+
}
|
|
489
|
+
catch {
|
|
490
|
+
// A snippet that has gone missing is a file without a preview, not a
|
|
491
|
+
// listing that fails. Reprocessing makes it again.
|
|
492
|
+
}
|
|
493
|
+
})
|
|
494
|
+
|
|
453
495
|
for (const folder of folders.values()) {
|
|
454
496
|
folder.items!.sort((a, b) => {
|
|
455
497
|
if ((a.type === 'folder') !== (b.type === 'folder'))
|
|
@@ -521,12 +563,14 @@ export async function deleteDashboardFile(
|
|
|
521
563
|
if (await selected.adapter.fileExists(path)) {
|
|
522
564
|
await selected.adapter.deleteFile(path)
|
|
523
565
|
await followDelete(store, selected.name, path)
|
|
566
|
+
await deleteVariants(selected.adapter, path)
|
|
524
567
|
return { path, type: 'file' }
|
|
525
568
|
}
|
|
526
569
|
if (await selected.adapter.directoryExists(path)) {
|
|
527
570
|
await selected.adapter.deleteDirectory(path)
|
|
528
571
|
// The subtree, because deleting a folder deletes everything under it.
|
|
529
572
|
await followDelete(store, selected.name, path)
|
|
573
|
+
await deleteVariants(selected.adapter, path)
|
|
530
574
|
return { path, type: 'directory' }
|
|
531
575
|
}
|
|
532
576
|
|
|
@@ -573,6 +617,7 @@ export async function renameDashboardFile(
|
|
|
573
617
|
// After the move, so a failed move does not leave the metadata describing a
|
|
574
618
|
// path with no file at it.
|
|
575
619
|
await followRename(store, selected.name, from, to)
|
|
620
|
+
await moveVariants(selected.adapter, from, to)
|
|
576
621
|
return { from, to, type: 'file', moved: 1 }
|
|
577
622
|
}
|
|
578
623
|
|
|
@@ -603,6 +648,7 @@ export async function renameDashboardFile(
|
|
|
603
648
|
// A folder rename moves every file beneath it, so this is a prefix update
|
|
604
649
|
// rather than one row - the part #2577 flagged as the one that would bite.
|
|
605
650
|
await followRename(store, selected.name, from, to)
|
|
651
|
+
await moveVariants(selected.adapter, from, to)
|
|
606
652
|
|
|
607
653
|
return { from, to, type: 'directory', moved: files.length }
|
|
608
654
|
}
|
|
@@ -824,6 +870,54 @@ export async function setDashboardFileTags(
|
|
|
824
870
|
return { path, favorite: record.favorite, tags: record.tags }
|
|
825
871
|
}
|
|
826
872
|
|
|
873
|
+
/**
|
|
874
|
+
* Move a path's derivatives with it (stacksjs/stacks#308).
|
|
875
|
+
*
|
|
876
|
+
* The task rows already follow a rename, so without this a finished preview
|
|
877
|
+
* would report `done` at the new path while its file sat under the old one.
|
|
878
|
+
* The variants folder mirrors the original's path, so a folder rename is a
|
|
879
|
+
* prefix move here too.
|
|
880
|
+
*
|
|
881
|
+
* Best effort, after the rename has succeeded: the rename is what was asked
|
|
882
|
+
* for, and a derivative that failed to follow is regenerated by reprocessing
|
|
883
|
+
* rather than worth failing the request over.
|
|
884
|
+
*/
|
|
885
|
+
async function moveVariants(adapter: StorageAdapter, from: string, to: string): Promise<void> {
|
|
886
|
+
const source = variantsPathFor(from)
|
|
887
|
+
const target = variantsPathFor(to)
|
|
888
|
+
|
|
889
|
+
try {
|
|
890
|
+
if (!(await adapter.directoryExists(source)))
|
|
891
|
+
return
|
|
892
|
+
|
|
893
|
+
const files: string[] = []
|
|
894
|
+
for await (const entry of adapter.list(source, { deep: true })) {
|
|
895
|
+
const listed = normalizeListedPath(String(entry.path))
|
|
896
|
+
if (entry.type === 'file' && listed)
|
|
897
|
+
files.push(listed.startsWith(`${source}/`) ? listed : `${source}/${listed}`)
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
for (const file of files)
|
|
901
|
+
await adapter.moveFile(file, `${target}/${file.slice(source.length + 1)}`)
|
|
902
|
+
|
|
903
|
+
await adapter.deleteDirectory(source)
|
|
904
|
+
}
|
|
905
|
+
catch {
|
|
906
|
+
// See above: the rename succeeded, and that is the answer the caller gets.
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/** Remove a path's derivatives once the path itself is gone. Best effort, like {@link moveVariants}. */
|
|
911
|
+
async function deleteVariants(adapter: StorageAdapter, path: string): Promise<void> {
|
|
912
|
+
try {
|
|
913
|
+
if (await adapter.directoryExists(variantsPathFor(path)))
|
|
914
|
+
await adapter.deleteDirectory(variantsPathFor(path))
|
|
915
|
+
}
|
|
916
|
+
catch {
|
|
917
|
+
// An orphaned derivative is hidden from the listing and costs only space.
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
|
|
827
921
|
/** 404 unless the path is a file or a folder on the disk. */
|
|
828
922
|
async function assertExists(adapter: StorageAdapter, path: string): Promise<void> {
|
|
829
923
|
if (await adapter.fileExists(path) || await adapter.directoryExists(path))
|
|
@@ -853,6 +947,7 @@ const TASK_JOBS = {
|
|
|
853
947
|
optimize: 'OptimizeStorageImageJob',
|
|
854
948
|
transcode: 'TranscodeStorageVideoJob',
|
|
855
949
|
tag: 'TagStorageMediaJob',
|
|
950
|
+
preview: 'GenerateStoragePreviewJob',
|
|
856
951
|
} as const satisfies Record<StorageTaskKind, JobName>
|
|
857
952
|
|
|
858
953
|
const queueDispatcher: TaskDispatcher = async (job, payload) => {
|
|
@@ -872,6 +967,12 @@ const queueDispatcher: TaskDispatcher = async (job, payload) => {
|
|
|
872
967
|
* nobody asked for. The dashboard knows them from the upload; a caller that
|
|
873
968
|
* does not can dispatch the transcode later through
|
|
874
969
|
* {@link reprocessDashboardFile}.
|
|
970
|
+
*
|
|
971
|
+
* A preview is decided separately, by `file-preview.ts`, and from the type the
|
|
972
|
+
* file CLAIMS - the reported type, or the extension when the disk reports
|
|
973
|
+
* nothing useful - because far more kinds have an answer there (#308). A kind
|
|
974
|
+
* with no renderer here is recorded `skipped` with its reason and never
|
|
975
|
+
* queued.
|
|
875
976
|
*/
|
|
876
977
|
export async function dispatchDashboardFileTasks(
|
|
877
978
|
input: { disk?: string, path: unknown, contentType?: string, videoProfile?: Record<string, unknown>, only?: readonly StorageTaskKind[] },
|
|
@@ -882,7 +983,11 @@ export async function dispatchDashboardFileTasks(
|
|
|
882
983
|
const selected = resolveDisk(manager, input.disk)
|
|
883
984
|
const path = normalizeDashboardFilePath(input.path)
|
|
884
985
|
|
|
885
|
-
const
|
|
986
|
+
const preview = previewPlanFor(claimedContentType(path, input.contentType))
|
|
987
|
+
const applicable: StorageTaskKind[] = [
|
|
988
|
+
...tasksForContentType(input.contentType),
|
|
989
|
+
...(preview ? ['preview' as const] : []),
|
|
990
|
+
]
|
|
886
991
|
const wanted = input.only
|
|
887
992
|
? applicable.filter(kind => input.only!.includes(kind))
|
|
888
993
|
: applicable
|
|
@@ -891,13 +996,20 @@ export async function dispatchDashboardFileTasks(
|
|
|
891
996
|
if (runnable.length === 0)
|
|
892
997
|
return []
|
|
893
998
|
|
|
894
|
-
return await dispatchTasks(
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
999
|
+
return await dispatchTasks(
|
|
1000
|
+
store,
|
|
1001
|
+
selected.name,
|
|
1002
|
+
path,
|
|
1003
|
+
runnable,
|
|
1004
|
+
async (kind) => {
|
|
1005
|
+
await dispatch(TASK_JOBS[kind], {
|
|
1006
|
+
disk: selected.name,
|
|
1007
|
+
path,
|
|
1008
|
+
...(kind === 'transcode' ? { profile: input.videoProfile } : {}),
|
|
1009
|
+
})
|
|
1010
|
+
},
|
|
1011
|
+
kind => kind === 'preview' && preview && 'skip' in preview ? preview.skip : undefined,
|
|
1012
|
+
)
|
|
901
1013
|
}
|
|
902
1014
|
|
|
903
1015
|
/**
|
|
@@ -42,28 +42,79 @@ export interface StorageItemMetadata {
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
/**
|
|
45
|
-
* The
|
|
45
|
+
* The kinds of background work a file can carry (stacksjs/stacks#2578, #308).
|
|
46
46
|
*
|
|
47
47
|
* `optimize` builds the image variants, `transcode` the mp4 and HLS renditions,
|
|
48
|
-
* `tag` asks a vision model what is in the file
|
|
49
|
-
*
|
|
48
|
+
* `tag` asks a vision model what is in the file, `preview` renders the small
|
|
49
|
+
* image a file manager shows in place of a generic icon. Independent: a video
|
|
50
|
+
* whose transcode finished and whose tagging failed is a normal state.
|
|
50
51
|
*/
|
|
51
|
-
export type StorageTaskKind = 'optimize' | 'transcode' | 'tag'
|
|
52
|
+
export type StorageTaskKind = 'optimize' | 'transcode' | 'tag' | 'preview'
|
|
52
53
|
|
|
53
|
-
|
|
54
|
+
/**
|
|
55
|
+
* Where one task is.
|
|
56
|
+
*
|
|
57
|
+
* `skipped` is not a failure and not a success: the work does not apply to this
|
|
58
|
+
* file HERE - a PDF with no homegrown renderer to draw it, an SVG that is not
|
|
59
|
+
* rasterized because it is markup somebody else wrote. It is recorded rather
|
|
60
|
+
* than left absent so a file manager can say why a file has no preview, and it
|
|
61
|
+
* is terminal, so the queue does not retry something that cannot succeed.
|
|
62
|
+
*/
|
|
63
|
+
export type StorageTaskState = 'queued' | 'running' | 'done' | 'failed' | 'skipped'
|
|
54
64
|
|
|
55
65
|
/** One unit of background work, as the dashboard sees it. */
|
|
56
66
|
export interface StorageItemTask {
|
|
57
67
|
kind: StorageTaskKind
|
|
58
68
|
state: StorageTaskState
|
|
59
69
|
attempts: number
|
|
70
|
+
/** Why a `failed` task failed, or why a `skipped` one did not apply. */
|
|
60
71
|
error?: string
|
|
61
72
|
startedAt?: string
|
|
62
73
|
finishedAt?: string
|
|
63
74
|
}
|
|
64
75
|
|
|
65
76
|
/** Every task kind, in the order a file would run them. */
|
|
66
|
-
export const STORAGE_TASK_KINDS: readonly StorageTaskKind[] = ['optimize', 'transcode', 'tag']
|
|
77
|
+
export const STORAGE_TASK_KINDS: readonly StorageTaskKind[] = ['optimize', 'transcode', 'tag', 'preview']
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Thrown from a task's work to record it as `skipped` rather than `failed`.
|
|
81
|
+
*
|
|
82
|
+
* For the case only the bytes can reveal: a `.png` whose bytes are a PDF, a
|
|
83
|
+
* `.ttf` that turns out to be a compressed WOFF. That is not a fault to retry -
|
|
84
|
+
* a second attempt reads the same bytes - so {@link runTask} records the reason
|
|
85
|
+
* and returns instead of rethrowing.
|
|
86
|
+
*/
|
|
87
|
+
export class StorageTaskSkipped extends Error {
|
|
88
|
+
constructor(reason: string) {
|
|
89
|
+
super(reason)
|
|
90
|
+
this.name = 'StorageTaskSkipped'
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Where derivatives live, relative to the disk root (stacksjs/stacks#2578).
|
|
96
|
+
*
|
|
97
|
+
* Hidden, so the file manager's listing skips it. Defined here rather than in
|
|
98
|
+
* a job, because the request path needs it too - a rename has to move a file's
|
|
99
|
+
* derivatives with it - and importing a job would load its encoder.
|
|
100
|
+
*/
|
|
101
|
+
export const VARIANT_PREFIX = '.variants'
|
|
102
|
+
|
|
103
|
+
/** The folder holding one file's derivatives. */
|
|
104
|
+
export function variantsPathFor(path: string): string {
|
|
105
|
+
return `${VARIANT_PREFIX}/${path}`
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Where a file's preview is stored (stacksjs/stacks#308).
|
|
110
|
+
*
|
|
111
|
+
* Derived from the path rather than recorded in a column, so it cannot go
|
|
112
|
+
* stale: the task row says whether one was made, and this says where. A rename
|
|
113
|
+
* through the dashboard moves the folder it sits in along with the task row.
|
|
114
|
+
*/
|
|
115
|
+
export function previewPathFor(path: string): string {
|
|
116
|
+
return `${variantsPathFor(path)}/preview.png`
|
|
117
|
+
}
|
|
67
118
|
|
|
68
119
|
/** The `taggable_type` these rows use, which keeps them apart from the CMS's. */
|
|
69
120
|
export const STORAGE_ITEM_TYPE = 'storage_items'
|
|
@@ -313,25 +364,40 @@ export async function sweepMetadata(
|
|
|
313
364
|
export function aggregateTaskState(tasks: readonly StorageItemTask[]): StorageTaskState | null {
|
|
314
365
|
if (tasks.length === 0)
|
|
315
366
|
return null
|
|
316
|
-
|
|
367
|
+
// A skipped task says nothing about the work that DID apply, so it does not
|
|
368
|
+
// count towards the word - a photo whose preview was skipped and whose
|
|
369
|
+
// variants finished is done. Only a file where nothing applied reads as
|
|
370
|
+
// skipped, which is still more than `null`: somebody looked.
|
|
371
|
+
const applied = tasks.filter(task => task.state !== 'skipped')
|
|
372
|
+
if (applied.length === 0)
|
|
373
|
+
return 'skipped'
|
|
374
|
+
if (applied.some(task => task.state === 'failed'))
|
|
317
375
|
return 'failed'
|
|
318
|
-
if (
|
|
376
|
+
if (applied.some(task => task.state === 'running'))
|
|
319
377
|
return 'running'
|
|
320
|
-
if (
|
|
378
|
+
if (applied.some(task => task.state === 'queued'))
|
|
321
379
|
return 'queued'
|
|
322
380
|
return 'done'
|
|
323
381
|
}
|
|
324
382
|
|
|
383
|
+
/** The bare, lower-cased MIME type, without parameters. */
|
|
384
|
+
export function bareMime(contentType: string | undefined): string {
|
|
385
|
+
return (contentType ?? '').toLowerCase().split(';')[0]?.trim() ?? ''
|
|
386
|
+
}
|
|
387
|
+
|
|
325
388
|
/**
|
|
326
|
-
* Which kinds of work a file's content type calls for.
|
|
389
|
+
* Which kinds of media work a file's content type calls for.
|
|
327
390
|
*
|
|
328
391
|
* Decided from the MIME type rather than the extension, when one is known: a
|
|
329
392
|
* `.mp4` that is actually a PDF should not be handed to a transcoder. Tagging
|
|
330
393
|
* applies to images and video alike, because a vision model can describe a
|
|
331
394
|
* frame as readily as a photograph.
|
|
395
|
+
*
|
|
396
|
+
* `preview` is not decided here: it applies to far more types than these, and
|
|
397
|
+
* `file-preview.ts` owns which (stacksjs/stacks#308).
|
|
332
398
|
*/
|
|
333
399
|
export function tasksForContentType(contentType: string | undefined): StorageTaskKind[] {
|
|
334
|
-
const mime = (contentType
|
|
400
|
+
const mime = bareMime(contentType)
|
|
335
401
|
|
|
336
402
|
if (mime.startsWith('image/')) {
|
|
337
403
|
// SVG is markup, not a raster: there is nothing to re-encode, and running
|
|
@@ -361,10 +427,24 @@ export async function dispatchTasks(
|
|
|
361
427
|
path: string,
|
|
362
428
|
kinds: readonly StorageTaskKind[],
|
|
363
429
|
dispatch: (kind: StorageTaskKind) => Promise<void>,
|
|
430
|
+
/**
|
|
431
|
+
* The reason a kind does not apply, when that is already known from the
|
|
432
|
+
* content type. Such a kind is recorded `skipped` and never queued: a queue
|
|
433
|
+
* entry that can only find out what the dispatcher already knew is noise.
|
|
434
|
+
*/
|
|
435
|
+
skip: (kind: StorageTaskKind) => string | undefined = () => undefined,
|
|
364
436
|
): Promise<StorageItemTask[]> {
|
|
365
437
|
const queued: StorageItemTask[] = []
|
|
366
438
|
|
|
367
439
|
for (const kind of kinds) {
|
|
440
|
+
const reason = skip(kind)
|
|
441
|
+
if (reason !== undefined) {
|
|
442
|
+
const skipped: StorageItemTask = { kind, state: 'skipped', attempts: 0, error: reason, finishedAt: new Date().toISOString() }
|
|
443
|
+
await store.writeTask(disk, path, skipped)
|
|
444
|
+
queued.push(skipped)
|
|
445
|
+
continue
|
|
446
|
+
}
|
|
447
|
+
|
|
368
448
|
const task: StorageItemTask = { kind, state: 'queued', attempts: 0 }
|
|
369
449
|
await store.writeTask(disk, path, task)
|
|
370
450
|
|
|
@@ -400,6 +480,10 @@ export async function dispatchTasks(
|
|
|
400
480
|
* and applies its backoff. The row and the queue are answering different
|
|
401
481
|
* questions: the queue decides whether to try again, the row is what somebody
|
|
402
482
|
* looking at the file sees.
|
|
483
|
+
*
|
|
484
|
+
* The one exception is {@link StorageTaskSkipped}: recorded as `skipped` with
|
|
485
|
+
* its reason, and NOT rethrown, because a retry would read the same bytes and
|
|
486
|
+
* reach the same answer. The result is `undefined` in that case.
|
|
403
487
|
*/
|
|
404
488
|
export async function runTask<T>(
|
|
405
489
|
store: StorageMetadataStore,
|
|
@@ -407,7 +491,7 @@ export async function runTask<T>(
|
|
|
407
491
|
path: string,
|
|
408
492
|
kind: StorageTaskKind,
|
|
409
493
|
work: () => Promise<T>,
|
|
410
|
-
): Promise<T> {
|
|
494
|
+
): Promise<T | undefined> {
|
|
411
495
|
const previous = (await store.tasksUnder(disk, path)).get(path)?.find(task => task.kind === kind)
|
|
412
496
|
const attempts = (previous?.attempts ?? 0) + 1
|
|
413
497
|
const startedAt = new Date().toISOString()
|
|
@@ -428,6 +512,18 @@ export async function runTask<T>(
|
|
|
428
512
|
return result
|
|
429
513
|
}
|
|
430
514
|
catch (error) {
|
|
515
|
+
if (error instanceof StorageTaskSkipped) {
|
|
516
|
+
await store.writeTask(disk, path, {
|
|
517
|
+
kind,
|
|
518
|
+
state: 'skipped',
|
|
519
|
+
attempts,
|
|
520
|
+
startedAt,
|
|
521
|
+
finishedAt: new Date().toISOString(),
|
|
522
|
+
error: describeError(error),
|
|
523
|
+
})
|
|
524
|
+
return undefined
|
|
525
|
+
}
|
|
526
|
+
|
|
431
527
|
await store.writeTask(disk, path, {
|
|
432
528
|
kind,
|
|
433
529
|
state: 'failed',
|
|
@@ -127,8 +127,9 @@ describe('dispatchDashboardFileTasks', () => {
|
|
|
127
127
|
dispatch,
|
|
128
128
|
)
|
|
129
129
|
|
|
130
|
-
|
|
131
|
-
expect(
|
|
130
|
+
// Plus the preview, which `file-preview.test.ts` covers (stacksjs/stacks#308).
|
|
131
|
+
expect(tasks.map(task => task.kind)).toEqual(['optimize', 'tag', 'preview'])
|
|
132
|
+
expect(dispatched.map(entry => entry.job)).toEqual(['OptimizeStorageImageJob', 'TagStorageMediaJob', 'GenerateStoragePreviewJob'])
|
|
132
133
|
expect(dispatched[0]?.payload).toEqual({ disk: 'public', path: 'photo.png' })
|
|
133
134
|
|
|
134
135
|
const recorded = (await store.tasksUnder('public', '')).get('photo.png')
|
|
@@ -136,9 +137,11 @@ describe('dispatchDashboardFileTasks', () => {
|
|
|
136
137
|
})
|
|
137
138
|
|
|
138
139
|
test('dispatches nothing for a content type with no work', async () => {
|
|
139
|
-
|
|
140
|
+
// An archive rather than a PDF since #308: a PDF now records a skipped
|
|
141
|
+
// preview, which is a row but still not a queue entry.
|
|
142
|
+
await manager.disk('public').write('bundle.zip', 'x')
|
|
140
143
|
|
|
141
|
-
expect(await dispatchDashboardFileTasks({ path: '
|
|
144
|
+
expect(await dispatchDashboardFileTasks({ path: 'bundle.zip', contentType: 'application/zip' }, manager, store, dispatch)).toEqual([])
|
|
142
145
|
expect(dispatched).toEqual([])
|
|
143
146
|
expect(await store.tasksUnder('public', '')).toEqual(new Map())
|
|
144
147
|
})
|
|
@@ -149,7 +152,8 @@ describe('dispatchDashboardFileTasks', () => {
|
|
|
149
152
|
await manager.disk('public').write('clip.mp4', 'x')
|
|
150
153
|
|
|
151
154
|
const withoutProfile = await dispatchDashboardFileTasks({ path: 'clip.mp4', contentType: 'video/mp4' }, manager, store, dispatch)
|
|
152
|
-
|
|
155
|
+
// The preview is recorded skipped: nothing here decodes a video frame.
|
|
156
|
+
expect(withoutProfile.map(task => [task.kind, task.state])).toEqual([['tag', 'queued'], ['preview', 'skipped']])
|
|
153
157
|
|
|
154
158
|
dispatched = []
|
|
155
159
|
const withProfile = await dispatchDashboardFileTasks(
|
|
@@ -158,7 +162,7 @@ describe('dispatchDashboardFileTasks', () => {
|
|
|
158
162
|
store,
|
|
159
163
|
dispatch,
|
|
160
164
|
)
|
|
161
|
-
expect(withProfile.map(task => task.kind)).toEqual(['transcode', 'tag'])
|
|
165
|
+
expect(withProfile.map(task => task.kind)).toEqual(['transcode', 'tag', 'preview'])
|
|
162
166
|
expect(dispatched[0]?.payload.profile).toEqual({ width: 1920, height: 1080 })
|
|
163
167
|
})
|
|
164
168
|
|
|
@@ -251,7 +255,7 @@ describe('the snapshot reports processing', () => {
|
|
|
251
255
|
const node = nodeAt(snapshot.root, 'photo.png')
|
|
252
256
|
|
|
253
257
|
expect(node?.processing).toBe('queued')
|
|
254
|
-
expect(node?.tasks.map(task => task.kind)).toEqual(['optimize', 'tag'])
|
|
258
|
+
expect(node?.tasks.map(task => task.kind)).toEqual(['optimize', 'tag', 'preview'])
|
|
255
259
|
})
|
|
256
260
|
|
|
257
261
|
test('a file nobody processed reports null, not done', async () => {
|
|
@@ -309,7 +313,7 @@ describe('reprocessDashboardFile', () => {
|
|
|
309
313
|
|
|
310
314
|
const result = await reprocessDashboardFile({ path: 'photo.png' }, manager, store, dispatch)
|
|
311
315
|
|
|
312
|
-
expect(result.tasks.map(task => task.kind)).toEqual(['optimize', 'tag'])
|
|
316
|
+
expect(result.tasks.map(task => task.kind)).toEqual(['optimize', 'tag', 'preview'])
|
|
313
317
|
// The failed task is reset to queued rather than left showing its old
|
|
314
318
|
// error beside a job that is about to run.
|
|
315
319
|
const optimize = (await store.tasksUnder('public', '')).get('photo.png')?.find(task => task.kind === 'optimize')
|
|
@@ -333,7 +337,7 @@ describe('reprocessDashboardFile', () => {
|
|
|
333
337
|
.rejects.toThrow(/was not found/)
|
|
334
338
|
})
|
|
335
339
|
|
|
336
|
-
test('rejects a kind that is not one of the
|
|
340
|
+
test('rejects a kind that is not one of the four', async () => {
|
|
337
341
|
await manager.disk('public').write('photo.png', 'x')
|
|
338
342
|
|
|
339
343
|
expect(reprocessDashboardFile({ path: 'photo.png', kinds: ['reticulate'] }, manager, store, dispatch))
|