@mrtimmy/payload-connector 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +88 -1
- package/package.json +1 -1
- package/src/index.d.ts +42 -0
- package/src/index.js +1 -1
- package/src/utils.js +393 -2
package/README.md
CHANGED
|
@@ -63,6 +63,11 @@ x-api-key: timmy_xxx
|
|
|
63
63
|
mrTimmyPayloadConnector({
|
|
64
64
|
postsCollection: "posts",
|
|
65
65
|
contentMode: "lexical",
|
|
66
|
+
media: {
|
|
67
|
+
collection: "media",
|
|
68
|
+
uploadFeaturedImage: true,
|
|
69
|
+
uploadInlineImages: true,
|
|
70
|
+
},
|
|
66
71
|
fields: {
|
|
67
72
|
title: "title",
|
|
68
73
|
content: "content",
|
|
@@ -75,6 +80,18 @@ mrTimmyPayloadConnector({
|
|
|
75
80
|
featuredImageUrl: "featuredImage",
|
|
76
81
|
imageAlt: "imageAlt",
|
|
77
82
|
contentAssets: "contentAssets",
|
|
83
|
+
categories: "categories",
|
|
84
|
+
tags: "tags",
|
|
85
|
+
},
|
|
86
|
+
taxonomies: {
|
|
87
|
+
categories: {
|
|
88
|
+
collection: "categories",
|
|
89
|
+
create: true,
|
|
90
|
+
},
|
|
91
|
+
tags: {
|
|
92
|
+
collection: "tags",
|
|
93
|
+
create: true,
|
|
94
|
+
},
|
|
78
95
|
},
|
|
79
96
|
})
|
|
80
97
|
```
|
|
@@ -87,10 +104,80 @@ mrTimmyPayloadConnector({
|
|
|
87
104
|
|
|
88
105
|
If your Payload post schema uses different field names, override `fields`.
|
|
89
106
|
|
|
107
|
+
## Media Uploads
|
|
108
|
+
|
|
109
|
+
If the configured `featuredImageUrl` field points to a Payload `upload` field,
|
|
110
|
+
the connector downloads the incoming image URL, uploads it to the configured
|
|
111
|
+
media collection, and stores the new media document ID on the post.
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
mrTimmyPayloadConnector({
|
|
115
|
+
media: {
|
|
116
|
+
collection: "media",
|
|
117
|
+
fields: {
|
|
118
|
+
alt: "alt",
|
|
119
|
+
title: "title",
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
fields: {
|
|
123
|
+
featuredImageUrl: "featuredImage",
|
|
124
|
+
},
|
|
125
|
+
})
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
If `featuredImageUrl` is a plain text/URL field, the connector keeps the old
|
|
129
|
+
behavior and stores the image URL instead.
|
|
130
|
+
|
|
131
|
+
Inline Markdown images in the article body are also uploaded to the media
|
|
132
|
+
collection when possible. Their URLs are rewritten before the content is
|
|
133
|
+
converted to HTML or Lexical JSON.
|
|
134
|
+
|
|
135
|
+
To disable media handling:
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
mrTimmyPayloadConnector({
|
|
139
|
+
media: false,
|
|
140
|
+
})
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## Categories and Tags
|
|
144
|
+
|
|
145
|
+
The connector accepts category/tag input from fields like `categories`,
|
|
146
|
+
`categoryNames`, `categorySlugs`, `tags`, `tagNames`, and `tagSlugs`.
|
|
147
|
+
|
|
148
|
+
If the target Payload fields are relationship fields, the connector looks up
|
|
149
|
+
matching documents by `slug`, `title`, or `name`. Missing terms are created by
|
|
150
|
+
default.
|
|
151
|
+
|
|
152
|
+
```ts
|
|
153
|
+
mrTimmyPayloadConnector({
|
|
154
|
+
fields: {
|
|
155
|
+
categories: "categories",
|
|
156
|
+
tags: "tags",
|
|
157
|
+
},
|
|
158
|
+
taxonomies: {
|
|
159
|
+
categories: {
|
|
160
|
+
collection: "categories",
|
|
161
|
+
labelField: "title",
|
|
162
|
+
slugField: "slug",
|
|
163
|
+
create: true,
|
|
164
|
+
},
|
|
165
|
+
tags: {
|
|
166
|
+
collection: "tags",
|
|
167
|
+
labelField: "title",
|
|
168
|
+
slugField: "slug",
|
|
169
|
+
create: true,
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
})
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
If your taxonomy collections use another required field, provide `createData`.
|
|
176
|
+
|
|
90
177
|
## Notes
|
|
91
178
|
|
|
92
179
|
- The plugin uses Payload Custom Endpoints and Payload Local API.
|
|
93
180
|
- Custom endpoints are authenticated by this plugin, not by Payload automatically.
|
|
94
181
|
- Draft/publish defaults to `_status: "draft" | "published"`, matching Payload draft-enabled collections.
|
|
95
182
|
- If your collection does not use drafts or uses a custom status field, configure `fields.status` and `statuses`, or set `fields.status` to `null`.
|
|
96
|
-
-
|
|
183
|
+
- Uploads use Payload's Local API with `filePath`; hooks and storage adapters on your media collection still run.
|
package/package.json
CHANGED
package/src/index.d.ts
CHANGED
|
@@ -12,6 +12,8 @@ export type FieldMap = {
|
|
|
12
12
|
featuredImageUrl?: string | null
|
|
13
13
|
imageAlt?: string | null
|
|
14
14
|
contentAssets?: string | null
|
|
15
|
+
categories?: string | null
|
|
16
|
+
tags?: string | null
|
|
15
17
|
}
|
|
16
18
|
|
|
17
19
|
export type ConnectorStatuses = {
|
|
@@ -19,6 +21,44 @@ export type ConnectorStatuses = {
|
|
|
19
21
|
published?: string
|
|
20
22
|
}
|
|
21
23
|
|
|
24
|
+
export type MediaOptions = false | {
|
|
25
|
+
collection?: string
|
|
26
|
+
uploadFeaturedImage?: boolean
|
|
27
|
+
uploadInlineImages?: boolean
|
|
28
|
+
maxBytes?: number
|
|
29
|
+
fields?: {
|
|
30
|
+
alt?: string | null
|
|
31
|
+
title?: string | null
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type TaxonomyTerm = {
|
|
36
|
+
id?: string | number
|
|
37
|
+
_id?: string | number
|
|
38
|
+
label?: string
|
|
39
|
+
title?: string
|
|
40
|
+
name?: string
|
|
41
|
+
slug?: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type TaxonomyOptions = false | {
|
|
45
|
+
collection?: string
|
|
46
|
+
create?: boolean
|
|
47
|
+
inputFields?: string[]
|
|
48
|
+
labelField?: string | null
|
|
49
|
+
slugField?: string | null
|
|
50
|
+
matchFields?: string[]
|
|
51
|
+
createData?: (
|
|
52
|
+
args: { label?: string; slug?: string; term: TaxonomyTerm },
|
|
53
|
+
req: unknown,
|
|
54
|
+
) => Record<string, unknown> | Promise<Record<string, unknown>>
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type TaxonomiesOptions = {
|
|
58
|
+
categories?: TaxonomyOptions
|
|
59
|
+
tags?: TaxonomyOptions
|
|
60
|
+
}
|
|
61
|
+
|
|
22
62
|
export type MrTimmyPayloadConnectorOptions = {
|
|
23
63
|
enabled?: boolean
|
|
24
64
|
postsCollection?: string
|
|
@@ -29,6 +69,8 @@ export type MrTimmyPayloadConnectorOptions = {
|
|
|
29
69
|
contentMode?: ContentMode
|
|
30
70
|
fields?: FieldMap
|
|
31
71
|
statuses?: ConnectorStatuses
|
|
72
|
+
media?: MediaOptions
|
|
73
|
+
taxonomies?: TaxonomiesOptions
|
|
32
74
|
mapCreateData?: (
|
|
33
75
|
data: Record<string, unknown>,
|
|
34
76
|
input: Record<string, unknown>,
|
package/src/index.js
CHANGED
package/src/utils.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import crypto from "node:crypto"
|
|
2
|
+
import fs from "node:fs/promises"
|
|
3
|
+
import os from "node:os"
|
|
4
|
+
import path from "node:path"
|
|
2
5
|
|
|
3
6
|
const optionalImport = (specifier) => import(/* @vite-ignore */ specifier)
|
|
4
7
|
|
|
@@ -18,6 +21,8 @@ export const DEFAULT_FIELDS = {
|
|
|
18
21
|
featuredImageUrl: "featuredImage",
|
|
19
22
|
imageAlt: "imageAlt",
|
|
20
23
|
contentAssets: "contentAssets",
|
|
24
|
+
categories: "categories",
|
|
25
|
+
tags: "tags",
|
|
21
26
|
}
|
|
22
27
|
|
|
23
28
|
export const DEFAULT_STATUSES = {
|
|
@@ -25,6 +30,34 @@ export const DEFAULT_STATUSES = {
|
|
|
25
30
|
published: "published",
|
|
26
31
|
}
|
|
27
32
|
|
|
33
|
+
export const DEFAULT_MEDIA = {
|
|
34
|
+
collection: "media",
|
|
35
|
+
uploadFeaturedImage: true,
|
|
36
|
+
uploadInlineImages: true,
|
|
37
|
+
maxBytes: 15 * 1024 * 1024,
|
|
38
|
+
fields: {
|
|
39
|
+
alt: "alt",
|
|
40
|
+
title: "title",
|
|
41
|
+
},
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const DEFAULT_TAXONOMIES = {
|
|
45
|
+
categories: {
|
|
46
|
+
create: true,
|
|
47
|
+
inputFields: ["categories", "categoryNames", "categorySlugs"],
|
|
48
|
+
labelField: "title",
|
|
49
|
+
slugField: "slug",
|
|
50
|
+
matchFields: ["slug", "title", "name"],
|
|
51
|
+
},
|
|
52
|
+
tags: {
|
|
53
|
+
create: true,
|
|
54
|
+
inputFields: ["tags", "tagNames", "tagSlugs"],
|
|
55
|
+
labelField: "title",
|
|
56
|
+
slugField: "slug",
|
|
57
|
+
matchFields: ["slug", "title", "name"],
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
|
|
28
61
|
export function generateConnectorKey(prefix = "timmy") {
|
|
29
62
|
return `${prefix}_${crypto.randomBytes(32).toString("hex")}`
|
|
30
63
|
}
|
|
@@ -50,6 +83,38 @@ export function normalizeFields(fields = {}) {
|
|
|
50
83
|
return { ...DEFAULT_FIELDS, ...fields }
|
|
51
84
|
}
|
|
52
85
|
|
|
86
|
+
export function normalizeMedia(media = {}) {
|
|
87
|
+
if (media === false) return { enabled: false }
|
|
88
|
+
return {
|
|
89
|
+
enabled: true,
|
|
90
|
+
...DEFAULT_MEDIA,
|
|
91
|
+
...media,
|
|
92
|
+
fields: {
|
|
93
|
+
...DEFAULT_MEDIA.fields,
|
|
94
|
+
...(media.fields ?? {}),
|
|
95
|
+
},
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function normalizeTaxonomies(taxonomies = {}) {
|
|
100
|
+
return {
|
|
101
|
+
categories:
|
|
102
|
+
taxonomies.categories === false
|
|
103
|
+
? false
|
|
104
|
+
: {
|
|
105
|
+
...DEFAULT_TAXONOMIES.categories,
|
|
106
|
+
...(taxonomies.categories ?? {}),
|
|
107
|
+
},
|
|
108
|
+
tags:
|
|
109
|
+
taxonomies.tags === false
|
|
110
|
+
? false
|
|
111
|
+
: {
|
|
112
|
+
...DEFAULT_TAXONOMIES.tags,
|
|
113
|
+
...(taxonomies.tags ?? {}),
|
|
114
|
+
},
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
53
118
|
export function setPath(target, path, value) {
|
|
54
119
|
if (!path || value === undefined || value === null) return target
|
|
55
120
|
const parts = String(path).split(".").filter(Boolean)
|
|
@@ -69,6 +134,81 @@ export function setPath(target, path, value) {
|
|
|
69
134
|
return target
|
|
70
135
|
}
|
|
71
136
|
|
|
137
|
+
export function slugify(value = "") {
|
|
138
|
+
return String(value)
|
|
139
|
+
.trim()
|
|
140
|
+
.toLowerCase()
|
|
141
|
+
.normalize("NFKD")
|
|
142
|
+
.replace(/[\u0300-\u036f]/g, "")
|
|
143
|
+
.replace(/ä/g, "ae")
|
|
144
|
+
.replace(/ö/g, "oe")
|
|
145
|
+
.replace(/ü/g, "ue")
|
|
146
|
+
.replace(/ß/g, "ss")
|
|
147
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
148
|
+
.replace(/^-+|-+$/g, "")
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function normalizeTermList(value) {
|
|
152
|
+
const values = Array.isArray(value) ? value : value ? [value] : []
|
|
153
|
+
return values
|
|
154
|
+
.map((entry) => {
|
|
155
|
+
if (entry && typeof entry === "object") {
|
|
156
|
+
const id = entry.id ?? entry._id
|
|
157
|
+
const label = entry.label ?? entry.title ?? entry.name ?? entry.slug
|
|
158
|
+
const slug = entry.slug ?? (label ? slugify(label) : undefined)
|
|
159
|
+
return id || label || slug ? { id, label, slug } : null
|
|
160
|
+
}
|
|
161
|
+
const label = String(entry ?? "").trim()
|
|
162
|
+
return label ? { label, slug: slugify(label) } : null
|
|
163
|
+
})
|
|
164
|
+
.filter(Boolean)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function getCollectionConfig(req, slug) {
|
|
168
|
+
return req?.payload?.config?.collections?.find(
|
|
169
|
+
(collection) => collection?.slug === slug,
|
|
170
|
+
)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function findFieldInFields(fields = [], parts = []) {
|
|
174
|
+
for (const field of fields) {
|
|
175
|
+
if (!field) continue
|
|
176
|
+
|
|
177
|
+
if (field.type === "tabs") {
|
|
178
|
+
for (const tab of field.tabs ?? []) {
|
|
179
|
+
const found = findFieldInFields(tab.fields ?? [], parts)
|
|
180
|
+
if (found) return found
|
|
181
|
+
}
|
|
182
|
+
continue
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (!field.name) {
|
|
186
|
+
const found = findFieldInFields(field.fields ?? [], parts)
|
|
187
|
+
if (found) return found
|
|
188
|
+
continue
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (field.name !== parts[0]) continue
|
|
192
|
+
if (parts.length === 1) return field
|
|
193
|
+
return findFieldInFields(field.fields ?? [], parts.slice(1))
|
|
194
|
+
}
|
|
195
|
+
return undefined
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function getFieldConfig(req, collectionSlug, fieldPath) {
|
|
199
|
+
if (!fieldPath) return undefined
|
|
200
|
+
const collection = getCollectionConfig(req, collectionSlug)
|
|
201
|
+
return findFieldInFields(collection?.fields ?? [], String(fieldPath).split("."))
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function fieldExpectsUpload(field) {
|
|
205
|
+
return field?.type === "upload"
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function fieldExpectsRelationship(field) {
|
|
209
|
+
return field?.type === "relationship"
|
|
210
|
+
}
|
|
211
|
+
|
|
72
212
|
export function extractExcerpt(markdown = "") {
|
|
73
213
|
const lines = String(markdown).split("\n")
|
|
74
214
|
for (const line of lines) {
|
|
@@ -95,6 +235,133 @@ export function buildApiUrlHint(req, endpointPath) {
|
|
|
95
235
|
return origin ? `${origin}/api${endpointPath}` : `/api${endpointPath}`
|
|
96
236
|
}
|
|
97
237
|
|
|
238
|
+
function absolutizeUrl(url, req) {
|
|
239
|
+
if (!url || typeof url !== "string") return url
|
|
240
|
+
if (/^https?:\/\//i.test(url)) return url
|
|
241
|
+
if (!url.startsWith("/")) return url
|
|
242
|
+
const origin = buildOriginFromRequest(req)
|
|
243
|
+
return origin ? `${origin}${url}` : url
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function isHttpUrl(value) {
|
|
247
|
+
return /^https?:\/\//i.test(String(value ?? ""))
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function extensionFor(contentType, sourceUrl) {
|
|
251
|
+
const fromType = {
|
|
252
|
+
"image/jpeg": ".jpg",
|
|
253
|
+
"image/jpg": ".jpg",
|
|
254
|
+
"image/png": ".png",
|
|
255
|
+
"image/webp": ".webp",
|
|
256
|
+
"image/gif": ".gif",
|
|
257
|
+
"image/avif": ".avif",
|
|
258
|
+
"image/svg+xml": ".svg",
|
|
259
|
+
}[String(contentType ?? "").split(";")[0].trim().toLowerCase()]
|
|
260
|
+
if (fromType) return fromType
|
|
261
|
+
const ext = path.extname(new URL(sourceUrl).pathname)
|
|
262
|
+
return ext || ".jpg"
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function safeFileBase(value) {
|
|
266
|
+
const base = slugify(value) || `mrtimmy-${Date.now()}`
|
|
267
|
+
return base.slice(0, 80)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function downloadImageToTempFile(sourceUrl, media) {
|
|
271
|
+
const response = await fetch(sourceUrl)
|
|
272
|
+
if (!response.ok) {
|
|
273
|
+
throw new Error(`Bild konnte nicht geladen werden: ${response.status}`)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const contentType =
|
|
277
|
+
response.headers.get("content-type") ?? "application/octet-stream"
|
|
278
|
+
if (!contentType.toLowerCase().startsWith("image/")) {
|
|
279
|
+
throw new Error(`URL ist kein Bild: ${contentType}`)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const buffer = Buffer.from(await response.arrayBuffer())
|
|
283
|
+
if (buffer.byteLength > media.maxBytes) {
|
|
284
|
+
throw new Error(
|
|
285
|
+
`Bild ist zu groß (${buffer.byteLength} Bytes, Limit ${media.maxBytes})`,
|
|
286
|
+
)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const extension = extensionFor(contentType, sourceUrl)
|
|
290
|
+
const filePath = path.join(
|
|
291
|
+
os.tmpdir(),
|
|
292
|
+
`${safeFileBase(path.basename(new URL(sourceUrl).pathname, extension))}-${crypto.randomBytes(8).toString("hex")}${extension}`,
|
|
293
|
+
)
|
|
294
|
+
await fs.writeFile(filePath, buffer)
|
|
295
|
+
return { filePath, contentType }
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function buildMediaData(media, alt, title) {
|
|
299
|
+
const data = {}
|
|
300
|
+
setPath(data, media.fields.alt, alt)
|
|
301
|
+
setPath(data, media.fields.title, title)
|
|
302
|
+
return data
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export async function uploadImageFromUrl(sourceUrl, media, req, meta = {}) {
|
|
306
|
+
if (!media.enabled || !isHttpUrl(sourceUrl)) return null
|
|
307
|
+
if (!getCollectionConfig(req, media.collection)?.upload) return null
|
|
308
|
+
|
|
309
|
+
const { filePath } = await downloadImageToTempFile(sourceUrl, media)
|
|
310
|
+
try {
|
|
311
|
+
const doc = await req.payload.create({
|
|
312
|
+
collection: media.collection,
|
|
313
|
+
data: buildMediaData(media, meta.alt, meta.title),
|
|
314
|
+
filePath,
|
|
315
|
+
depth: 0,
|
|
316
|
+
overrideAccess: true,
|
|
317
|
+
})
|
|
318
|
+
return {
|
|
319
|
+
id: doc.id ?? doc._id,
|
|
320
|
+
url: absolutizeUrl(doc.url, req),
|
|
321
|
+
doc,
|
|
322
|
+
originalUrl: sourceUrl,
|
|
323
|
+
}
|
|
324
|
+
} finally {
|
|
325
|
+
await fs.unlink(filePath).catch(() => {})
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export function extractMarkdownImages(markdown = "") {
|
|
330
|
+
const images = []
|
|
331
|
+
String(markdown).replace(
|
|
332
|
+
/!\[([^\]]*)\]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)/g,
|
|
333
|
+
(match, alt, url) => {
|
|
334
|
+
if (isHttpUrl(url)) images.push({ match, alt, url })
|
|
335
|
+
return match
|
|
336
|
+
},
|
|
337
|
+
)
|
|
338
|
+
return images
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async function rewriteInlineImages(markdown, media, req, cache) {
|
|
342
|
+
if (!media.enabled || !media.uploadInlineImages) return markdown
|
|
343
|
+
const images = extractMarkdownImages(markdown)
|
|
344
|
+
if (images.length === 0) return markdown
|
|
345
|
+
|
|
346
|
+
let next = String(markdown)
|
|
347
|
+
for (const image of images) {
|
|
348
|
+
const asset = await getUploadedImage(image.url, media, req, cache, {
|
|
349
|
+
alt: image.alt,
|
|
350
|
+
title: image.alt,
|
|
351
|
+
})
|
|
352
|
+
if (!asset?.url) continue
|
|
353
|
+
next = next.replace(image.url, asset.url)
|
|
354
|
+
}
|
|
355
|
+
return next
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
async function getUploadedImage(sourceUrl, media, req, cache, meta) {
|
|
359
|
+
if (!cache.has(sourceUrl)) {
|
|
360
|
+
cache.set(sourceUrl, uploadImageFromUrl(sourceUrl, media, req, meta))
|
|
361
|
+
}
|
|
362
|
+
return cache.get(sourceUrl)
|
|
363
|
+
}
|
|
364
|
+
|
|
98
365
|
export function readConnectorHeader(req, headerNames = DEFAULT_HEADER_NAMES) {
|
|
99
366
|
for (const name of headerNames) {
|
|
100
367
|
const value = req?.headers?.get?.(name)
|
|
@@ -128,12 +395,128 @@ export async function convertContent(markdown, mode, req) {
|
|
|
128
395
|
})
|
|
129
396
|
}
|
|
130
397
|
|
|
398
|
+
function taxonomyInput(input, config) {
|
|
399
|
+
for (const name of config.inputFields ?? []) {
|
|
400
|
+
const terms = normalizeTermList(input[name])
|
|
401
|
+
if (terms.length > 0) return terms
|
|
402
|
+
}
|
|
403
|
+
return []
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
async function findOrCreateTaxonomyDoc(term, config, collection, req) {
|
|
407
|
+
if (term.id) return term.id
|
|
408
|
+
|
|
409
|
+
const label = term.label ?? term.slug
|
|
410
|
+
const slug = term.slug ?? slugify(label)
|
|
411
|
+
const matchFields = config.matchFields ?? []
|
|
412
|
+
const where = {
|
|
413
|
+
or: matchFields.flatMap((field) => {
|
|
414
|
+
const values = field === config.slugField ? [slug, label] : [label, slug]
|
|
415
|
+
return [...new Set(values.filter(Boolean))].map((value) => ({
|
|
416
|
+
[field]: { equals: value },
|
|
417
|
+
}))
|
|
418
|
+
}),
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
if (where.or.length > 0) {
|
|
422
|
+
const result = await req.payload.find({
|
|
423
|
+
collection,
|
|
424
|
+
where,
|
|
425
|
+
depth: 0,
|
|
426
|
+
limit: 1,
|
|
427
|
+
overrideAccess: true,
|
|
428
|
+
})
|
|
429
|
+
const existing = result.docs?.[0]
|
|
430
|
+
if (existing) return existing.id ?? existing._id
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
if (!config.create) return undefined
|
|
434
|
+
|
|
435
|
+
const data =
|
|
436
|
+
typeof config.createData === "function"
|
|
437
|
+
? await config.createData({ label, slug, term }, req)
|
|
438
|
+
: {}
|
|
439
|
+
setPath(data, config.labelField, label)
|
|
440
|
+
setPath(data, config.slugField, slug)
|
|
441
|
+
|
|
442
|
+
const doc = await req.payload.create({
|
|
443
|
+
collection,
|
|
444
|
+
data,
|
|
445
|
+
depth: 0,
|
|
446
|
+
overrideAccess: true,
|
|
447
|
+
})
|
|
448
|
+
return doc.id ?? doc._id
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
async function resolveTaxonomyValues(input, options, req, key, fieldConfig) {
|
|
452
|
+
const config = options.taxonomies[key]
|
|
453
|
+
if (!config) return undefined
|
|
454
|
+
const terms = taxonomyInput(input, config)
|
|
455
|
+
if (terms.length === 0) return undefined
|
|
456
|
+
|
|
457
|
+
if (fieldExpectsRelationship(fieldConfig)) {
|
|
458
|
+
const relationTo = Array.isArray(fieldConfig.relationTo)
|
|
459
|
+
? fieldConfig.relationTo[0]
|
|
460
|
+
: fieldConfig.relationTo
|
|
461
|
+
const collection = config.collection ?? relationTo
|
|
462
|
+
if (!collection) return undefined
|
|
463
|
+
|
|
464
|
+
const ids = []
|
|
465
|
+
for (const term of terms) {
|
|
466
|
+
const id = await findOrCreateTaxonomyDoc(term, config, collection, req)
|
|
467
|
+
if (id) ids.push(id)
|
|
468
|
+
}
|
|
469
|
+
if (ids.length === 0) return undefined
|
|
470
|
+
return fieldConfig.hasMany === false ? ids[0] : ids
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const values = terms.map((term) => term.slug ?? term.label).filter(Boolean)
|
|
474
|
+
if (fieldConfig?.hasMany === false) return values[0]
|
|
475
|
+
return values
|
|
476
|
+
}
|
|
477
|
+
|
|
131
478
|
export async function buildPostData(input, options, req) {
|
|
132
479
|
const fields = normalizeFields(options.fields)
|
|
133
480
|
const statuses = { ...DEFAULT_STATUSES, ...(options.statuses ?? {}) }
|
|
481
|
+
const media = normalizeMedia(options.media)
|
|
482
|
+
const taxonomies = normalizeTaxonomies(options.taxonomies)
|
|
483
|
+
const resolvedOptions = { ...options, taxonomies }
|
|
134
484
|
const contentMode = options.contentMode ?? "lexical"
|
|
135
|
-
const
|
|
485
|
+
const uploadCache = new Map()
|
|
486
|
+
const markdown = await rewriteInlineImages(
|
|
487
|
+
input.content ?? input.body ?? "",
|
|
488
|
+
media,
|
|
489
|
+
req,
|
|
490
|
+
uploadCache,
|
|
491
|
+
)
|
|
136
492
|
const data = {}
|
|
493
|
+
const featuredImageField = getFieldConfig(
|
|
494
|
+
req,
|
|
495
|
+
options.postsCollection,
|
|
496
|
+
fields.featuredImageUrl,
|
|
497
|
+
)
|
|
498
|
+
const featuredImage = input.featuredImage
|
|
499
|
+
? fieldExpectsUpload(featuredImageField) && media.uploadFeaturedImage
|
|
500
|
+
? await getUploadedImage(input.featuredImage, media, req, uploadCache, {
|
|
501
|
+
alt: input.imageAlt ?? input.title,
|
|
502
|
+
title: input.title,
|
|
503
|
+
})
|
|
504
|
+
: { url: input.featuredImage }
|
|
505
|
+
: null
|
|
506
|
+
const categories = await resolveTaxonomyValues(
|
|
507
|
+
input,
|
|
508
|
+
resolvedOptions,
|
|
509
|
+
req,
|
|
510
|
+
"categories",
|
|
511
|
+
getFieldConfig(req, options.postsCollection, fields.categories),
|
|
512
|
+
)
|
|
513
|
+
const tags = await resolveTaxonomyValues(
|
|
514
|
+
input,
|
|
515
|
+
resolvedOptions,
|
|
516
|
+
req,
|
|
517
|
+
"tags",
|
|
518
|
+
getFieldConfig(req, options.postsCollection, fields.tags),
|
|
519
|
+
)
|
|
137
520
|
|
|
138
521
|
setPath(data, fields.title, input.title)
|
|
139
522
|
setPath(data, fields.content, await convertContent(markdown, contentMode, req))
|
|
@@ -142,9 +525,17 @@ export async function buildPostData(input, options, req) {
|
|
|
142
525
|
setPath(data, fields.metaTitle, input.metaTitle)
|
|
143
526
|
setPath(data, fields.metaDescription, input.metaDescription)
|
|
144
527
|
setPath(data, fields.focusKeyword, input.focusKeyword)
|
|
145
|
-
setPath(
|
|
528
|
+
setPath(
|
|
529
|
+
data,
|
|
530
|
+
fields.featuredImageUrl,
|
|
531
|
+
fieldExpectsUpload(featuredImageField)
|
|
532
|
+
? featuredImage?.id
|
|
533
|
+
: featuredImage?.url,
|
|
534
|
+
)
|
|
146
535
|
setPath(data, fields.imageAlt, input.imageAlt)
|
|
147
536
|
setPath(data, fields.contentAssets, input.contentAssets)
|
|
537
|
+
setPath(data, fields.categories, categories)
|
|
538
|
+
setPath(data, fields.tags, tags)
|
|
148
539
|
setPath(data, fields.status, statuses.draft)
|
|
149
540
|
|
|
150
541
|
return typeof options.mapCreateData === "function"
|