@jhb.software/payload-alt-text-plugin 0.11.0 → 0.12.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.
- package/README.md +82 -39
- package/dist/components/BulkGenerateAltTextsButton.js +3 -16
- package/dist/components/BulkGenerateAltTextsButton.js.map +1 -1
- package/dist/components/summarizeBulkGenerate.d.ts +26 -0
- package/dist/components/summarizeBulkGenerate.js +56 -0
- package/dist/components/summarizeBulkGenerate.js.map +1 -0
- package/dist/endpoints/bulkGenerateAltTexts.d.ts +18 -1
- package/dist/endpoints/bulkGenerateAltTexts.js +31 -12
- package/dist/endpoints/bulkGenerateAltTexts.js.map +1 -1
- package/dist/endpoints/generateAltText.js +14 -8
- package/dist/endpoints/generateAltText.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js.map +1 -1
- package/dist/plugin.js +1 -0
- package/dist/plugin.js.map +1 -1
- package/dist/resolvers/anthropic.d.ts +3 -4
- package/dist/resolvers/anthropic.js +3 -4
- package/dist/resolvers/anthropic.js.map +1 -1
- package/dist/resolvers/createVisionResolver.d.ts +1 -1
- package/dist/resolvers/createVisionResolver.js.map +1 -1
- package/dist/resolvers/mistral.d.ts +4 -3
- package/dist/resolvers/mistral.js +4 -3
- package/dist/resolvers/mistral.js.map +1 -1
- package/dist/resolvers/openAI.d.ts +3 -3
- package/dist/resolvers/openAI.js +3 -3
- package/dist/resolvers/openAI.js.map +1 -1
- package/dist/translations/de.js +12 -4
- package/dist/translations/de.js.map +1 -1
- package/dist/translations/en.js +12 -4
- package/dist/translations/en.js.map +1 -1
- package/dist/translations/translation-schema.json +24 -8
- package/dist/types/AltTextPluginConfig.d.ts +24 -1
- package/dist/types/AltTextPluginConfig.js.map +1 -1
- package/dist/utilities/altTextHealth.d.ts +1 -1
- package/dist/utilities/altTextHealth.js +29 -4
- package/dist/utilities/altTextHealth.js.map +1 -1
- package/dist/utilities/resolveLocales.d.ts +15 -0
- package/dist/utilities/resolveLocales.js +38 -0
- package/dist/utilities/resolveLocales.js.map +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Image Alt Text Generation Plugin for Payload CMS
|
|
2
2
|
|
|
3
|
-
A [Payload CMS](https://payloadcms.com/) plugin that adds AI-powered alt text generation for images.
|
|
3
|
+
A [Payload CMS](https://payloadcms.com/) plugin that adds AI-powered alt text generation for images. It automatically adds an alt text field with a button to generate the alt text to specified upload collections, and includes a bulk generation feature in the list view for processing multiple images at once.
|
|
4
4
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
@@ -8,7 +8,7 @@ A [Payload CMS](https://payloadcms.com/) plugin that adds AI-powered alt text ge
|
|
|
8
8
|
- Supports any AI provider using a resolver pattern (e.g., OpenAI, Anthropic, etc.)
|
|
9
9
|
- Comes with ready-to-use OpenAI, Anthropic and Mistral resolvers out of the box
|
|
10
10
|
- Automatic keyword extraction for improved admin search
|
|
11
|
-
- Bulk generation
|
|
11
|
+
- Bulk generation from the collection list view
|
|
12
12
|
- Full localization support
|
|
13
13
|
- Dashboard health widget with cached coverage insights across all configured upload collections
|
|
14
14
|
- Multi-tenant aware: the health report can be scoped to the tenant the request is for
|
|
@@ -22,7 +22,6 @@ When the plugin is enabled for an upload collection, it will:
|
|
|
22
22
|
- This field will be automatically filled when generating the alt text
|
|
23
23
|
- It will be used for improving the search of images in the admin panel
|
|
24
24
|
3. Add a bulk generate button to the collection list view
|
|
25
|
-
- This button will allow you to generate alt text for multiple images at once
|
|
26
25
|
4. Register an `Alt text health` dashboard widget
|
|
27
26
|
- Results are cached and revalidated when documents in the configured upload collections change
|
|
28
27
|
|
|
@@ -78,19 +77,20 @@ This is also the recommended escape hatch if you hit Payload's Postgres SQL-buil
|
|
|
78
77
|
|
|
79
78
|
### Plugin Options
|
|
80
79
|
|
|
81
|
-
| Option | Type
|
|
82
|
-
| ---------------------------- |
|
|
83
|
-
| `collections` | `(CollectionSlug \| CollectionObj)[]`
|
|
84
|
-
| `resolver` | `AltTextResolver`
|
|
85
|
-
| `getImageThumbnail` | `Function`
|
|
86
|
-
| `enabled` | `boolean`
|
|
87
|
-
| `access` | `({ req }) => boolean \| Promise<boolean>`
|
|
88
|
-
| `locale` | `string`
|
|
89
|
-
| `
|
|
90
|
-
| `
|
|
91
|
-
| `
|
|
92
|
-
| `
|
|
93
|
-
| `
|
|
80
|
+
| Option | Type | Required | Description |
|
|
81
|
+
| ---------------------------- | ----------------------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
82
|
+
| `collections` | `(CollectionSlug \| CollectionObj)[]` | Yes | Collections to enable alt text generation for (see [Per-collection options](#per-collection-options)) |
|
|
83
|
+
| `resolver` | `AltTextResolver` | Yes | Alt text resolver to use (e.g., `openAIResolver`) |
|
|
84
|
+
| `getImageThumbnail` | `Function` | Yes | Function to get the thumbnail URL from an image document |
|
|
85
|
+
| `enabled` | `boolean` | No | Disables the plugin entirely when `false` (default: `true`) |
|
|
86
|
+
| `access` | `({ req }) => boolean \| Promise<boolean>` | No | Access control for the plugin's REST endpoints. Defaults to `({ req }) => !!req.user` (any authenticated user) — see [Authentication](#authentication) |
|
|
87
|
+
| `locale` | `string` | No | Locale for alt text generation (required when localization is disabled) |
|
|
88
|
+
| `filterLocales` | `({ locales, req }) => string[] \| Promise<string[]>` | No | Narrows the locales a request generates for and is measured against — in a multi-tenant CMS, to the locales the selected tenant serves (see [Per-request locales](#per-request-locales)) |
|
|
89
|
+
| `maxBulkGenerateConcurrency` | `number` | No | Maximum concurrent API requests for bulk operations (default: 16) |
|
|
90
|
+
| `maxBulkGenerateIds` | `number` | No | Maximum number of image IDs accepted per bulk generate request; larger requests are rejected with `400`. Duplicate IDs are collapsed before the limit is applied (default: 100) |
|
|
91
|
+
| `fieldsOverride` | `Function` | No | Override the default fields inserted by the plugin |
|
|
92
|
+
| `healthCheck` | `boolean \| AltTextHealthCheckConfig` | No | Alt text health tracking (REST endpoint, cache revalidation hooks, dashboard widget). `false` disables it; `true` enables it for every document, gated by `access`; an object enables it and configures its `access` gate and `baseFilter` (see [Health report](#dashboard-widget)) (default: `true`) |
|
|
93
|
+
| `imageThumbnailMimeType` | `string` | No | The MIME type `getImageThumbnail` delivers. Set it when your thumbnail URL transcodes the image, so the stored format no longer decides whether generation is possible (see [Transcoding thumbnails](#transcoding-thumbnails)) |
|
|
94
94
|
|
|
95
95
|
`getImageThumbnail` receives the document and `{ collection, req }`, so a single function can build different URLs per collection:
|
|
96
96
|
|
|
@@ -105,9 +105,48 @@ It may also be async, so the URL can be signed on demand:
|
|
|
105
105
|
getImageThumbnail: async (doc, { req }) => await presignThumbnailUrl(String(doc.url), req)
|
|
106
106
|
```
|
|
107
107
|
|
|
108
|
+
### Per-request locales
|
|
109
|
+
|
|
110
|
+
Payload's locale list is config-wide. A multi-tenant project therefore configures the union of every tenant's locales — and without narrowing, every tenant is served all of them: a bulk generation writes (and pays for) locales the tenant does not serve, and the health report counts its images as incomplete until locales it will never publish are filled.
|
|
111
|
+
|
|
112
|
+
`filterLocales` narrows that list per request:
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
import { getTenantFromCookie } from '@payloadcms/plugin-multi-tenant/utilities'
|
|
116
|
+
|
|
117
|
+
filterLocales: async ({ locales, req }) => {
|
|
118
|
+
const tenantId = getTenantFromCookie(req.headers, req.payload.db.defaultIDType)
|
|
119
|
+
|
|
120
|
+
// No tenant selected: every configured locale, matching the tenant selector.
|
|
121
|
+
if (!tenantId) {
|
|
122
|
+
return locales
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const tenant = await req.payload.findByID({
|
|
126
|
+
id: tenantId,
|
|
127
|
+
collection: 'tenants',
|
|
128
|
+
depth: 0,
|
|
129
|
+
req,
|
|
130
|
+
select: { locales: true },
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
return tenant?.locales?.length ? tenant.locales : locales
|
|
134
|
+
}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
It governs all three locale-dependent behaviors:
|
|
138
|
+
|
|
139
|
+
| Behavior | Effect |
|
|
140
|
+
| ------------------------------ | ------------------------------------------------------------------------------------------- |
|
|
141
|
+
| `POST /alt-text/generate/bulk` | Generates and writes only the admitted locales |
|
|
142
|
+
| `POST /alt-text/generate` | Rejects a request `locale` outside them with `400`, before the resolver runs |
|
|
143
|
+
| Health report and widget | Counts a document complete once the admitted locales are filled, and scopes its cache entry |
|
|
144
|
+
|
|
145
|
+
The returned codes must be a non-empty subset of the configured locales; anything else fails the request rather than writing into a locale the project does not define. Omit the option and every request targets the full configured list — no call is made, so a project that does not scope locales pays nothing for it.
|
|
146
|
+
|
|
108
147
|
### Per-collection options
|
|
109
148
|
|
|
110
|
-
Each entry in `collections` may be either a bare collection slug
|
|
149
|
+
Each entry in `collections` may be either a bare collection slug or an object with the following fields:
|
|
111
150
|
|
|
112
151
|
| Option | Type | Required | Description |
|
|
113
152
|
| ------------------------ | ------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
@@ -119,7 +158,7 @@ Each entry in `collections` may be either a bare collection slug (shorthand, def
|
|
|
119
158
|
```ts
|
|
120
159
|
payloadAltTextPlugin({
|
|
121
160
|
collections: [
|
|
122
|
-
'images', // shorthand
|
|
161
|
+
'images', // shorthand
|
|
123
162
|
{ slug: 'media', mimeTypes: ['image/*', 'application/pdf'] },
|
|
124
163
|
],
|
|
125
164
|
// ...
|
|
@@ -142,7 +181,7 @@ payloadAltTextPlugin({
|
|
|
142
181
|
})
|
|
143
182
|
```
|
|
144
183
|
|
|
145
|
-
|
|
184
|
+
Which source formats get alt text at all is still governed by each collection's `mimeTypes`. The declaration is validated against the resolver's `supportedMimeTypes` once at config load, so transcoding into a format your resolver cannot handle fails at boot instead of once per image.
|
|
146
185
|
|
|
147
186
|
Only declare a format your transformation **always** produces. A `f_auto`-style transformation negotiates the format from the fetching client's `Accept` header and may serve the source format back, so leave it unset there and let the conservative source check apply. If you want AVIF sources to work, transcode explicitly.
|
|
148
187
|
|
|
@@ -226,12 +265,14 @@ healthCheck: {
|
|
|
226
265
|
}
|
|
227
266
|
```
|
|
228
267
|
|
|
229
|
-
`baseFilter` returns a `Where` that is ANDed onto the scan's MIME type filter. It is resolved once per configured collection, so a collection that does not carry the constraining field — a media library shared across tenants, say — can return `{}` and be scanned whole.
|
|
268
|
+
`baseFilter` returns a `Where` that is ANDed onto the scan's MIME type filter. It is resolved once per configured collection, so a collection that does not carry the constraining field — a media library shared across tenants, say — can return `{}` and be scanned whole. Omitting `baseFilter` entirely does the same for every collection.
|
|
230
269
|
|
|
231
270
|
The scan is cached across requests, and its cache key is derived from the resolved filters: a narrowed scan always gets its own cache entry, so one tenant's counts can never be served to another. Cache invalidation stays per collection, so a write in one tenant refreshes the report for all of them.
|
|
232
271
|
|
|
233
272
|
This scopes what the report counts, not who may see it — use `access` for that. Independently of both, the report always omits the collections the requesting user cannot read.
|
|
234
273
|
|
|
274
|
+
Which locales a document is measured against is scoped separately, by [`filterLocales`](#per-request-locales).
|
|
275
|
+
|
|
235
276
|
#### Skipping cache revalidation for individual writes
|
|
236
277
|
|
|
237
278
|
The plugin invalidates the cached health scan via `afterChange` and `afterDelete` hooks. For writes that don't need to invalidate the cache — typically seed data created from `payload.onInit`, batch imports, or migrations — pass `context: { disableRevalidate: true }` to skip the revalidation:
|
|
@@ -246,9 +287,13 @@ await payload.create({
|
|
|
246
287
|
|
|
247
288
|
### Resolvers
|
|
248
289
|
|
|
249
|
-
|
|
290
|
+
Three resolvers ship with the plugin — [OpenAI](#openai-resolver),
|
|
291
|
+
[Mistral](#mistral-resolver) and [Anthropic](#anthropic-resolver) — and any other
|
|
292
|
+
provider can be used by writing your own.
|
|
250
293
|
|
|
251
|
-
|
|
294
|
+
They differ in how the image reaches the provider: the OpenAI resolver passes the
|
|
295
|
+
thumbnail URL, the Mistral and Anthropic resolvers download the image and send
|
|
296
|
+
the bytes.
|
|
252
297
|
|
|
253
298
|
#### OpenAI Resolver
|
|
254
299
|
|
|
@@ -261,6 +306,10 @@ openAIResolver({
|
|
|
261
306
|
})
|
|
262
307
|
```
|
|
263
308
|
|
|
309
|
+
OpenAI fetches the thumbnail URL itself, so it must be reachable from the public
|
|
310
|
+
internet. If it is not, use [`mistralResolver`](#mistral-resolver) or
|
|
311
|
+
[`anthropicResolver`](#anthropic-resolver), which send the bytes instead.
|
|
312
|
+
|
|
264
313
|
| Option | Type | Required | Description |
|
|
265
314
|
| -------------------- | ---------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
266
315
|
| `apiKey` | `string` | Yes | API key for authentication |
|
|
@@ -281,13 +330,6 @@ mistralResolver({
|
|
|
281
330
|
})
|
|
282
331
|
```
|
|
283
332
|
|
|
284
|
-
Unlike the OpenAI resolver, this one downloads the image and sends the bytes
|
|
285
|
-
rather than handing Mistral the thumbnail URL. Mistral's own fetcher needs the
|
|
286
|
-
file to be reachable from the public internet, which is never the case in local
|
|
287
|
-
development and not the case for private buckets; some hosts also refuse it
|
|
288
|
-
outright (`File could not be fetched from url`, error 3310). Sending the bytes
|
|
289
|
-
costs one extra download and removes that whole class of failure.
|
|
290
|
-
|
|
291
333
|
Because there is no image conversion step, `supportedMimeTypes` is limited to
|
|
292
334
|
what the Mistral API accepts directly: JPEG, PNG, GIF and WebP. Documents in
|
|
293
335
|
other formats — SVG or AVIF, for instance — keep their generate button disabled.
|
|
@@ -312,12 +354,6 @@ anthropicResolver({
|
|
|
312
354
|
})
|
|
313
355
|
```
|
|
314
356
|
|
|
315
|
-
Like the Mistral resolver, this one downloads the image and sends the bytes.
|
|
316
|
-
Claude can fetch an image URL itself, but that requires the file to be reachable
|
|
317
|
-
from the public internet, which is never the case in local development and not
|
|
318
|
-
the case for private buckets. Sending the bytes also supplies the `media_type`
|
|
319
|
-
that a base64 image block requires and a URL cannot carry.
|
|
320
|
-
|
|
321
357
|
`supportedMimeTypes` is limited to what the Messages API accepts: JPEG, PNG, GIF
|
|
322
358
|
and WebP. Documents in other formats keep their generate button disabled.
|
|
323
359
|
|
|
@@ -387,7 +423,6 @@ export const myResolver = ({ apiKey }: { apiKey: string }) =>
|
|
|
387
423
|
})
|
|
388
424
|
}
|
|
389
425
|
|
|
390
|
-
// Return the parsed JSON object.
|
|
391
426
|
return await response.json()
|
|
392
427
|
},
|
|
393
428
|
inlineImage: true,
|
|
@@ -488,7 +523,7 @@ Generates alt text for a single image. By default, returns the result without sa
|
|
|
488
523
|
|
|
489
524
|
### `POST /api/alt-text/generate/bulk`
|
|
490
525
|
|
|
491
|
-
Generates and persists alt text for multiple images across
|
|
526
|
+
Generates and persists alt text for multiple images across every target locale (see [Per-request locales](#per-request-locales)).
|
|
492
527
|
|
|
493
528
|
**Request body:**
|
|
494
529
|
|
|
@@ -501,12 +536,20 @@ Generates and persists alt text for multiple images across all configured locale
|
|
|
501
536
|
|
|
502
537
|
```json
|
|
503
538
|
{
|
|
504
|
-
"updatedDocs":
|
|
539
|
+
"updatedDocs": 4,
|
|
505
540
|
"totalDocs": 6,
|
|
506
|
-
"erroredDocs": ["abc789"]
|
|
541
|
+
"erroredDocs": ["abc789"],
|
|
542
|
+
"skippedDocs": [{ "id": "def456", "reason": "notTracked" }]
|
|
507
543
|
}
|
|
508
544
|
```
|
|
509
545
|
|
|
546
|
+
`skippedDocs` holds the documents no provider call was made for. They are reported separately from `erroredDocs` because they are not failures of the run — a selection covering a whole list view routinely contains a few — and each carries the reason, which decides what the editor has to do:
|
|
547
|
+
|
|
548
|
+
| Reason | Meaning |
|
|
549
|
+
| ------------------- | ----------------------------------------------------------------------------------------------- |
|
|
550
|
+
| `notTracked` | The collection does not track this file type, so it needs no alt text at all |
|
|
551
|
+
| `unsupportedFormat` | A tracked file whose format the resolver cannot read — it still needs alt text, written by hand |
|
|
552
|
+
|
|
510
553
|
### `GET /api/alt-text/health`
|
|
511
554
|
|
|
512
555
|
Returns alt text coverage statistics across all configured collections. Only available when `healthCheck` is enabled.
|
|
@@ -6,6 +6,7 @@ import { useTransition } from 'react';
|
|
|
6
6
|
import { PLUGIN_SLUG } from '../constants.js';
|
|
7
7
|
import { Lightning } from './icons/Lightning.js';
|
|
8
8
|
import { Spinner } from './icons/Spinner.js';
|
|
9
|
+
import { summarizeBulkGenerate } from './summarizeBulkGenerate.js';
|
|
9
10
|
export function BulkGenerateAltTextsButton({ collectionSlug }) {
|
|
10
11
|
const { t } = useTranslation();
|
|
11
12
|
const [isPending, startTransition] = useTransition();
|
|
@@ -33,22 +34,8 @@ export function BulkGenerateAltTextsButton({ collectionSlug }) {
|
|
|
33
34
|
return;
|
|
34
35
|
}
|
|
35
36
|
const data = await response.json();
|
|
36
|
-
|
|
37
|
-
toast
|
|
38
|
-
count: data.erroredDocs.length
|
|
39
|
-
}));
|
|
40
|
-
}
|
|
41
|
-
// in case not all images were updated, show a warning instead of a success message:
|
|
42
|
-
if (data.updatedDocs === data.totalDocs) {
|
|
43
|
-
toast.success(t('@jhb.software/payload-alt-text-plugin:xOfYImagesUpdated', {
|
|
44
|
-
total: data.totalDocs,
|
|
45
|
-
updated: data.updatedDocs
|
|
46
|
-
}));
|
|
47
|
-
} else {
|
|
48
|
-
toast.warning(t('@jhb.software/payload-alt-text-plugin:xOfYImagesUpdated', {
|
|
49
|
-
total: data.totalDocs,
|
|
50
|
-
updated: data.updatedDocs
|
|
51
|
-
}));
|
|
37
|
+
for (const { severity, translationKey, variables } of summarizeBulkGenerate(data)){
|
|
38
|
+
toast[severity](t(`@jhb.software/payload-alt-text-plugin:${translationKey}`, variables));
|
|
52
39
|
}
|
|
53
40
|
// deselect all previously selected images
|
|
54
41
|
for (const id of selectedIds){
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/components/BulkGenerateAltTextsButton.tsx"],"sourcesContent":["'use client'\n\nimport { Button, toast, useAuth, useConfig, useSelection, useTranslation } from '@payloadcms/ui'\nimport { useRouter } from 'next/navigation.js'\nimport { useTransition } from 'react'\n\nimport type {\n PluginAltTextTranslationKeys,\n PluginAltTextTranslations,\n} from '../translations/index.js'\n\nimport { PLUGIN_SLUG } from '../constants.js'\nimport { Lightning } from './icons/Lightning.js'\nimport { Spinner } from './icons/Spinner.js'\n\nexport function BulkGenerateAltTextsButton({ collectionSlug }: { collectionSlug: string }) {\n const { t } = useTranslation<PluginAltTextTranslations, PluginAltTextTranslationKeys>()\n const [isPending, startTransition] = useTransition()\n const { permissions } = useAuth()\n const { selected, setSelection } = useSelection()\n\n const canUpdateCollection = Boolean(permissions?.collections?.[collectionSlug]?.update)\n const {\n config: {\n routes: { api: apiRoute },\n serverURL,\n },\n } = useConfig()\n\n const selectedIds = Array.from(selected.entries())\n .filter(([, isSelected]) => isSelected)\n .map(([id]) => id) as string[]\n\n const router = useRouter()\n\n const handleGenerateAltTexts = () => {\n startTransition(async () => {\n if (!collectionSlug) {\n throw new Error('Collection slug is required')\n }\n\n try {\n const response = await fetch(`${serverURL ?? ''}${apiRoute}/${PLUGIN_SLUG}/generate/bulk`, {\n body: JSON.stringify({\n collection: collectionSlug,\n ids: selectedIds,\n }),\n method: 'POST',\n })\n\n if (!response.ok) {\n toast.error(t('@jhb.software/payload-alt-text-plugin:failedToGenerate'))\n return\n }\n\n const data = (await response.json()) as
|
|
1
|
+
{"version":3,"sources":["../../src/components/BulkGenerateAltTextsButton.tsx"],"sourcesContent":["'use client'\n\nimport { Button, toast, useAuth, useConfig, useSelection, useTranslation } from '@payloadcms/ui'\nimport { useRouter } from 'next/navigation.js'\nimport { useTransition } from 'react'\n\nimport type {\n PluginAltTextTranslationKeys,\n PluginAltTextTranslations,\n} from '../translations/index.js'\nimport type { BulkGenerateResult } from './summarizeBulkGenerate.js'\n\nimport { PLUGIN_SLUG } from '../constants.js'\nimport { Lightning } from './icons/Lightning.js'\nimport { Spinner } from './icons/Spinner.js'\nimport { summarizeBulkGenerate } from './summarizeBulkGenerate.js'\n\nexport function BulkGenerateAltTextsButton({ collectionSlug }: { collectionSlug: string }) {\n const { t } = useTranslation<PluginAltTextTranslations, PluginAltTextTranslationKeys>()\n const [isPending, startTransition] = useTransition()\n const { permissions } = useAuth()\n const { selected, setSelection } = useSelection()\n\n const canUpdateCollection = Boolean(permissions?.collections?.[collectionSlug]?.update)\n const {\n config: {\n routes: { api: apiRoute },\n serverURL,\n },\n } = useConfig()\n\n const selectedIds = Array.from(selected.entries())\n .filter(([, isSelected]) => isSelected)\n .map(([id]) => id) as string[]\n\n const router = useRouter()\n\n const handleGenerateAltTexts = () => {\n startTransition(async () => {\n if (!collectionSlug) {\n throw new Error('Collection slug is required')\n }\n\n try {\n const response = await fetch(`${serverURL ?? ''}${apiRoute}/${PLUGIN_SLUG}/generate/bulk`, {\n body: JSON.stringify({\n collection: collectionSlug,\n ids: selectedIds,\n }),\n method: 'POST',\n })\n\n if (!response.ok) {\n toast.error(t('@jhb.software/payload-alt-text-plugin:failedToGenerate'))\n return\n }\n\n const data = (await response.json()) as BulkGenerateResult\n\n for (const { severity, translationKey, variables } of summarizeBulkGenerate(data)) {\n toast[severity](t(`@jhb.software/payload-alt-text-plugin:${translationKey}`, variables))\n }\n\n // deselect all previously selected images\n for (const id of selectedIds) {\n setSelection(id)\n }\n\n router.refresh()\n } catch (error) {\n console.error('Error generating alt text:', error)\n toast.error(t('@jhb.software/payload-alt-text-plugin:errorGeneratingAltText'))\n }\n })\n }\n\n return (\n canUpdateCollection &&\n selectedIds.length > 0 && (\n <div className=\"m-0\" style={{ display: 'flex', justifyContent: 'right' }}>\n <Button\n className=\"m-0\"\n disabled={isPending || selectedIds.length === 0}\n icon={isPending ? <Spinner /> : <Lightning />}\n onClick={handleGenerateAltTexts}\n >\n {t('@jhb.software/payload-alt-text-plugin:generateAltTextFor', {\n count: selectedIds.length,\n })}\n </Button>\n </div>\n )\n )\n}\n"],"names":["Button","toast","useAuth","useConfig","useSelection","useTranslation","useRouter","useTransition","PLUGIN_SLUG","Lightning","Spinner","summarizeBulkGenerate","BulkGenerateAltTextsButton","collectionSlug","t","isPending","startTransition","permissions","selected","setSelection","canUpdateCollection","Boolean","collections","update","config","routes","api","apiRoute","serverURL","selectedIds","Array","from","entries","filter","isSelected","map","id","router","handleGenerateAltTexts","Error","response","fetch","body","JSON","stringify","collection","ids","method","ok","error","data","json","severity","translationKey","variables","refresh","console","length","div","className","style","display","justifyContent","disabled","icon","onClick","count"],"mappings":"AAAA;;AAEA,SAASA,MAAM,EAAEC,KAAK,EAAEC,OAAO,EAAEC,SAAS,EAAEC,YAAY,EAAEC,cAAc,QAAQ,iBAAgB;AAChG,SAASC,SAAS,QAAQ,qBAAoB;AAC9C,SAASC,aAAa,QAAQ,QAAO;AAQrC,SAASC,WAAW,QAAQ,kBAAiB;AAC7C,SAASC,SAAS,QAAQ,uBAAsB;AAChD,SAASC,OAAO,QAAQ,qBAAoB;AAC5C,SAASC,qBAAqB,QAAQ,6BAA4B;AAElE,OAAO,SAASC,2BAA2B,EAAEC,cAAc,EAA8B;IACvF,MAAM,EAAEC,CAAC,EAAE,GAAGT;IACd,MAAM,CAACU,WAAWC,gBAAgB,GAAGT;IACrC,MAAM,EAAEU,WAAW,EAAE,GAAGf;IACxB,MAAM,EAAEgB,QAAQ,EAAEC,YAAY,EAAE,GAAGf;IAEnC,MAAMgB,sBAAsBC,QAAQJ,aAAaK,aAAa,CAACT,eAAe,EAAEU;IAChF,MAAM,EACJC,QAAQ,EACNC,QAAQ,EAAEC,KAAKC,QAAQ,EAAE,EACzBC,SAAS,EACV,EACF,GAAGzB;IAEJ,MAAM0B,cAAcC,MAAMC,IAAI,CAACb,SAASc,OAAO,IAC5CC,MAAM,CAAC,CAAC,GAAGC,WAAW,GAAKA,YAC3BC,GAAG,CAAC,CAAC,CAACC,GAAG,GAAKA;IAEjB,MAAMC,SAAS/B;IAEf,MAAMgC,yBAAyB;QAC7BtB,gBAAgB;YACd,IAAI,CAACH,gBAAgB;gBACnB,MAAM,IAAI0B,MAAM;YAClB;YAEA,IAAI;gBACF,MAAMC,WAAW,MAAMC,MAAM,GAAGb,aAAa,KAAKD,SAAS,CAAC,EAAEnB,YAAY,cAAc,CAAC,EAAE;oBACzFkC,MAAMC,KAAKC,SAAS,CAAC;wBACnBC,YAAYhC;wBACZiC,KAAKjB;oBACP;oBACAkB,QAAQ;gBACV;gBAEA,IAAI,CAACP,SAASQ,EAAE,EAAE;oBAChB/C,MAAMgD,KAAK,CAACnC,EAAE;oBACd;gBACF;gBAEA,MAAMoC,OAAQ,MAAMV,SAASW,IAAI;gBAEjC,KAAK,MAAM,EAAEC,QAAQ,EAAEC,cAAc,EAAEC,SAAS,EAAE,IAAI3C,sBAAsBuC,MAAO;oBACjFjD,KAAK,CAACmD,SAAS,CAACtC,EAAE,CAAC,sCAAsC,EAAEuC,gBAAgB,EAAEC;gBAC/E;gBAEA,0CAA0C;gBAC1C,KAAK,MAAMlB,MAAMP,YAAa;oBAC5BV,aAAaiB;gBACf;gBAEAC,OAAOkB,OAAO;YAChB,EAAE,OAAON,OAAO;gBACdO,QAAQP,KAAK,CAAC,8BAA8BA;gBAC5ChD,MAAMgD,KAAK,CAACnC,EAAE;YAChB;QACF;IACF;IAEA,OACEM,uBACAS,YAAY4B,MAAM,GAAG,mBACnB,KAACC;QAAIC,WAAU;QAAMC,OAAO;YAAEC,SAAS;YAAQC,gBAAgB;QAAQ;kBACrE,cAAA,KAAC9D;YACC2D,WAAU;YACVI,UAAUhD,aAAac,YAAY4B,MAAM,KAAK;YAC9CO,MAAMjD,0BAAY,KAACL,6BAAa,KAACD;YACjCwD,SAAS3B;sBAERxB,EAAE,4DAA4D;gBAC7DoD,OAAOrC,YAAY4B,MAAM;YAC3B;;;AAKV"}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { SkippedDoc } from '../endpoints/bulkGenerateAltTexts.js';
|
|
2
|
+
export type BulkGenerateResult = {
|
|
3
|
+
erroredDocs: (number | string)[];
|
|
4
|
+
skippedDocs: SkippedDoc[];
|
|
5
|
+
totalDocs: number;
|
|
6
|
+
updatedDocs: number;
|
|
7
|
+
};
|
|
8
|
+
export type BulkGenerateToast = {
|
|
9
|
+
severity: 'error' | 'info' | 'success' | 'warning';
|
|
10
|
+
translationKey: 'failedToGenerateForXImages' | 'skippedNoAltTextNeeded' | 'skippedUnsupportedFormat' | 'xOfYImagesUpdated';
|
|
11
|
+
variables: {
|
|
12
|
+
count: number;
|
|
13
|
+
total?: number;
|
|
14
|
+
updated?: number;
|
|
15
|
+
};
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* What a finished bulk run tells the editor, in the order it is told.
|
|
19
|
+
*
|
|
20
|
+
* Skipped files were never candidates, so they are reported on their own terms
|
|
21
|
+
* — by what the editor has to do about each — and left out of the count the run
|
|
22
|
+
* is measured against. A run whose selection skipped away entirely has no
|
|
23
|
+
* result to report: "0 of 0 images updated" reads as a failure of work that
|
|
24
|
+
* never existed.
|
|
25
|
+
*/
|
|
26
|
+
export declare function summarizeBulkGenerate({ erroredDocs, skippedDocs, totalDocs, updatedDocs, }: BulkGenerateResult): BulkGenerateToast[];
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a finished bulk run tells the editor, in the order it is told.
|
|
3
|
+
*
|
|
4
|
+
* Skipped files were never candidates, so they are reported on their own terms
|
|
5
|
+
* — by what the editor has to do about each — and left out of the count the run
|
|
6
|
+
* is measured against. A run whose selection skipped away entirely has no
|
|
7
|
+
* result to report: "0 of 0 images updated" reads as a failure of work that
|
|
8
|
+
* never existed.
|
|
9
|
+
*/ export function summarizeBulkGenerate({ erroredDocs, skippedDocs, totalDocs, updatedDocs }) {
|
|
10
|
+
const toasts = [];
|
|
11
|
+
if (erroredDocs.length > 0) {
|
|
12
|
+
toasts.push({
|
|
13
|
+
severity: 'error',
|
|
14
|
+
translationKey: 'failedToGenerateForXImages',
|
|
15
|
+
variables: {
|
|
16
|
+
count: erroredDocs.length
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
const notTracked = skippedDocs.filter((doc)=>doc.reason === 'notTracked').length;
|
|
21
|
+
const unsupported = skippedDocs.length - notTracked;
|
|
22
|
+
if (notTracked > 0) {
|
|
23
|
+
toasts.push({
|
|
24
|
+
severity: 'info',
|
|
25
|
+
translationKey: 'skippedNoAltTextNeeded',
|
|
26
|
+
variables: {
|
|
27
|
+
count: notTracked
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
// Needs alt text, just not from a model — so it is the editor's to write.
|
|
32
|
+
if (unsupported > 0) {
|
|
33
|
+
toasts.push({
|
|
34
|
+
severity: 'warning',
|
|
35
|
+
translationKey: 'skippedUnsupportedFormat',
|
|
36
|
+
variables: {
|
|
37
|
+
count: unsupported
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
const attempted = totalDocs - skippedDocs.length;
|
|
42
|
+
if (attempted > 0) {
|
|
43
|
+
toasts.push({
|
|
44
|
+
severity: updatedDocs === attempted ? 'success' : 'warning',
|
|
45
|
+
translationKey: 'xOfYImagesUpdated',
|
|
46
|
+
variables: {
|
|
47
|
+
count: attempted,
|
|
48
|
+
total: attempted,
|
|
49
|
+
updated: updatedDocs
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
return toasts;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
//# sourceMappingURL=summarizeBulkGenerate.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/components/summarizeBulkGenerate.ts"],"sourcesContent":["import type { SkippedDoc } from '../endpoints/bulkGenerateAltTexts.js'\n\nexport type BulkGenerateResult = {\n erroredDocs: (number | string)[]\n skippedDocs: SkippedDoc[]\n totalDocs: number\n updatedDocs: number\n}\n\nexport type BulkGenerateToast = {\n severity: 'error' | 'info' | 'success' | 'warning'\n translationKey:\n | 'failedToGenerateForXImages'\n | 'skippedNoAltTextNeeded'\n | 'skippedUnsupportedFormat'\n | 'xOfYImagesUpdated'\n variables: { count: number; total?: number; updated?: number }\n}\n\n/**\n * What a finished bulk run tells the editor, in the order it is told.\n *\n * Skipped files were never candidates, so they are reported on their own terms\n * — by what the editor has to do about each — and left out of the count the run\n * is measured against. A run whose selection skipped away entirely has no\n * result to report: \"0 of 0 images updated\" reads as a failure of work that\n * never existed.\n */\nexport function summarizeBulkGenerate({\n erroredDocs,\n skippedDocs,\n totalDocs,\n updatedDocs,\n}: BulkGenerateResult): BulkGenerateToast[] {\n const toasts: BulkGenerateToast[] = []\n\n if (erroredDocs.length > 0) {\n toasts.push({\n severity: 'error',\n translationKey: 'failedToGenerateForXImages',\n variables: { count: erroredDocs.length },\n })\n }\n\n const notTracked = skippedDocs.filter((doc) => doc.reason === 'notTracked').length\n const unsupported = skippedDocs.length - notTracked\n\n if (notTracked > 0) {\n toasts.push({\n severity: 'info',\n translationKey: 'skippedNoAltTextNeeded',\n variables: { count: notTracked },\n })\n }\n\n // Needs alt text, just not from a model — so it is the editor's to write.\n if (unsupported > 0) {\n toasts.push({\n severity: 'warning',\n translationKey: 'skippedUnsupportedFormat',\n variables: { count: unsupported },\n })\n }\n\n const attempted = totalDocs - skippedDocs.length\n\n if (attempted > 0) {\n toasts.push({\n severity: updatedDocs === attempted ? 'success' : 'warning',\n translationKey: 'xOfYImagesUpdated',\n variables: { count: attempted, total: attempted, updated: updatedDocs },\n })\n }\n\n return toasts\n}\n"],"names":["summarizeBulkGenerate","erroredDocs","skippedDocs","totalDocs","updatedDocs","toasts","length","push","severity","translationKey","variables","count","notTracked","filter","doc","reason","unsupported","attempted","total","updated"],"mappings":"AAmBA;;;;;;;;CAQC,GACD,OAAO,SAASA,sBAAsB,EACpCC,WAAW,EACXC,WAAW,EACXC,SAAS,EACTC,WAAW,EACQ;IACnB,MAAMC,SAA8B,EAAE;IAEtC,IAAIJ,YAAYK,MAAM,GAAG,GAAG;QAC1BD,OAAOE,IAAI,CAAC;YACVC,UAAU;YACVC,gBAAgB;YAChBC,WAAW;gBAAEC,OAAOV,YAAYK,MAAM;YAAC;QACzC;IACF;IAEA,MAAMM,aAAaV,YAAYW,MAAM,CAAC,CAACC,MAAQA,IAAIC,MAAM,KAAK,cAAcT,MAAM;IAClF,MAAMU,cAAcd,YAAYI,MAAM,GAAGM;IAEzC,IAAIA,aAAa,GAAG;QAClBP,OAAOE,IAAI,CAAC;YACVC,UAAU;YACVC,gBAAgB;YAChBC,WAAW;gBAAEC,OAAOC;YAAW;QACjC;IACF;IAEA,0EAA0E;IAC1E,IAAII,cAAc,GAAG;QACnBX,OAAOE,IAAI,CAAC;YACVC,UAAU;YACVC,gBAAgB;YAChBC,WAAW;gBAAEC,OAAOK;YAAY;QAClC;IACF;IAEA,MAAMC,YAAYd,YAAYD,YAAYI,MAAM;IAEhD,IAAIW,YAAY,GAAG;QACjBZ,OAAOE,IAAI,CAAC;YACVC,UAAUJ,gBAAgBa,YAAY,YAAY;YAClDR,gBAAgB;YAChBC,WAAW;gBAAEC,OAAOM;gBAAWC,OAAOD;gBAAWE,SAASf;YAAY;QACxE;IACF;IAEA,OAAOC;AACT"}
|
|
@@ -1,6 +1,23 @@
|
|
|
1
1
|
import type { PayloadHandler } from 'payload';
|
|
2
2
|
import type { AltTextPluginConfig } from '../types/AltTextPluginConfig.js';
|
|
3
3
|
/**
|
|
4
|
-
* Generates and updates alt text for multiple images in all locales.
|
|
4
|
+
* Generates and updates alt text for multiple images in all target locales.
|
|
5
|
+
*
|
|
6
|
+
* Files nothing can be generated for are reported as `skippedDocs` rather than
|
|
7
|
+
* `erroredDocs` — burying them among real failures hides those — each with the
|
|
8
|
+
* reason that decides what the editor has to do next. See {@link SkipReason}.
|
|
5
9
|
*/
|
|
6
10
|
export declare const bulkGenerateAltTextsEndpoint: (access: AltTextPluginConfig["access"]) => PayloadHandler;
|
|
11
|
+
/**
|
|
12
|
+
* Why a document was left alone.
|
|
13
|
+
*
|
|
14
|
+
* - `notTracked` — the collection does not track this file type, so it needs no
|
|
15
|
+
* alt text at all.
|
|
16
|
+
* - `unsupportedFormat` — a tracked file whose format the resolver cannot read.
|
|
17
|
+
* It still needs alt text; an editor has to write it.
|
|
18
|
+
*/
|
|
19
|
+
export type SkipReason = 'notTracked' | 'unsupportedFormat';
|
|
20
|
+
export type SkippedDoc = {
|
|
21
|
+
id: number | string;
|
|
22
|
+
reason: SkipReason;
|
|
23
|
+
};
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import pMap from 'p-map';
|
|
2
2
|
import { APIError, Forbidden } from 'payload';
|
|
3
3
|
import { ZodError } from 'zod';
|
|
4
|
-
import { localesFromConfig } from '../utilities/localesFromConfig.js';
|
|
5
4
|
import { getUnsupportedSourceMimeTypeError, matchesMimeType } from '../utilities/mimeTypes.js';
|
|
5
|
+
import { resolveLocales } from '../utilities/resolveLocales.js';
|
|
6
6
|
import { bulkGenerateAltTextsRequestSchema, formatZodError } from './schemas.js';
|
|
7
7
|
/**
|
|
8
|
-
* Generates and updates alt text for multiple images in all locales.
|
|
8
|
+
* Generates and updates alt text for multiple images in all target locales.
|
|
9
|
+
*
|
|
10
|
+
* Files nothing can be generated for are reported as `skippedDocs` rather than
|
|
11
|
+
* `erroredDocs` — burying them among real failures hides those — each with the
|
|
12
|
+
* reason that decides what the editor has to do next. See {@link SkipReason}.
|
|
9
13
|
*/ export const bulkGenerateAltTextsEndpoint = (access)=>async (req)=>{
|
|
10
14
|
try {
|
|
11
15
|
if (!await access({
|
|
@@ -21,6 +25,7 @@ import { bulkGenerateAltTextsRequestSchema, formatZodError } from './schemas.js'
|
|
|
21
25
|
const { collection, ids } = bulkGenerateAltTextsRequestSchema.parse(data);
|
|
22
26
|
let updatedDocs = 0;
|
|
23
27
|
const erroredDocs = [];
|
|
28
|
+
const skippedDocs = [];
|
|
24
29
|
// Get plugin config from payload config
|
|
25
30
|
const pluginConfig = req.payload.config.custom?.altTextPluginConfig;
|
|
26
31
|
if (!pluginConfig) {
|
|
@@ -62,12 +67,11 @@ import { bulkGenerateAltTextsRequestSchema, formatZodError } from './schemas.js'
|
|
|
62
67
|
status: 400
|
|
63
68
|
});
|
|
64
69
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
if (!targetLocales) {
|
|
70
|
+
const targetLocales = await resolveLocales({
|
|
71
|
+
pluginConfig,
|
|
72
|
+
req
|
|
73
|
+
});
|
|
74
|
+
if (targetLocales.length === 0) {
|
|
71
75
|
return Response.json({
|
|
72
76
|
error: 'Could not determine target locales for alt text generation. Please check your plugin configuration.'
|
|
73
77
|
}, {
|
|
@@ -76,7 +80,7 @@ import { bulkGenerateAltTextsRequestSchema, formatZodError } from './schemas.js'
|
|
|
76
80
|
}
|
|
77
81
|
await pMap(uniqueIds, async (id)=>{
|
|
78
82
|
try {
|
|
79
|
-
await generateAndUpdateAltText({
|
|
83
|
+
const skipReason = await generateAndUpdateAltText({
|
|
80
84
|
id,
|
|
81
85
|
collection,
|
|
82
86
|
locales: targetLocales,
|
|
@@ -84,6 +88,14 @@ import { bulkGenerateAltTextsRequestSchema, formatZodError } from './schemas.js'
|
|
|
84
88
|
pluginConfig,
|
|
85
89
|
req
|
|
86
90
|
});
|
|
91
|
+
if (skipReason) {
|
|
92
|
+
skippedDocs.push({
|
|
93
|
+
id,
|
|
94
|
+
reason: skipReason.reason
|
|
95
|
+
});
|
|
96
|
+
req.payload.logger.info(`Skipped ${id}: ${skipReason.detail}`);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
87
99
|
updatedDocs++;
|
|
88
100
|
req.payload.logger.info(`${updatedDocs}/${uniqueIds.length} updated (${Math.round(updatedDocs / uniqueIds.length * 100)}%)`);
|
|
89
101
|
} catch (error) {
|
|
@@ -107,6 +119,7 @@ import { bulkGenerateAltTextsRequestSchema, formatZodError } from './schemas.js'
|
|
|
107
119
|
}
|
|
108
120
|
return Response.json({
|
|
109
121
|
erroredDocs,
|
|
122
|
+
skippedDocs,
|
|
110
123
|
totalDocs: uniqueIds.length,
|
|
111
124
|
updatedDocs
|
|
112
125
|
});
|
|
@@ -135,7 +148,7 @@ import { bulkGenerateAltTextsRequestSchema, formatZodError } from './schemas.js'
|
|
|
135
148
|
});
|
|
136
149
|
}
|
|
137
150
|
};
|
|
138
|
-
async function generateAndUpdateAltText({ id, collection, locales, payload, pluginConfig, req }) {
|
|
151
|
+
/** Returns why the document was skipped, or `undefined` once it has been written. */ async function generateAndUpdateAltText({ id, collection, locales, payload, pluginConfig, req }) {
|
|
139
152
|
const imageDoc = await payload.findByID({
|
|
140
153
|
id,
|
|
141
154
|
collection,
|
|
@@ -153,7 +166,10 @@ async function generateAndUpdateAltText({ id, collection, locales, payload, plug
|
|
|
153
166
|
// reaching this helper, so a matching entry is guaranteed.
|
|
154
167
|
const collectionConfig = pluginConfig.collections.find((entry)=>entry.slug === collection);
|
|
155
168
|
if (mimeType && !matchesMimeType(mimeType, collectionConfig.mimeTypes)) {
|
|
156
|
-
|
|
169
|
+
return {
|
|
170
|
+
detail: `alt text is not tracked for files of type "${mimeType}" in the "${collection}" collection`,
|
|
171
|
+
reason: 'notTracked'
|
|
172
|
+
};
|
|
157
173
|
}
|
|
158
174
|
const unsupportedSourceError = getUnsupportedSourceMimeTypeError({
|
|
159
175
|
declaredThumbnailMimeType: collectionConfig.imageThumbnailMimeType,
|
|
@@ -161,7 +177,10 @@ async function generateAndUpdateAltText({ id, collection, locales, payload, plug
|
|
|
161
177
|
supportedMimeTypes: pluginConfig.resolver.supportedMimeTypes
|
|
162
178
|
});
|
|
163
179
|
if (unsupportedSourceError) {
|
|
164
|
-
|
|
180
|
+
return {
|
|
181
|
+
detail: unsupportedSourceError,
|
|
182
|
+
reason: 'unsupportedFormat'
|
|
183
|
+
};
|
|
165
184
|
}
|
|
166
185
|
const imageThumbnailUrl = await pluginConfig.getImageThumbnail(imageDoc, {
|
|
167
186
|
collection,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/endpoints/bulkGenerateAltTexts.ts"],"sourcesContent":["import type { BasePayload, CollectionSlug, PayloadHandler, PayloadRequest } from 'payload'\n\nimport pMap from 'p-map'\nimport { APIError, Forbidden } from 'payload'\nimport { ZodError } from 'zod'\n\nimport type { AltTextPluginConfig } from '../types/AltTextPluginConfig.js'\n\nimport { localesFromConfig } from '../utilities/localesFromConfig.js'\nimport { getUnsupportedSourceMimeTypeError, matchesMimeType } from '../utilities/mimeTypes.js'\nimport { bulkGenerateAltTextsRequestSchema, formatZodError } from './schemas.js'\n\n/**\n * Generates and updates alt text for multiple images in all locales.\n */\nexport const bulkGenerateAltTextsEndpoint =\n (access: AltTextPluginConfig['access']): PayloadHandler =>\n async (req: PayloadRequest) => {\n try {\n if (!(await access({ req }))) {\n return Response.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n const data = 'json' in req && typeof req.json === 'function' ? await req.json() : null\n\n const { collection, ids } = bulkGenerateAltTextsRequestSchema.parse(data)\n\n let updatedDocs = 0\n const erroredDocs: (number | string)[] = []\n\n // Get plugin config from payload config\n const pluginConfig = req.payload.config.custom?.altTextPluginConfig as\n AltTextPluginConfig | undefined\n\n if (!pluginConfig) {\n return Response.json({ error: 'Plugin config not found' }, { status: 500 })\n }\n\n // Treat the configured collections as an allowlist. Reject any other\n // collection before touching the Local API, so the endpoint can only ever\n // operate on the upload collections the plugin manages.\n const collectionConfig = pluginConfig.collections.find((entry) => entry.slug === collection)\n\n if (!collectionConfig) {\n return Response.json(\n { error: `Collection \"${collection}\" is not managed by the alt text plugin.` },\n { status: 403 },\n )\n }\n\n if (!pluginConfig.resolver) {\n return Response.json({ error: 'No alt text resolver configured' }, { status: 500 })\n }\n\n const concurrency = pluginConfig.maxBulkGenerateConcurrency\n\n // De-duplicate so the same image is never generated (and billed) twice,\n // then bound the batch so a single request cannot fan out into an\n // unbounded number of paid resolver calls.\n const uniqueIds = [...new Set(ids)]\n\n if (uniqueIds.length > pluginConfig.maxBulkGenerateIds) {\n return Response.json(\n {\n error: `Too many ids: ${uniqueIds.length} exceeds the maximum of ${pluginConfig.maxBulkGenerateIds} per request.`,\n },\n { status: 400 },\n )\n }\n\n // determine target locales based on config\n const locales = localesFromConfig(req.payload.config)\n const targetLocales = locales ?? [pluginConfig.locale!]\n if (!targetLocales) {\n return Response.json(\n {\n error:\n 'Could not determine target locales for alt text generation. Please check your plugin configuration.',\n },\n { status: 500 },\n )\n }\n\n await pMap(\n uniqueIds,\n async (id) => {\n try {\n await generateAndUpdateAltText({\n id,\n collection,\n locales: targetLocales,\n payload: req.payload,\n pluginConfig,\n req,\n })\n updatedDocs++\n req.payload.logger.info(\n `${updatedDocs}/${uniqueIds.length} updated (${Math.round((updatedDocs / uniqueIds.length) * 100)}%)`,\n )\n } catch (error) {\n // A Forbidden means the user has no read/update access to the\n // collection at all — it applies to every id, so fail the whole\n // request with a real 403 instead of silently listing all ids as\n // errored. Row-level NotFound stays a per-doc error (partial success).\n if (error instanceof Forbidden) {\n throw error\n }\n req.payload.logger.error({ err: error }, `Error generating alt text for ${id}`)\n erroredDocs.push(id)\n }\n },\n { concurrency },\n )\n\n if (erroredDocs.length > 0) {\n req.payload.logger.error(`Failed for: ${erroredDocs.join(', ')}`)\n }\n\n return Response.json({\n erroredDocs,\n totalDocs: uniqueIds.length,\n updatedDocs,\n })\n } catch (error) {\n if (error instanceof ZodError) {\n return Response.json(formatZodError(error), { status: 400 })\n }\n // Surface Payload access errors (Forbidden 403) with their real status so\n // an agent gets an accurate, non-retryable signal instead of a 500.\n if (error instanceof APIError) {\n return Response.json({ error: error.message }, { status: error.status })\n }\n req.payload.logger.error({ err: error }, 'Error in bulk generation')\n return Response.json(\n {\n error: `Error generating alt text: ${error instanceof Error ? error.message : 'Unknown error'}`,\n },\n { status: 500 },\n )\n }\n }\n\nasync function generateAndUpdateAltText({\n id,\n collection,\n locales,\n payload,\n pluginConfig,\n req,\n}: {\n collection: CollectionSlug\n id: number | string\n locales: string[]\n payload: BasePayload\n pluginConfig: AltTextPluginConfig\n req: PayloadRequest\n}) {\n const imageDoc = await payload.findByID({\n id,\n collection,\n depth: 0,\n // Run under the requesting user's access, not Payload's default\n // `overrideAccess: true`, so collection-level access control applies.\n overrideAccess: false,\n user: req.user,\n })\n\n if (!imageDoc) {\n throw new Error('Image not found')\n }\n\n const mimeType =\n 'mimeType' in imageDoc && typeof imageDoc.mimeType === 'string' ? imageDoc.mimeType : undefined\n\n // The handler validates `collection` against the configured collections before\n // reaching this helper, so a matching entry is guaranteed.\n const collectionConfig = pluginConfig.collections.find((entry) => entry.slug === collection)!\n\n if (mimeType && !matchesMimeType(mimeType, collectionConfig.mimeTypes)) {\n throw new Error(\n `Alt text is not tracked for files of type \"${mimeType}\" in the \"${collection}\" collection. Tracked types: ${collectionConfig.mimeTypes.join(', ')}.`,\n )\n }\n\n const unsupportedSourceError = getUnsupportedSourceMimeTypeError({\n declaredThumbnailMimeType: collectionConfig.imageThumbnailMimeType,\n mimeType,\n supportedMimeTypes: pluginConfig.resolver.supportedMimeTypes,\n })\n if (unsupportedSourceError) {\n throw new Error(unsupportedSourceError)\n }\n\n const imageThumbnailUrl = await pluginConfig.getImageThumbnail(imageDoc, { collection, req })\n\n const result = await pluginConfig.resolver.resolveBulk({\n filename:\n 'filename' in imageDoc && typeof imageDoc.filename === 'string'\n ? imageDoc.filename\n : undefined,\n imageThumbnailMimeType: collectionConfig.imageThumbnailMimeType,\n imageThumbnailUrl,\n locales,\n req,\n })\n\n if (!result.success) {\n throw new Error(result.error || 'Failed to generate alt text')\n }\n\n for (const locale of locales) {\n const localeResult = result.results[locale]\n if (localeResult) {\n await payload.update({\n id,\n collection,\n data: {\n alt: localeResult.altText,\n keywords: localeResult.keywords,\n },\n locale,\n // Run under the requesting user's access, not Payload's default\n // `overrideAccess: true`, so collection-level access control applies.\n overrideAccess: false,\n user: req.user,\n })\n }\n }\n}\n"],"names":["pMap","APIError","Forbidden","ZodError","localesFromConfig","getUnsupportedSourceMimeTypeError","matchesMimeType","bulkGenerateAltTextsRequestSchema","formatZodError","bulkGenerateAltTextsEndpoint","access","req","Response","json","error","status","data","collection","ids","parse","updatedDocs","erroredDocs","pluginConfig","payload","config","custom","altTextPluginConfig","collectionConfig","collections","find","entry","slug","resolver","concurrency","maxBulkGenerateConcurrency","uniqueIds","Set","length","maxBulkGenerateIds","locales","targetLocales","locale","id","generateAndUpdateAltText","logger","info","Math","round","err","push","join","totalDocs","message","Error","imageDoc","findByID","depth","overrideAccess","user","mimeType","undefined","mimeTypes","unsupportedSourceError","declaredThumbnailMimeType","imageThumbnailMimeType","supportedMimeTypes","imageThumbnailUrl","getImageThumbnail","result","resolveBulk","filename","success","localeResult","results","update","alt","altText","keywords"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../src/endpoints/bulkGenerateAltTexts.ts"],"sourcesContent":["import type { BasePayload, CollectionSlug, PayloadHandler, PayloadRequest } from 'payload'\n\nimport pMap from 'p-map'\nimport { APIError, Forbidden } from 'payload'\nimport { ZodError } from 'zod'\n\nimport type { AltTextPluginConfig } from '../types/AltTextPluginConfig.js'\n\nimport { getUnsupportedSourceMimeTypeError, matchesMimeType } from '../utilities/mimeTypes.js'\nimport { resolveLocales } from '../utilities/resolveLocales.js'\nimport { bulkGenerateAltTextsRequestSchema, formatZodError } from './schemas.js'\n\n/**\n * Generates and updates alt text for multiple images in all target locales.\n *\n * Files nothing can be generated for are reported as `skippedDocs` rather than\n * `erroredDocs` — burying them among real failures hides those — each with the\n * reason that decides what the editor has to do next. See {@link SkipReason}.\n */\nexport const bulkGenerateAltTextsEndpoint =\n (access: AltTextPluginConfig['access']): PayloadHandler =>\n async (req: PayloadRequest) => {\n try {\n if (!(await access({ req }))) {\n return Response.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n const data = 'json' in req && typeof req.json === 'function' ? await req.json() : null\n\n const { collection, ids } = bulkGenerateAltTextsRequestSchema.parse(data)\n\n let updatedDocs = 0\n const erroredDocs: (number | string)[] = []\n const skippedDocs: SkippedDoc[] = []\n\n // Get plugin config from payload config\n const pluginConfig = req.payload.config.custom?.altTextPluginConfig as\n AltTextPluginConfig | undefined\n\n if (!pluginConfig) {\n return Response.json({ error: 'Plugin config not found' }, { status: 500 })\n }\n\n // Treat the configured collections as an allowlist. Reject any other\n // collection before touching the Local API, so the endpoint can only ever\n // operate on the upload collections the plugin manages.\n const collectionConfig = pluginConfig.collections.find((entry) => entry.slug === collection)\n\n if (!collectionConfig) {\n return Response.json(\n { error: `Collection \"${collection}\" is not managed by the alt text plugin.` },\n { status: 403 },\n )\n }\n\n if (!pluginConfig.resolver) {\n return Response.json({ error: 'No alt text resolver configured' }, { status: 500 })\n }\n\n const concurrency = pluginConfig.maxBulkGenerateConcurrency\n\n // De-duplicate so the same image is never generated (and billed) twice,\n // then bound the batch so a single request cannot fan out into an\n // unbounded number of paid resolver calls.\n const uniqueIds = [...new Set(ids)]\n\n if (uniqueIds.length > pluginConfig.maxBulkGenerateIds) {\n return Response.json(\n {\n error: `Too many ids: ${uniqueIds.length} exceeds the maximum of ${pluginConfig.maxBulkGenerateIds} per request.`,\n },\n { status: 400 },\n )\n }\n\n const targetLocales = await resolveLocales({ pluginConfig, req })\n\n if (targetLocales.length === 0) {\n return Response.json(\n {\n error:\n 'Could not determine target locales for alt text generation. Please check your plugin configuration.',\n },\n { status: 500 },\n )\n }\n\n await pMap(\n uniqueIds,\n async (id) => {\n try {\n const skipReason = await generateAndUpdateAltText({\n id,\n collection,\n locales: targetLocales,\n payload: req.payload,\n pluginConfig,\n req,\n })\n\n if (skipReason) {\n skippedDocs.push({ id, reason: skipReason.reason })\n req.payload.logger.info(`Skipped ${id}: ${skipReason.detail}`)\n return\n }\n\n updatedDocs++\n req.payload.logger.info(\n `${updatedDocs}/${uniqueIds.length} updated (${Math.round((updatedDocs / uniqueIds.length) * 100)}%)`,\n )\n } catch (error) {\n // A Forbidden means the user has no read/update access to the\n // collection at all — it applies to every id, so fail the whole\n // request with a real 403 instead of silently listing all ids as\n // errored. Row-level NotFound stays a per-doc error (partial success).\n if (error instanceof Forbidden) {\n throw error\n }\n req.payload.logger.error({ err: error }, `Error generating alt text for ${id}`)\n erroredDocs.push(id)\n }\n },\n { concurrency },\n )\n\n if (erroredDocs.length > 0) {\n req.payload.logger.error(`Failed for: ${erroredDocs.join(', ')}`)\n }\n\n return Response.json({\n erroredDocs,\n skippedDocs,\n totalDocs: uniqueIds.length,\n updatedDocs,\n })\n } catch (error) {\n if (error instanceof ZodError) {\n return Response.json(formatZodError(error), { status: 400 })\n }\n // Surface Payload access errors (Forbidden 403) with their real status so\n // an agent gets an accurate, non-retryable signal instead of a 500.\n if (error instanceof APIError) {\n return Response.json({ error: error.message }, { status: error.status })\n }\n req.payload.logger.error({ err: error }, 'Error in bulk generation')\n return Response.json(\n {\n error: `Error generating alt text: ${error instanceof Error ? error.message : 'Unknown error'}`,\n },\n { status: 500 },\n )\n }\n }\n\n/**\n * Why a document was left alone.\n *\n * - `notTracked` — the collection does not track this file type, so it needs no\n * alt text at all.\n * - `unsupportedFormat` — a tracked file whose format the resolver cannot read.\n * It still needs alt text; an editor has to write it.\n */\nexport type SkipReason = 'notTracked' | 'unsupportedFormat'\n\nexport type SkippedDoc = { id: number | string; reason: SkipReason }\n\n/** Returns why the document was skipped, or `undefined` once it has been written. */\nasync function generateAndUpdateAltText({\n id,\n collection,\n locales,\n payload,\n pluginConfig,\n req,\n}: {\n collection: CollectionSlug\n id: number | string\n locales: string[]\n payload: BasePayload\n pluginConfig: AltTextPluginConfig\n req: PayloadRequest\n}): Promise<{ detail: string; reason: SkipReason } | undefined> {\n const imageDoc = await payload.findByID({\n id,\n collection,\n depth: 0,\n // Run under the requesting user's access, not Payload's default\n // `overrideAccess: true`, so collection-level access control applies.\n overrideAccess: false,\n user: req.user,\n })\n\n if (!imageDoc) {\n throw new Error('Image not found')\n }\n\n const mimeType =\n 'mimeType' in imageDoc && typeof imageDoc.mimeType === 'string' ? imageDoc.mimeType : undefined\n\n // The handler validates `collection` against the configured collections before\n // reaching this helper, so a matching entry is guaranteed.\n const collectionConfig = pluginConfig.collections.find((entry) => entry.slug === collection)!\n\n if (mimeType && !matchesMimeType(mimeType, collectionConfig.mimeTypes)) {\n return {\n detail: `alt text is not tracked for files of type \"${mimeType}\" in the \"${collection}\" collection`,\n reason: 'notTracked',\n }\n }\n\n const unsupportedSourceError = getUnsupportedSourceMimeTypeError({\n declaredThumbnailMimeType: collectionConfig.imageThumbnailMimeType,\n mimeType,\n supportedMimeTypes: pluginConfig.resolver.supportedMimeTypes,\n })\n if (unsupportedSourceError) {\n return { detail: unsupportedSourceError, reason: 'unsupportedFormat' }\n }\n\n const imageThumbnailUrl = await pluginConfig.getImageThumbnail(imageDoc, { collection, req })\n\n const result = await pluginConfig.resolver.resolveBulk({\n filename:\n 'filename' in imageDoc && typeof imageDoc.filename === 'string'\n ? imageDoc.filename\n : undefined,\n imageThumbnailMimeType: collectionConfig.imageThumbnailMimeType,\n imageThumbnailUrl,\n locales,\n req,\n })\n\n if (!result.success) {\n throw new Error(result.error || 'Failed to generate alt text')\n }\n\n for (const locale of locales) {\n const localeResult = result.results[locale]\n if (localeResult) {\n await payload.update({\n id,\n collection,\n data: {\n alt: localeResult.altText,\n keywords: localeResult.keywords,\n },\n locale,\n // Run under the requesting user's access, not Payload's default\n // `overrideAccess: true`, so collection-level access control applies.\n overrideAccess: false,\n user: req.user,\n })\n }\n }\n}\n"],"names":["pMap","APIError","Forbidden","ZodError","getUnsupportedSourceMimeTypeError","matchesMimeType","resolveLocales","bulkGenerateAltTextsRequestSchema","formatZodError","bulkGenerateAltTextsEndpoint","access","req","Response","json","error","status","data","collection","ids","parse","updatedDocs","erroredDocs","skippedDocs","pluginConfig","payload","config","custom","altTextPluginConfig","collectionConfig","collections","find","entry","slug","resolver","concurrency","maxBulkGenerateConcurrency","uniqueIds","Set","length","maxBulkGenerateIds","targetLocales","id","skipReason","generateAndUpdateAltText","locales","push","reason","logger","info","detail","Math","round","err","join","totalDocs","message","Error","imageDoc","findByID","depth","overrideAccess","user","mimeType","undefined","mimeTypes","unsupportedSourceError","declaredThumbnailMimeType","imageThumbnailMimeType","supportedMimeTypes","imageThumbnailUrl","getImageThumbnail","result","resolveBulk","filename","success","locale","localeResult","results","update","alt","altText","keywords"],"mappings":"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"}
|