@aglyn/plugins-ai 1.0.0-beta.246 → 1.0.0-beta.247

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 (127) hide show
  1. package/package.json +17 -17
  2. package/src/lib/activity/ai-activity-actions.js +5 -1
  3. package/src/lib/activity/ai-activity-actions.js.map +1 -1
  4. package/src/lib/components/ai-job-links.js +14 -0
  5. package/src/lib/components/ai-job-links.js.map +1 -1
  6. package/src/lib/components/ai-site-start-card.component.d.ts +1 -1
  7. package/src/lib/components/ai-site-start-card.component.js +92 -19
  8. package/src/lib/components/ai-site-start-card.component.js.map +1 -1
  9. package/src/lib/declarations.server.js +12 -0
  10. package/src/lib/declarations.server.js.map +1 -1
  11. package/src/lib/jobs/ai-build-capabilities.js +56 -2
  12. package/src/lib/jobs/ai-build-capabilities.js.map +1 -1
  13. package/src/lib/jobs/ai-job-build-step.d.ts +3 -8
  14. package/src/lib/jobs/ai-job-build-step.js +49 -12
  15. package/src/lib/jobs/ai-job-build-step.js.map +1 -1
  16. package/src/lib/jobs/ai-job-form-dataset.d.ts +8 -5
  17. package/src/lib/jobs/ai-job-form-dataset.js +28 -10
  18. package/src/lib/jobs/ai-job-form-dataset.js.map +1 -1
  19. package/src/lib/jobs/ai-job-form-step.d.ts +20 -4
  20. package/src/lib/jobs/ai-job-form-step.js +98 -28
  21. package/src/lib/jobs/ai-job-form-step.js.map +1 -1
  22. package/src/lib/jobs/ai-job-layout-language.d.ts +6 -2
  23. package/src/lib/jobs/ai-job-layout-language.js +16 -5
  24. package/src/lib/jobs/ai-job-layout-language.js.map +1 -1
  25. package/src/lib/jobs/ai-job-layout-step.js +4 -1
  26. package/src/lib/jobs/ai-job-layout-step.js.map +1 -1
  27. package/src/lib/jobs/ai-job-media-step.d.ts +239 -0
  28. package/src/lib/jobs/ai-job-media-step.js +610 -0
  29. package/src/lib/jobs/ai-job-media-step.js.map +1 -0
  30. package/src/lib/jobs/ai-job-page-language.d.ts +2 -0
  31. package/src/lib/jobs/ai-job-page-language.js +9 -0
  32. package/src/lib/jobs/ai-job-page-language.js.map +1 -1
  33. package/src/lib/jobs/ai-job-page-sections.js +4 -3
  34. package/src/lib/jobs/ai-job-page-sections.js.map +1 -1
  35. package/src/lib/jobs/ai-job-page-step.js +3 -2
  36. package/src/lib/jobs/ai-job-page-step.js.map +1 -1
  37. package/src/lib/jobs/ai-job-plan-step.d.ts +10 -1
  38. package/src/lib/jobs/ai-job-plan-step.js +73 -15
  39. package/src/lib/jobs/ai-job-plan-step.js.map +1 -1
  40. package/src/lib/jobs/ai-job-settings-step.d.ts +0 -4
  41. package/src/lib/jobs/ai-job-settings-step.js +2 -7
  42. package/src/lib/jobs/ai-job-settings-step.js.map +1 -1
  43. package/src/lib/jobs/ai-job-site-content.d.ts +2 -2
  44. package/src/lib/jobs/ai-job-site-content.js +5 -3
  45. package/src/lib/jobs/ai-job-site-content.js.map +1 -1
  46. package/src/lib/jobs/ai-job-site-datasets.d.ts +1 -1
  47. package/src/lib/jobs/ai-job-site-datasets.js +3 -2
  48. package/src/lib/jobs/ai-job-site-datasets.js.map +1 -1
  49. package/src/lib/jobs/ai-job-site-events.d.ts +148 -0
  50. package/src/lib/jobs/ai-job-site-events.js +313 -0
  51. package/src/lib/jobs/ai-job-site-events.js.map +1 -0
  52. package/src/lib/jobs/ai-job-site-step.d.ts +9 -3
  53. package/src/lib/jobs/ai-job-site-step.js +63 -17
  54. package/src/lib/jobs/ai-job-site-step.js.map +1 -1
  55. package/src/lib/jobs/ai-job-tracking-step.d.ts +184 -0
  56. package/src/lib/jobs/ai-job-tracking-step.js +501 -0
  57. package/src/lib/jobs/ai-job-tracking-step.js.map +1 -0
  58. package/src/lib/jobs/ai-layout-stock-photos.d.ts +21 -0
  59. package/src/lib/jobs/ai-layout-stock-photos.js +41 -4
  60. package/src/lib/jobs/ai-layout-stock-photos.js.map +1 -1
  61. package/src/lib/jobs/ai-site-publish.js +2 -3
  62. package/src/lib/jobs/ai-site-publish.js.map +1 -1
  63. package/src/lib/jobs/ai-stock-photo-picker.d.ts +24 -0
  64. package/src/lib/jobs/ai-stock-photo-picker.js +108 -0
  65. package/src/lib/jobs/ai-stock-photo-picker.js.map +1 -0
  66. package/src/lib/jobs/fixtures/ai-jazz-events-build-plan-recording.d.ts +37 -0
  67. package/src/lib/jobs/fixtures/ai-jazz-events-build-plan-recording.js +47 -0
  68. package/src/lib/jobs/fixtures/ai-jazz-events-build-plan-recording.js.map +1 -0
  69. package/src/lib/jobs/fixtures/ai-stock-photo-agl3760-recordings.d.ts +21 -0
  70. package/src/lib/jobs/fixtures/ai-stock-photo-agl3760-recordings.js +128 -0
  71. package/src/lib/jobs/fixtures/ai-stock-photo-agl3760-recordings.js.map +1 -0
  72. package/src/lib/layout-language/ai-layout-compiler.d.ts +10 -4
  73. package/src/lib/layout-language/ai-layout-compiler.js +38 -19
  74. package/src/lib/layout-language/ai-layout-compiler.js.map +1 -1
  75. package/src/lib/layout-language/ai-layout-frame.d.ts +7 -5
  76. package/src/lib/layout-language/ai-layout-frame.js +12 -9
  77. package/src/lib/layout-language/ai-layout-frame.js.map +1 -1
  78. package/src/lib/layout-language/ai-layout-gaps.js +3 -1
  79. package/src/lib/layout-language/ai-layout-gaps.js.map +1 -1
  80. package/src/lib/layout-language/ai-layout-labels.d.ts +70 -0
  81. package/src/lib/layout-language/ai-layout-labels.js +162 -0
  82. package/src/lib/layout-language/ai-layout-labels.js.map +1 -0
  83. package/src/lib/layout-language/ai-layout-links.js +5 -2
  84. package/src/lib/layout-language/ai-layout-links.js.map +1 -1
  85. package/src/lib/layout-language/ai-layout-listings.d.ts +14 -1
  86. package/src/lib/layout-language/ai-layout-listings.js +32 -5
  87. package/src/lib/layout-language/ai-layout-listings.js.map +1 -1
  88. package/src/lib/model/ai-build-event-page.d.ts +70 -0
  89. package/src/lib/model/ai-build-event-page.js +145 -0
  90. package/src/lib/model/ai-build-event-page.js.map +1 -0
  91. package/src/lib/model/ai-build-job.d.ts +21 -0
  92. package/src/lib/model/ai-build-job.js +39 -0
  93. package/src/lib/model/ai-build-job.js.map +1 -1
  94. package/src/lib/model/ai-build-plan.d.ts +25 -14
  95. package/src/lib/model/ai-build-plan.js +33 -23
  96. package/src/lib/model/ai-build-plan.js.map +1 -1
  97. package/src/lib/model/ai-job-activity.js +3 -1
  98. package/src/lib/model/ai-job-activity.js.map +1 -1
  99. package/src/lib/model/ai-jobs.types.d.ts +10 -2
  100. package/src/lib/model/ai-jobs.types.js +3 -1
  101. package/src/lib/model/ai-jobs.types.js.map +1 -1
  102. package/src/lib/model/ai-plan-capabilities.d.ts +8 -0
  103. package/src/lib/model/ai-plan-capabilities.js.map +1 -1
  104. package/src/lib/model/ai-site-inventory.d.ts +2 -0
  105. package/src/lib/model/ai-site-inventory.js +5 -0
  106. package/src/lib/model/ai-site-inventory.js.map +1 -1
  107. package/src/lib/model/ai-site-job.d.ts +2 -2
  108. package/src/lib/model/ai-site-job.js +3 -3
  109. package/src/lib/model/ai-site-job.js.map +1 -1
  110. package/src/lib/runtime/ai-doctrine-validators.d.ts +25 -3
  111. package/src/lib/runtime/ai-doctrine-validators.js +53 -6
  112. package/src/lib/runtime/ai-doctrine-validators.js.map +1 -1
  113. package/src/lib/runtime/ai-doctrine.d.ts +6 -1
  114. package/src/lib/runtime/ai-doctrine.js +13 -9
  115. package/src/lib/runtime/ai-doctrine.js.map +1 -1
  116. package/src/lib/runtime/ai-eval-live.d.ts +1 -1
  117. package/src/lib/runtime/ai-eval-live.js +1 -1
  118. package/src/lib/runtime/ai-eval-live.js.map +1 -1
  119. package/src/lib/runtime/ai-palette.d.ts +3 -1
  120. package/src/lib/runtime/ai-palette.generated.js +498 -10
  121. package/src/lib/runtime/ai-palette.generated.js.map +1 -1
  122. package/src/lib/runtime/ai-palette.js +5 -3
  123. package/src/lib/runtime/ai-palette.js.map +1 -1
  124. package/src/lib/runtime/ai-template-examples.js +8 -2
  125. package/src/lib/runtime/ai-template-examples.js.map +1 -1
  126. package/src/lib/server.js +8 -0
  127. package/src/lib/server.js.map +1 -1
@@ -0,0 +1,610 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ /**
3
+ * @license
4
+ * Copyright 2026 Aglyn LLC
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this file except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ */ import { createResourceUid } from "@aglyn/aglyn/app-utils/create-resource-uid";
18
+ import { MEDIA_ALT_MAX_LENGTH } from "@aglyn/aglyn/app-utils/media-alt";
19
+ import { embeddedMetadataIsCurrent } from "@aglyn/aglyn/app-utils/media-embedded-fields";
20
+ import { MEDIA_FOLDER_MAX_DEPTH, MEDIA_FOLDER_NAME_MAX_LENGTH, newMediaFolderDoc, normalizeFolderName } from "@aglyn/aglyn/app-utils/media-folders";
21
+ import { MEDIA_TAG_MAX_COUNT, MEDIA_TAG_MAX_LENGTH, mediaFilterKeys, mediaNameWords, normalizeMediaTags } from "@aglyn/aglyn/app-utils/media-metadata";
22
+ import { hostRoleCanWrite } from "@aglyn/aglyn/app-utils/organizations";
23
+ import { getLockdownVerdict } from "@aglyn/tenant-data-admin/server/lockdown";
24
+ import { resolveOrgIdForHost } from "@aglyn/tenant-data-admin/server/organizations";
25
+ import { FieldValue } from "firebase-admin/firestore";
26
+ import { registerAiJobAdmission } from "./ai-job-admission.js";
27
+ import { aiJobEditModel } from "./ai-job-edit-step.js";
28
+ import { aiLimitReview, aiUnspentOutcome } from "./ai-job-generation.js";
29
+ import { registerAiJobStep } from "./ai-jobs.js";
30
+ /**
31
+ * The `media` step (AGL-3752): organizing files already in a site's own media
32
+ * library — the folder each is filed in, its tags, its alt text and its name —
33
+ * as a first-class build operation, so "put the kiln photos in a Studio
34
+ * folder", "tag these portfolio" or "write the missing alt text" is one item
35
+ * on Assist's plan card.
36
+ *
37
+ * NO MODEL. The planner wrote the values as the item's arguments; this holds
38
+ * each to the rule the media library's own controls hold it to and writes it,
39
+ * so it costs no credits.
40
+ *
41
+ * ── Only the library's own fields ────────────────────────────────────────
42
+ *
43
+ * Exactly what the Details drawer and the grid's bulk actions write on a
44
+ * file's document: `fileName` (200), `alt` (`MEDIA_ALT_MAX_LENGTH`), `tags`
45
+ * (`normalizeMediaTags`: lower case, 20 of 40 characters), and `folderId`
46
+ * with the legacy `folder` name beside it, with the library's filter and
47
+ * search keys re-derived from the document as the write leaves it
48
+ * (`mediaFilterKeys`). A new folder is the New folder button's: a
49
+ * `mediaFolders` document made by `newMediaFolderDoc`, its name held by
50
+ * `normalizeFolderName`, unique among its siblings without case, at most
51
+ * `MEDIA_FOLDER_MAX_DEPTH` deep. Filing a file writes its folder as the
52
+ * drawer's save does; the stored object takes the folder's path the next time
53
+ * that folder is moved or renamed, as after a drawer save. Its address never
54
+ * changes, so no page that uses it breaks.
55
+ *
56
+ * NEVER: uploading, copying, replacing or deleting a file, its bytes or the
57
+ * details inside them, its sharing or privacy, renaming or deleting a folder,
58
+ * or anything in the organization's shared library (every site of the
59
+ * workspace uses it). Those are not arguments.
60
+ *
61
+ * ── Alt text ─────────────────────────────────────────────────────────────
62
+ *
63
+ * Alt text describes ONE image: `alt` is for one image the request says what
64
+ * it shows. `altFromDetails` writes a missing alt from what the library
65
+ * already holds about the image — its description, or the caption, headline
66
+ * or title inside the file — and names the images that hold none. Never the
67
+ * file name, which read aloud is worse than silence (`inheritedMediaAlt`).
68
+ * The library's alt is what a placement takes when the image is placed;
69
+ * placements made before keep their own.
70
+ *
71
+ * ── Who may ──────────────────────────────────────────────────────────────
72
+ *
73
+ * The rules' own for a site library (`canWriteHostContent`,
74
+ * `hostWritesFrozen`): a member with the admin, editor or author role, while
75
+ * no lockdown freezes the site, its org or the platform. Asked at the door,
76
+ * before the write, and the role again inside the transaction that writes.
77
+ *
78
+ * ── Idempotent ───────────────────────────────────────────────────────────
79
+ *
80
+ * One transaction reads the files and folders as they are stored and writes
81
+ * only what differs; a folder of the asked name is found before one is made.
82
+ * A pass asked again writes nothing.
83
+ */ export const AI_MEDIA_KIND = 'media';
84
+ /** ASSUMED: the site, the org, up to 20 file lookups and one transaction. No model. */ export const AI_JOB_MEDIA_STEP_MINIMUM_MS = 8000;
85
+ export const AI_MEDIA_NO_SITE_COPY = 'Open the site whose media library should change.';
86
+ export const AI_MEDIA_ROLE_COPY = 'Organizing the media library needs the admin, editor or author role on this site.';
87
+ export const AI_MEDIA_LOCKED_COPY = 'This site is not accepting changes right now.';
88
+ export const AI_MEDIA_NOTHING_COPY = 'The request named no file in the media library and no change this could make to it.';
89
+ /** Where a person finds what the op changed. */ export const AI_MEDIA_WHERE = 'the site’s Media library';
90
+ /** The output's fixed id: one site's library, whatever changed. */ export const AI_MEDIA_OUTPUT_ID = 'library';
91
+ /** The library's own limits, each from the control that edits the field. */ export const AI_MEDIA_LIMITS = {
92
+ /** Files one item may name or select. */ files: 20,
93
+ /** The Details drawer's file name. */ fileName: 200,
94
+ alt: MEDIA_ALT_MAX_LENGTH,
95
+ tags: MEDIA_TAG_MAX_COUNT,
96
+ tag: MEDIA_TAG_MAX_LENGTH,
97
+ /** A folder path: five names of 60 and their separators. */ folder: MEDIA_FOLDER_MAX_DEPTH * (MEDIA_FOLDER_NAME_MAX_LENGTH + 3)
98
+ };
99
+ /** The item's arguments, as the planner fills them in. */ export const AI_MEDIA_ARGS_SCHEMA = {
100
+ type: 'object',
101
+ properties: {
102
+ files: {
103
+ type: 'array',
104
+ description: 'Files already in this site’s media library, each by its id or exact file name as the request gives it.',
105
+ items: {
106
+ type: 'string',
107
+ maxLength: 200
108
+ },
109
+ maxItems: AI_MEDIA_LIMITS.files
110
+ },
111
+ missingAlt: {
112
+ type: 'boolean',
113
+ description: 'True to also take the site library’s images that have no alt text yet (up to 20), for altFromDetails.'
114
+ },
115
+ folder: {
116
+ type: 'string',
117
+ description: 'The folder to file them in, as a path such as Portfolio / 2026; a folder not there yet is made.',
118
+ maxLength: AI_MEDIA_LIMITS.folder
119
+ },
120
+ addTags: {
121
+ type: 'array',
122
+ description: 'Tags to add, each a word or two.',
123
+ items: {
124
+ type: 'string',
125
+ maxLength: AI_MEDIA_LIMITS.tag
126
+ },
127
+ maxItems: AI_MEDIA_LIMITS.tags
128
+ },
129
+ removeTags: {
130
+ type: 'array',
131
+ description: 'Tags to take off.',
132
+ items: {
133
+ type: 'string',
134
+ maxLength: AI_MEDIA_LIMITS.tag
135
+ },
136
+ maxItems: AI_MEDIA_LIMITS.tags
137
+ },
138
+ rename: {
139
+ type: 'string',
140
+ description: 'A new file name, for exactly one file. Its address does not change.',
141
+ maxLength: AI_MEDIA_LIMITS.fileName
142
+ },
143
+ alt: {
144
+ type: 'string',
145
+ description: 'Alt text for exactly one image, saying what it shows in one plain sentence, only from what the request says about it. Never its file name.',
146
+ maxLength: AI_MEDIA_LIMITS.alt
147
+ },
148
+ altFromDetails: {
149
+ type: 'boolean',
150
+ description: 'True to write each image’s missing alt text from its own description or caption already in the library.'
151
+ }
152
+ },
153
+ additionalProperties: false
154
+ };
155
+ const text = (value)=>typeof value === 'string' ? value.replace(/\s+/g, ' ').trim() : '';
156
+ const list = (value)=>(Array.isArray(value) ? value : typeof value === 'string' ? [
157
+ value
158
+ ] : []).map(text).filter(Boolean);
159
+ /** A folder path's names, root first, or a sentence saying why it cannot be one. */ export function aiMediaFolderPathOf(value) {
160
+ const names = value.split('/').map((part)=>part.trim()).filter(Boolean);
161
+ if (!names.length) return {
162
+ error: 'The folder has no name.'
163
+ };
164
+ if (names.length > MEDIA_FOLDER_MAX_DEPTH) return {
165
+ error: `Folders can nest at most ${MEDIA_FOLDER_MAX_DEPTH} levels.`
166
+ };
167
+ const normalized = [];
168
+ for (const name of names){
169
+ const one = normalizeFolderName(name);
170
+ if (!one) return {
171
+ error: `A folder name is at most ${MEDIA_FOLDER_NAME_MAX_LENGTH} characters.`
172
+ };
173
+ normalized.push(one);
174
+ }
175
+ return normalized;
176
+ }
177
+ /** Words that name no image: alt text made of them alone describes nothing. */ const EMPTY_ALT = /^(an? |the )?(image|photo|photograph|picture|pic|graphic|img|icon|file|logo|banner|thumbnail)s?( of)?\.?$/i;
178
+ /** Why this alt text describes nothing, or `null`. */ export function aiMediaAltProblem(alt, fileName) {
179
+ if (EMPTY_ALT.test(alt.trim())) return `“${alt}” does not say what the image shows.`;
180
+ const folded = (value)=>mediaNameWords(value).toLowerCase().replace(/\s+/g, ' ');
181
+ const base = fileName.replace(/\.[A-Za-z0-9]{2,5}$/, '');
182
+ if (folded(alt) && (folded(alt) === folded(fileName) || folded(alt) === folded(base))) {
183
+ return 'Alt text that repeats the file name does not say what the image shows.';
184
+ }
185
+ return null;
186
+ }
187
+ /** The item's arguments read: what may be written, and a sentence for each that may not. */ export function aiMediaRequestOf(inputs) {
188
+ const problems = [];
189
+ const files = [
190
+ ...new Set(list(inputs == null ? void 0 : inputs['files']).map((one)=>one.slice(0, 200)))
191
+ ];
192
+ if (files.length > AI_MEDIA_LIMITS.files) problems.push(`One change takes at most ${AI_MEDIA_LIMITS.files} files; the rest were left.`);
193
+ const request = {
194
+ files: files.slice(0, AI_MEDIA_LIMITS.files),
195
+ missingAlt: (inputs == null ? void 0 : inputs['missingAlt']) === true,
196
+ addTags: normalizeMediaTags(list(inputs == null ? void 0 : inputs['addTags'])),
197
+ removeTags: normalizeMediaTags(list(inputs == null ? void 0 : inputs['removeTags'])),
198
+ altFromDetails: (inputs == null ? void 0 : inputs['altFromDetails']) === true
199
+ };
200
+ const folder = text(inputs == null ? void 0 : inputs['folder']);
201
+ if (folder) {
202
+ const path = aiMediaFolderPathOf(folder);
203
+ if (Array.isArray(path)) request.folder = path;
204
+ else problems.push(path.error);
205
+ }
206
+ const rename = text(inputs == null ? void 0 : inputs['rename']);
207
+ if (rename) {
208
+ if (rename.length > AI_MEDIA_LIMITS.fileName) problems.push(`A file name is at most ${AI_MEDIA_LIMITS.fileName} characters.`);
209
+ else if (request.files.length !== 1 || request.missingAlt) problems.push('A new name is for exactly one file.');
210
+ else request.rename = rename;
211
+ }
212
+ const alt = text(inputs == null ? void 0 : inputs['alt']);
213
+ if (alt) {
214
+ if (alt.length > AI_MEDIA_LIMITS.alt) problems.push(`Alt text is at most ${AI_MEDIA_LIMITS.alt} characters.`);
215
+ else if (request.files.length !== 1 || request.missingAlt) problems.push('Alt text describes one image; name exactly one.');
216
+ else request.alt = alt;
217
+ }
218
+ return {
219
+ request,
220
+ problems
221
+ };
222
+ }
223
+ /** Whether a read request names files and a change to make to them. */ export function aiMediaRequestEmpty(request) {
224
+ const selects = request.files.length > 0 || request.missingAlt;
225
+ const changes = request.folder !== undefined || request.addTags.length > 0 || request.removeTags.length > 0 || request.rename !== undefined || request.alt !== undefined || request.altFromDetails;
226
+ return !selects || !changes;
227
+ }
228
+ const fileNameOf = (row)=>{
229
+ var _row_data_fileName;
230
+ return String((_row_data_fileName = row.data['fileName']) != null ? _row_data_fileName : '') || row.id;
231
+ };
232
+ const isImage = (row)=>{
233
+ var _row_data_contentType;
234
+ return String((_row_data_contentType = row.data['contentType']) != null ? _row_data_contentType : '').startsWith('image/');
235
+ };
236
+ /**
237
+ * The file a name or id names among the rows read from the site's library:
238
+ * never a deleted one. Its id wins over a file name; two files of one name
239
+ * are refused in words.
240
+ */ export function aiMediaFileOf(source, rows) {
241
+ var _named_;
242
+ const live = rows.filter((row)=>row.data['deletedAt'] == null);
243
+ const byId = live.find((row)=>row.id === source);
244
+ if (byId) return byId;
245
+ const named = live.filter((row)=>row.data['fileName'] === source);
246
+ if (named.length > 1) return {
247
+ error: `The media library has more than one file called “${source}”; name it by its id.`
248
+ };
249
+ return (_named_ = named[0]) != null ? _named_ : {
250
+ error: `This site’s media library has no file “${source}”.`
251
+ };
252
+ }
253
+ /**
254
+ * Alt text written from what the library already holds about an image: its
255
+ * own description, else the caption, headline or title read from inside the
256
+ * file (only while that record still describes the file's bytes). `null`
257
+ * when it holds none that says what the image shows.
258
+ */ export function aiMediaAltFromDetails(row) {
259
+ const candidates = [
260
+ row.data['description']
261
+ ];
262
+ const embedded = row.data['embeddedMetadata'];
263
+ if (embeddedMetadataIsCurrent(embedded, row.data['contentSha256'])) {
264
+ const byKey = new Map(embedded.fields.map((field)=>[
265
+ field.key,
266
+ field.value
267
+ ]));
268
+ candidates.push(byKey.get('description'), byKey.get('headline'), byKey.get('title'));
269
+ }
270
+ for (const candidate of candidates){
271
+ const value = text(candidate);
272
+ if (!value) continue;
273
+ const alt = value.length > AI_MEDIA_LIMITS.alt ? `${value.slice(0, AI_MEDIA_LIMITS.alt - 1).replace(/\s+\S*$/, '')}…` : value;
274
+ if (!aiMediaAltProblem(alt, fileNameOf(row))) return alt;
275
+ }
276
+ return null;
277
+ }
278
+ /**
279
+ * Where a folder path lands: the existing folders it walks, matched without
280
+ * case among siblings as the library's sibling rule matches them, and the
281
+ * folders left to make below the last one found.
282
+ */ export function aiMediaFolderPlan(path, folders) {
283
+ let parentId = null;
284
+ let index = 0;
285
+ for(; index < path.length; index += 1){
286
+ const name = path[index].toLowerCase();
287
+ const found = folders.find((folder)=>folder.parentId === parentId && folder.name.trim().toLowerCase() === name);
288
+ if (!found) break;
289
+ parentId = found.id;
290
+ }
291
+ const create = path.slice(index).map((name, offset)=>({
292
+ name,
293
+ parent: offset === 0 ? {
294
+ id: parentId
295
+ } : {
296
+ made: offset - 1
297
+ }
298
+ }));
299
+ return {
300
+ existingId: parentId,
301
+ create
302
+ };
303
+ }
304
+ /**
305
+ * What the request changes on one file, compared with what is stored: only
306
+ * the fields that differ, with the filter keys re-derived from the document
307
+ * as the change leaves it, exactly as the Details drawer derives them.
308
+ */ export function aiMediaFileChange(row, request, folder) {
309
+ var _row_data_folderId;
310
+ const name = fileNameOf(row);
311
+ const update = {};
312
+ const said = [];
313
+ const notDone = [];
314
+ if (request.rename && request.rename !== row.data['fileName']) {
315
+ update['fileName'] = request.rename;
316
+ said.push(`“${name}” is now called “${request.rename}”.`);
317
+ }
318
+ const tags = Array.isArray(row.data['tags']) ? row.data['tags'].map(String) : [];
319
+ if (request.addTags.length || request.removeTags.length) {
320
+ const removed = new Set(request.removeTags);
321
+ const next = normalizeMediaTags([
322
+ ...tags,
323
+ ...request.addTags
324
+ ]).filter((tag)=>!removed.has(tag));
325
+ const dropped = request.addTags.filter((tag)=>!removed.has(tag) && !next.includes(tag));
326
+ if (dropped.length) notDone.push(`“${name}” holds ${AI_MEDIA_LIMITS.tags} tags at most, so ${dropped.join(', ')} was not added.`);
327
+ if (JSON.stringify(next) !== JSON.stringify(tags)) {
328
+ update['tags'] = next;
329
+ said.push(`“${name}” is tagged ${next.length ? next.join(', ') : 'with nothing'}.`);
330
+ }
331
+ }
332
+ if (folder && ((_row_data_folderId = row.data['folderId']) != null ? _row_data_folderId : null) !== folder.id) {
333
+ update['folderId'] = folder.id;
334
+ // The legacy name beside it, as the drawer keeps it until every reader is on folderId.
335
+ update['folder'] = folder.name;
336
+ said.push(`“${name}” is in ${folder.name}.`);
337
+ }
338
+ const storedAlt = text(row.data['alt']);
339
+ let alt = null;
340
+ if (request.alt) {
341
+ if (!isImage(row)) notDone.push(`“${name}” is not an image, so it takes no alt text.`);
342
+ else {
343
+ const problem = aiMediaAltProblem(request.alt, name);
344
+ if (problem) notDone.push(problem);
345
+ else alt = request.alt;
346
+ }
347
+ } else if (request.altFromDetails && isImage(row) && !storedAlt) {
348
+ alt = aiMediaAltFromDetails(row);
349
+ if (!alt) notDone.push(`“${name}” has no description or caption in the library to write its alt text from; say what it shows.`);
350
+ }
351
+ if (alt && alt !== storedAlt) {
352
+ update['alt'] = alt;
353
+ said.push(`“${name}” has alt text.`);
354
+ }
355
+ if (update['fileName'] !== undefined || update['alt'] !== undefined) {
356
+ Object.assign(update, mediaFilterKeys(_extends({}, row.data, update)));
357
+ }
358
+ return {
359
+ update,
360
+ said,
361
+ notDone
362
+ };
363
+ }
364
+ const ID_SHAPE = /^[A-Za-z0-9_-]{1,128}$/;
365
+ /**
366
+ * The library change, written in one transaction that reads the site, its
367
+ * folders and the named files as they are stored NOW: the member still holds
368
+ * a write role, a folder of the asked name is used before one is made, and
369
+ * only fields that differ are written.
370
+ */ export async function writeAiMediaChanges(firestore, input) {
371
+ const hostRef = firestore.collection('hosts').doc(input.hostId);
372
+ const mediaRef = hostRef.collection('media');
373
+ const foldersRef = hostRef.collection('mediaFolders');
374
+ const { request } = input;
375
+ return firestore.runTransaction(async (tx)=>{
376
+ var _host_get;
377
+ // Every read first: a transaction writes nothing until its reads are done.
378
+ const host = await tx.get(hostRef);
379
+ if (!host.exists) return {
380
+ ok: false,
381
+ error: 'Unknown site'
382
+ };
383
+ const roles = (_host_get = host.get('memberRoles')) != null ? _host_get : {};
384
+ if (!hostRoleCanWrite(roles[input.uid])) return {
385
+ ok: false,
386
+ error: AI_MEDIA_ROLE_COPY
387
+ };
388
+ const notDone = [];
389
+ const rows = new Map();
390
+ for (const source of request.files){
391
+ const read = [];
392
+ if (ID_SHAPE.test(source)) {
393
+ var _byId_data;
394
+ const byId = await tx.get(mediaRef.doc(source));
395
+ if (byId.exists) read.push({
396
+ id: byId.id,
397
+ data: (_byId_data = byId.data()) != null ? _byId_data : {}
398
+ });
399
+ }
400
+ const byName = await tx.get(mediaRef.where('fileName', '==', source).limit(5));
401
+ for (const doc of byName.docs)if (!read.some((row)=>row.id === doc.id)) read.push({
402
+ id: doc.id,
403
+ data: doc.data()
404
+ });
405
+ const found = aiMediaFileOf(source, read);
406
+ if ('error' in found) notDone.push(found.error);
407
+ else rows.set(found.id, found);
408
+ }
409
+ if (request.missingAlt) {
410
+ // The library's own "Alt text is missing" filter: `hasAlt`, which every writer derives.
411
+ const missing = await tx.get(mediaRef.where('hasAlt', '==', false).limit(AI_MEDIA_LIMITS.files * 2));
412
+ for (const doc of missing.docs){
413
+ const row = {
414
+ id: doc.id,
415
+ data: doc.data()
416
+ };
417
+ if (rows.size >= AI_MEDIA_LIMITS.files) break;
418
+ if (row.data['deletedAt'] == null && isImage(row)) rows.set(row.id, row);
419
+ }
420
+ if (!missing.docs.length && !request.files.length) notDone.push('Every image in this site’s media library has alt text.');
421
+ }
422
+ const folders = [];
423
+ if (request.folder && rows.size) {
424
+ const stored = await tx.get(foldersRef.limit(500));
425
+ for (const doc of stored.docs){
426
+ var _doc_get, _doc_get1;
427
+ folders.push({
428
+ id: doc.id,
429
+ name: String((_doc_get = doc.get('name')) != null ? _doc_get : ''),
430
+ parentId: (_doc_get1 = doc.get('parentId')) != null ? _doc_get1 : null
431
+ });
432
+ }
433
+ }
434
+ let folder = null;
435
+ const madeFolders = [];
436
+ if (request.folder && rows.size) {
437
+ var _made_;
438
+ const plan = aiMediaFolderPlan(request.folder, folders);
439
+ const made = [];
440
+ for (const one of plan.create){
441
+ const ref = foldersRef.doc(createResourceUid());
442
+ const parentId = 'id' in one.parent ? one.parent.id : made[one.parent.made];
443
+ // The New folder button's document: a site library's folder stores no scope.
444
+ tx.set(ref, newMediaFolderDoc({
445
+ name: one.name,
446
+ parentId,
447
+ createdAt: FieldValue.serverTimestamp(),
448
+ visibleTo: null
449
+ }));
450
+ made.push(ref.id);
451
+ madeFolders.push(one.name);
452
+ }
453
+ const id = (_made_ = made[made.length - 1]) != null ? _made_ : plan.existingId;
454
+ if (id) folder = {
455
+ id,
456
+ name: request.folder[request.folder.length - 1]
457
+ };
458
+ }
459
+ const done = madeFolders.map((name)=>`Made the folder ${name}.`);
460
+ let changedFiles = 0;
461
+ let altChanged = false;
462
+ for (const row of rows.values()){
463
+ const change = aiMediaFileChange(row, request, folder);
464
+ notDone.push(...change.notDone);
465
+ if (!Object.keys(change.update).length) continue;
466
+ tx.update(mediaRef.doc(row.id), _extends({}, change.update, {
467
+ updatedAt: input.now
468
+ }));
469
+ done.push(...change.said);
470
+ changedFiles += 1;
471
+ if (change.update['alt'] !== undefined) altChanged = true;
472
+ }
473
+ return {
474
+ ok: true,
475
+ done,
476
+ notDone,
477
+ changedFiles,
478
+ madeFolders,
479
+ altChanged
480
+ };
481
+ });
482
+ }
483
+ // ── The step ──────────────────────────────────────────────────────────────
484
+ /** Why this member may not organize this site's library now, or `null`. */ export async function aiMediaAccessRefusal(input, deps = {}) {
485
+ var _input_host_get, _deps_lockdown;
486
+ if (!input.host.exists) return 'Unknown site';
487
+ const roles = (_input_host_get = input.host.get('memberRoles')) != null ? _input_host_get : {};
488
+ if (!hostRoleCanWrite(roles[input.uid])) return AI_MEDIA_ROLE_COPY;
489
+ const locked = await ((_deps_lockdown = deps.lockdown) != null ? _deps_lockdown : getLockdownVerdict)({
490
+ staff: false,
491
+ uid: input.uid,
492
+ org: input.org,
493
+ host: input.host.data(),
494
+ intent: 'write'
495
+ });
496
+ return locked ? AI_MEDIA_LOCKED_COPY : null;
497
+ }
498
+ /** The output's note: what changed, that it is live, and what was not done. */ export function aiMediaOutputNote(done, notDone, altChanged) {
499
+ // The build's own wording for an item with no draft state (AGL-3745).
500
+ const did = done.length ? `${done.join(' ')} Live now, in ${AI_MEDIA_WHERE}.` : 'Nothing needed changing.';
501
+ const placed = altChanged ? ' Images placed from now on take the new alt text; ones already on a page keep their own.' : '';
502
+ const not = notDone.length ? ` Not done: ${notDone.join(' ')}` : '';
503
+ return `${did}${placed}${not}`.slice(0, 1000);
504
+ }
505
+ export function createAiJobMediaStep(deps = {}) {
506
+ var _deps_write;
507
+ const write = (_deps_write = deps.write) != null ? _deps_write : writeAiMediaChanges;
508
+ return async ({ job, now, firestore, org, modelFor })=>{
509
+ var _ref, _data;
510
+ // No model is asked: the outcome records the edit step's at no cost.
511
+ const model = (_ref = modelFor == null ? void 0 : modelFor('job.edit')) != null ? _ref : aiJobEditModel();
512
+ const unspent = (failure)=>_extends({}, aiUnspentOutcome(model), {
513
+ failure
514
+ });
515
+ const hostId = job.hostId;
516
+ if (!hostId) return unspent(AI_MEDIA_NO_SITE_COPY);
517
+ const read = aiMediaRequestOf(job.inputs);
518
+ if (aiMediaRequestEmpty(read.request)) return unspent([
519
+ AI_MEDIA_NOTHING_COPY,
520
+ ...read.problems
521
+ ].join(' '));
522
+ const orgData = org != null ? org : (_data = (await firestore.collection('orgs').doc(job.orgId).get()).data()) != null ? _data : null;
523
+ const host = await firestore.collection('hosts').doc(hostId).get();
524
+ const refused = await aiMediaAccessRefusal({
525
+ host,
526
+ uid: job.createdBy,
527
+ org: orgData
528
+ }, {
529
+ lockdown: deps.lockdown
530
+ });
531
+ if (refused) return aiUnspentOutcome(model, {
532
+ review: aiLimitReview(refused)
533
+ });
534
+ const written = await write(firestore, {
535
+ hostId,
536
+ uid: job.createdBy,
537
+ request: read.request,
538
+ now
539
+ });
540
+ if (written.ok === false) return unspent(written.error);
541
+ const notDone = [
542
+ ...read.problems,
543
+ ...written.notDone
544
+ ];
545
+ if (!written.done.length && notDone.length) return unspent(notDone.join(' '));
546
+ const output = {
547
+ resource: 'media',
548
+ id: AI_MEDIA_OUTPUT_ID,
549
+ hostId,
550
+ hostSubdomain: typeof host.get('subdomain') === 'string' && host.get('subdomain') || null,
551
+ label: 'Media library',
552
+ note: aiMediaOutputNote(written.done, notDone, written.altChanged)
553
+ };
554
+ return aiUnspentOutcome(model, {
555
+ outputs: [
556
+ output
557
+ ]
558
+ });
559
+ };
560
+ }
561
+ export const runAiJobMediaStep = createAiJobMediaStep();
562
+ /**
563
+ * A library change is admitted on a site of the job's own org, for a member
564
+ * who may write its library now, where the request names files and a change
565
+ * this can make. Refused in those words before anything is written.
566
+ */ export function createAiMediaJobAdmission(deps = {}) {
567
+ var _deps_ownerOf;
568
+ const ownerOf = (_deps_ownerOf = deps.ownerOf) != null ? _deps_ownerOf : resolveOrgIdForHost;
569
+ return async (context)=>{
570
+ var _context_uid;
571
+ if (!context.hostId) return {
572
+ status: 400,
573
+ error: AI_MEDIA_NO_SITE_COPY
574
+ };
575
+ const owner = await ownerOf(context.hostId);
576
+ if (!owner || owner !== context.orgId) return {
577
+ status: 404,
578
+ error: 'Unknown site'
579
+ };
580
+ const host = await context.firestore.collection('hosts').doc(context.hostId).get();
581
+ const refused = await aiMediaAccessRefusal({
582
+ host,
583
+ uid: (_context_uid = context.uid) != null ? _context_uid : '',
584
+ org: context.org
585
+ }, {
586
+ lockdown: deps.lockdown
587
+ });
588
+ if (refused) return {
589
+ status: 403,
590
+ error: refused
591
+ };
592
+ const read = aiMediaRequestOf(context.inputs);
593
+ return aiMediaRequestEmpty(read.request) ? {
594
+ status: 400,
595
+ error: [
596
+ AI_MEDIA_NOTHING_COPY,
597
+ ...read.problems
598
+ ].join(' ')
599
+ } : null;
600
+ };
601
+ }
602
+ export const aiMediaJobAdmission = createAiMediaJobAdmission();
603
+ /** Registers the media library step and its door (AGL-3752). */ export function registerAiMediaJob() {
604
+ registerAiJobStep(AI_MEDIA_KIND, runAiJobMediaStep, {
605
+ minimumMs: AI_JOB_MEDIA_STEP_MINIMUM_MS
606
+ });
607
+ registerAiJobAdmission(AI_MEDIA_KIND, aiMediaJobAdmission);
608
+ }
609
+
610
+ //# sourceMappingURL=ai-job-media-step.js.map