@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.
Files changed (40) hide show
  1. package/README.md +82 -39
  2. package/dist/components/BulkGenerateAltTextsButton.js +3 -16
  3. package/dist/components/BulkGenerateAltTextsButton.js.map +1 -1
  4. package/dist/components/summarizeBulkGenerate.d.ts +26 -0
  5. package/dist/components/summarizeBulkGenerate.js +56 -0
  6. package/dist/components/summarizeBulkGenerate.js.map +1 -0
  7. package/dist/endpoints/bulkGenerateAltTexts.d.ts +18 -1
  8. package/dist/endpoints/bulkGenerateAltTexts.js +31 -12
  9. package/dist/endpoints/bulkGenerateAltTexts.js.map +1 -1
  10. package/dist/endpoints/generateAltText.js +14 -8
  11. package/dist/endpoints/generateAltText.js.map +1 -1
  12. package/dist/index.d.ts +1 -1
  13. package/dist/index.js.map +1 -1
  14. package/dist/plugin.js +1 -0
  15. package/dist/plugin.js.map +1 -1
  16. package/dist/resolvers/anthropic.d.ts +3 -4
  17. package/dist/resolvers/anthropic.js +3 -4
  18. package/dist/resolvers/anthropic.js.map +1 -1
  19. package/dist/resolvers/createVisionResolver.d.ts +1 -1
  20. package/dist/resolvers/createVisionResolver.js.map +1 -1
  21. package/dist/resolvers/mistral.d.ts +4 -3
  22. package/dist/resolvers/mistral.js +4 -3
  23. package/dist/resolvers/mistral.js.map +1 -1
  24. package/dist/resolvers/openAI.d.ts +3 -3
  25. package/dist/resolvers/openAI.js +3 -3
  26. package/dist/resolvers/openAI.js.map +1 -1
  27. package/dist/translations/de.js +12 -4
  28. package/dist/translations/de.js.map +1 -1
  29. package/dist/translations/en.js +12 -4
  30. package/dist/translations/en.js.map +1 -1
  31. package/dist/translations/translation-schema.json +24 -8
  32. package/dist/types/AltTextPluginConfig.d.ts +24 -1
  33. package/dist/types/AltTextPluginConfig.js.map +1 -1
  34. package/dist/utilities/altTextHealth.d.ts +1 -1
  35. package/dist/utilities/altTextHealth.js +29 -4
  36. package/dist/utilities/altTextHealth.js.map +1 -1
  37. package/dist/utilities/resolveLocales.d.ts +15 -0
  38. package/dist/utilities/resolveLocales.js +38 -0
  39. package/dist/utilities/resolveLocales.js.map +1 -0
  40. 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. I 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.
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 for processing multiple images at once
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 | Required | Description |
82
- | ---------------------------- | ------------------------------------------ | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
83
- | `collections` | `(CollectionSlug \| CollectionObj)[]` | Yes | Collections to enable alt text generation for (see [Per-collection options](#per-collection-options)) |
84
- | `resolver` | `AltTextResolver` | Yes | Alt text resolver to use (e.g., `openAIResolver`) |
85
- | `getImageThumbnail` | `Function` | Yes | Function to get the thumbnail URL from an image document |
86
- | `enabled` | `boolean` | No | Whether to enable the plugin |
87
- | `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) |
88
- | `locale` | `string` | No | Locale for alt text generation (required when localization is disabled) |
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)) |
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 (shorthand, defaults to `['image/*']` for `mimeTypes`) or an object with the following fields:
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 — defaults to mimeTypes: ['image/*']
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
- With the declaration in place, the source format no longer gates generation — the admin button stays enabled and the endpoints stop rejecting on `mimeType`. 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.
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. Returning `{}` for every collection is the default behaviour.
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
- This plugin is designed to work seamlessly with various AI providers by accepting a customizable resolver as a configuration option.
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
- An OpenAI resolver is provided out of the box, but you can use any AI provider by creating your own resolver and specifying it in the plugin configuration.
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 all configured locales.
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": 5,
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
- if (data.erroredDocs.length > 0) {
37
- toast.error(t('@jhb.software/payload-alt-text-plugin:failedToGenerateForXImages', {
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 {\n erroredDocs: string[]\n totalDocs: number\n updatedDocs: number\n }\n\n if (data.erroredDocs.length > 0) {\n toast.error(\n t('@jhb.software/payload-alt-text-plugin:failedToGenerateForXImages', {\n count: data.erroredDocs.length,\n }),\n )\n }\n\n // in case not all images were updated, show a warning instead of a success message:\n if (data.updatedDocs === data.totalDocs) {\n toast.success(\n t('@jhb.software/payload-alt-text-plugin:xOfYImagesUpdated', {\n total: data.totalDocs,\n updated: data.updatedDocs,\n }),\n )\n } else {\n toast.warning(\n t('@jhb.software/payload-alt-text-plugin:xOfYImagesUpdated', {\n total: data.totalDocs,\n updated: data.updatedDocs,\n }),\n )\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","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","erroredDocs","length","count","updatedDocs","totalDocs","success","total","updated","warning","refresh","console","div","className","style","display","justifyContent","disabled","icon","onClick"],"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;AAOrC,SAASC,WAAW,QAAQ,kBAAiB;AAC7C,SAASC,SAAS,QAAQ,uBAAsB;AAChD,SAASC,OAAO,QAAQ,qBAAoB;AAE5C,OAAO,SAASC,2BAA2B,EAAEC,cAAc,EAA8B;IACvF,MAAM,EAAEC,CAAC,EAAE,GAAGR;IACd,MAAM,CAACS,WAAWC,gBAAgB,GAAGR;IACrC,MAAM,EAAES,WAAW,EAAE,GAAGd;IACxB,MAAM,EAAEe,QAAQ,EAAEC,YAAY,EAAE,GAAGd;IAEnC,MAAMe,sBAAsBC,QAAQJ,aAAaK,aAAa,CAACT,eAAe,EAAEU;IAChF,MAAM,EACJC,QAAQ,EACNC,QAAQ,EAAEC,KAAKC,QAAQ,EAAE,EACzBC,SAAS,EACV,EACF,GAAGxB;IAEJ,MAAMyB,cAAcC,MAAMC,IAAI,CAACb,SAASc,OAAO,IAC5CC,MAAM,CAAC,CAAC,GAAGC,WAAW,GAAKA,YAC3BC,GAAG,CAAC,CAAC,CAACC,GAAG,GAAKA;IAEjB,MAAMC,SAAS9B;IAEf,MAAM+B,yBAAyB;QAC7BtB,gBAAgB;YACd,IAAI,CAACH,gBAAgB;gBACnB,MAAM,IAAI0B,MAAM;YAClB;YAEA,IAAI;gBACF,MAAMC,WAAW,MAAMC,MAAM,GAAGb,aAAa,KAAKD,SAAS,CAAC,EAAElB,YAAY,cAAc,CAAC,EAAE;oBACzFiC,MAAMC,KAAKC,SAAS,CAAC;wBACnBC,YAAYhC;wBACZiC,KAAKjB;oBACP;oBACAkB,QAAQ;gBACV;gBAEA,IAAI,CAACP,SAASQ,EAAE,EAAE;oBAChB9C,MAAM+C,KAAK,CAACnC,EAAE;oBACd;gBACF;gBAEA,MAAMoC,OAAQ,MAAMV,SAASW,IAAI;gBAMjC,IAAID,KAAKE,WAAW,CAACC,MAAM,GAAG,GAAG;oBAC/BnD,MAAM+C,KAAK,CACTnC,EAAE,oEAAoE;wBACpEwC,OAAOJ,KAAKE,WAAW,CAACC,MAAM;oBAChC;gBAEJ;gBAEA,oFAAoF;gBACpF,IAAIH,KAAKK,WAAW,KAAKL,KAAKM,SAAS,EAAE;oBACvCtD,MAAMuD,OAAO,CACX3C,EAAE,2DAA2D;wBAC3D4C,OAAOR,KAAKM,SAAS;wBACrBG,SAAST,KAAKK,WAAW;oBAC3B;gBAEJ,OAAO;oBACLrD,MAAM0D,OAAO,CACX9C,EAAE,2DAA2D;wBAC3D4C,OAAOR,KAAKM,SAAS;wBACrBG,SAAST,KAAKK,WAAW;oBAC3B;gBAEJ;gBAEA,0CAA0C;gBAC1C,KAAK,MAAMnB,MAAMP,YAAa;oBAC5BV,aAAaiB;gBACf;gBAEAC,OAAOwB,OAAO;YAChB,EAAE,OAAOZ,OAAO;gBACda,QAAQb,KAAK,CAAC,8BAA8BA;gBAC5C/C,MAAM+C,KAAK,CAACnC,EAAE;YAChB;QACF;IACF;IAEA,OACEM,uBACAS,YAAYwB,MAAM,GAAG,mBACnB,KAACU;QAAIC,WAAU;QAAMC,OAAO;YAAEC,SAAS;YAAQC,gBAAgB;QAAQ;kBACrE,cAAA,KAAClE;YACC+D,WAAU;YACVI,UAAUrD,aAAac,YAAYwB,MAAM,KAAK;YAC9CgB,MAAMtD,0BAAY,KAACJ,6BAAa,KAACD;YACjC4D,SAAShC;sBAERxB,EAAE,4DAA4D;gBAC7DwC,OAAOzB,YAAYwB,MAAM;YAC3B;;;AAKV"}
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
- // determine target locales based on config
66
- const locales = localesFromConfig(req.payload.config);
67
- const targetLocales = locales ?? [
68
- pluginConfig.locale
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
- throw new Error(`Alt text is not tracked for files of type "${mimeType}" in the "${collection}" collection. Tracked types: ${collectionConfig.mimeTypes.join(', ')}.`);
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
- throw new Error(unsupportedSourceError);
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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"}