Skip to content

Commit 59ef714

Browse files
committed
feat: enhance attachment handling by adding title and alt field support in MarkdownPlugin - e.g. for sitemap generation task
1 parent 78891f1 commit 59ef714

File tree

2 files changed

+183
-54
lines changed

2 files changed

+183
-54
lines changed

index.ts

Lines changed: 169 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,20 @@ export default class MarkdownPlugin extends AdminForthPlugin {
5252
if (!field) {
5353
throw new Error(`Field '${this.options.attachments!.attachmentFieldName}' not found in resource '${this.options.attachments!.attachmentResource}'`);
5454
}
55+
56+
if (this.options.attachments.attachmentTitleFieldName) {
57+
const titleField = await resource.columns.find(c => c.name === this.options.attachments!.attachmentTitleFieldName);
58+
if (!titleField) {
59+
throw new Error(`Field '${this.options.attachments!.attachmentTitleFieldName}' not found in resource '${this.options.attachments!.attachmentResource}'`);
60+
}
61+
}
62+
63+
if (this.options.attachments.attachmentAltFieldName) {
64+
const altField = await resource.columns.find(c => c.name === this.options.attachments!.attachmentAltFieldName);
65+
if (!altField) {
66+
throw new Error(`Field '${this.options.attachments!.attachmentAltFieldName}' not found in resource '${this.options.attachments!.attachmentResource}'`);
67+
}
68+
}
5569

5670
const plugin = await adminforth.activatedPlugins.find(p =>
5771
p.resourceConfig!.resourceId === this.options.attachments!.attachmentResource &&
@@ -106,43 +120,74 @@ export default class MarkdownPlugin extends AdminForthPlugin {
106120
const editorRecordPkField = resourceConfig.columns.find(c => c.primaryKey);
107121
if (this.options.attachments) {
108122

109-
function getAttachmentPathes(markdown: string): string[] {
123+
type AttachmentMeta = { key: string; alt: string | null; title: string | null };
124+
125+
const extractKeyFromUrl = (url: string) => url.replace(/^https:\/\/[^\/]+\/+/, '');
126+
127+
function getAttachmentMetas(markdown: string): AttachmentMeta[] {
110128
if (!markdown) {
111129
return [];
112130
}
113131

114-
const s3PathRegex = /!\[.*?\]\((https:\/\/.*?\/.*?)(\?.*)?\)/g;
115-
116-
const matches = [...markdown.matchAll(s3PathRegex)];
132+
// Minimal image syntax: ![alt](src) or ![alt](src "title") or ![alt](src 'title')
133+
// We only track https URLs and only those that look like S3/AWS public URLs.
134+
const imageRegex = /!\[([^\]]*)\]\(\s*(https:\/\/[^\s)]+)\s*(?:\s+(?:"([^"]*)"|'([^']*)'))?\s*\)/g;
135+
136+
const byKey = new Map<string, AttachmentMeta>();
137+
for (const match of markdown.matchAll(imageRegex)) {
138+
const altRaw = match[1] ?? '';
139+
const srcRaw = match[2];
140+
const titleRaw = (match[3] ?? match[4]) ?? null;
141+
142+
const srcNoQuery = srcRaw.split('?')[0];
143+
if (!srcNoQuery.includes('s3') && !srcNoQuery.includes('amazonaws')) {
144+
continue;
145+
}
117146

118-
return matches
119-
.map(match => match[1])
120-
.filter(src => src.includes("s3") || src.includes("amazonaws"));
147+
const key = extractKeyFromUrl(srcNoQuery);
148+
byKey.set(key, {
149+
key,
150+
alt: altRaw,
151+
title: titleRaw,
152+
});
153+
}
154+
return [...byKey.values()];
121155
}
122156

123157
const createAttachmentRecords = async (
124-
adminforth: IAdminForth, options: PluginOptions, recordId: any, s3Paths: string[], adminUser: AdminUser
158+
adminforth: IAdminForth,
159+
options: PluginOptions,
160+
recordId: any,
161+
metas: AttachmentMeta[],
162+
adminUser: AdminUser
125163
) => {
126-
const extractKey = (s3Paths: string) => s3Paths.replace(/^https:\/\/[^\/]+\/+/, '');
164+
if (!metas.length) {
165+
return;
166+
}
127167
process.env.HEAVY_DEBUG && console.log('📸 Creating attachment records', JSON.stringify(recordId))
128168
try {
129-
await Promise.all(s3Paths.map(async (s3Path) => {
130-
console.log('Processing path:', s3Path);
169+
await Promise.all(metas.map(async (meta) => {
131170
try {
132-
await adminforth.createResourceRecord(
133-
{
134-
resource: this.attachmentResource,
135-
record: {
136-
[options.attachments.attachmentFieldName]: extractKey(s3Path),
137-
[options.attachments.attachmentRecordIdFieldName]: recordId,
138-
[options.attachments.attachmentResourceIdFieldName]: resourceConfig.resourceId,
139-
},
140-
adminUser,
141-
}
142-
);
143-
console.log('Successfully created record for:', s3Path);
171+
const recordToCreate: any = {
172+
[options.attachments.attachmentFieldName]: meta.key,
173+
[options.attachments.attachmentRecordIdFieldName]: recordId,
174+
[options.attachments.attachmentResourceIdFieldName]: resourceConfig.resourceId,
175+
};
176+
177+
if (options.attachments.attachmentTitleFieldName) {
178+
recordToCreate[options.attachments.attachmentTitleFieldName] = meta.title;
179+
}
180+
if (options.attachments.attachmentAltFieldName) {
181+
recordToCreate[options.attachments.attachmentAltFieldName] = meta.alt;
182+
}
183+
184+
await adminforth.createResourceRecord({
185+
resource: this.attachmentResource,
186+
record: recordToCreate,
187+
adminUser,
188+
});
144189
} catch (err) {
145-
console.error('Error creating record for', s3Path, err);
190+
console.error('Error creating record for', meta.key, err);
146191
}
147192
}));
148193
} catch (err) {
@@ -151,35 +196,95 @@ export default class MarkdownPlugin extends AdminForthPlugin {
151196
}
152197

153198
const deleteAttachmentRecords = async (
154-
adminforth: IAdminForth, options: PluginOptions, s3Paths: string[], adminUser: AdminUser
199+
adminforth: IAdminForth,
200+
options: PluginOptions,
201+
recordId: any,
202+
keys: string[],
203+
adminUser: AdminUser
155204
) => {
156-
if (!s3Paths.length) {
205+
if (!keys.length) {
157206
return;
158207
}
159208
const attachmentPrimaryKeyField = this.attachmentResource.columns.find(c => c.primaryKey);
160-
const attachments = await adminforth.resource(options.attachments.attachmentResource).list(
161-
Filters.IN(options.attachments.attachmentFieldName, s3Paths)
162-
);
209+
const attachments = await adminforth.resource(options.attachments.attachmentResource).list([
210+
Filters.EQ(options.attachments.attachmentRecordIdFieldName, recordId),
211+
Filters.EQ(options.attachments.attachmentResourceIdFieldName, resourceConfig.resourceId),
212+
Filters.IN(options.attachments.attachmentFieldName, keys),
213+
]);
214+
163215
await Promise.all(attachments.map(async (a: any) => {
164-
await adminforth.deleteResourceRecord(
165-
{
166-
resource: this.attachmentResource,
167-
recordId: a[attachmentPrimaryKeyField.name],
168-
adminUser,
169-
record: a,
170-
}
171-
)
216+
await adminforth.deleteResourceRecord({
217+
resource: this.attachmentResource,
218+
recordId: a[attachmentPrimaryKeyField.name],
219+
adminUser,
220+
record: a,
221+
})
172222
}))
173223
}
224+
225+
const updateAttachmentRecordsMetadata = async (
226+
adminforth: IAdminForth,
227+
options: PluginOptions,
228+
recordId: any,
229+
metas: AttachmentMeta[],
230+
adminUser: AdminUser
231+
) => {
232+
if (!metas.length) {
233+
return;
234+
}
235+
if (!options.attachments.attachmentTitleFieldName && !options.attachments.attachmentAltFieldName) {
236+
return;
237+
}
238+
const attachmentPrimaryKeyField = this.attachmentResource.columns.find(c => c.primaryKey);
239+
const metaByKey = new Map(metas.map(m => [m.key, m] as const));
240+
241+
const existingAparts = await adminforth.resource(options.attachments.attachmentResource).list([
242+
Filters.EQ(options.attachments.attachmentRecordIdFieldName, recordId),
243+
Filters.EQ(options.attachments.attachmentResourceIdFieldName, resourceConfig.resourceId)
244+
]);
245+
246+
await Promise.all(existingAparts.map(async (a: any) => {
247+
const key = a[options.attachments.attachmentFieldName];
248+
const meta = metaByKey.get(key);
249+
if (!meta) {
250+
return;
251+
}
252+
253+
const patch: any = {};
254+
if (options.attachments.attachmentTitleFieldName) {
255+
const field = options.attachments.attachmentTitleFieldName;
256+
if ((a[field] ?? null) !== (meta.title ?? null)) {
257+
patch[field] = meta.title;
258+
}
259+
}
260+
if (options.attachments.attachmentAltFieldName) {
261+
const field = options.attachments.attachmentAltFieldName;
262+
if ((a[field] ?? null) !== (meta.alt ?? null)) {
263+
patch[field] = meta.alt;
264+
}
265+
}
266+
if (!Object.keys(patch).length) {
267+
return;
268+
}
269+
270+
await adminforth.updateResourceRecord({
271+
resource: this.attachmentResource,
272+
recordId: a[attachmentPrimaryKeyField.name],
273+
record: patch,
274+
oldRecord: a,
275+
adminUser,
276+
});
277+
}));
278+
}
174279

175280
(resourceConfig.hooks.create.afterSave).push(async ({ record, adminUser }: { record: any, adminUser: AdminUser }) => {
176281
// find all s3Paths in the html
177-
const s3Paths = getAttachmentPathes(record[this.options.fieldName])
178-
179-
process.env.HEAVY_DEBUG && console.log('📸 Found s3Paths', s3Paths);
282+
const metas = getAttachmentMetas(record[this.options.fieldName]);
283+
const keys = metas.map(m => m.key);
284+
process.env.HEAVY_DEBUG && console.log('📸 Found attachment keys', keys);
180285
// create attachment records
181286
await createAttachmentRecords(
182-
adminforth, this.options, record[editorRecordPkField.name], s3Paths, adminUser);
287+
adminforth, this.options, record[editorRecordPkField.name], metas, adminUser);
183288

184289
return { ok: true };
185290
});
@@ -198,19 +303,29 @@ export default class MarkdownPlugin extends AdminForthPlugin {
198303
Filters.EQ(this.options.attachments.attachmentRecordIdFieldName, recordId),
199304
Filters.EQ(this.options.attachments.attachmentResourceIdFieldName, resourceConfig.resourceId)
200305
]);
201-
const existingS3Paths = existingAparts.map((a: any) => a[this.options.attachments.attachmentFieldName]);
202-
const newS3Paths = getAttachmentPathes(record[this.options.fieldName]);
203-
process.env.HEAVY_DEBUG && console.log('📸 Existing s3Paths (from db)', existingS3Paths)
204-
process.env.HEAVY_DEBUG && console.log('📸 Found new s3Paths (from text)', newS3Paths);
205-
const toDelete = existingS3Paths.filter(s3Path => !newS3Paths.includes(s3Path));
206-
const toAdd = newS3Paths.filter(s3Path => !existingS3Paths.includes(s3Path));
207-
process.env.HEAVY_DEBUG && console.log('📸 Found s3Paths to delete', toDelete)
208-
process.env.HEAVY_DEBUG && console.log('📸 Found s3Paths to add', toAdd);
306+
const existingKeys = existingAparts.map((a: any) => a[this.options.attachments.attachmentFieldName]);
307+
308+
const metas = getAttachmentMetas(record[this.options.fieldName]);
309+
const newKeys = metas.map(m => m.key);
310+
311+
process.env.HEAVY_DEBUG && console.log('📸 Existing keys (from db)', existingKeys)
312+
process.env.HEAVY_DEBUG && console.log('📸 Found new keys (from text)', newKeys);
313+
314+
const toDelete = existingKeys.filter(key => !newKeys.includes(key));
315+
const toAdd = newKeys.filter(key => !existingKeys.includes(key));
316+
317+
process.env.HEAVY_DEBUG && console.log('📸 Found keys to delete', toDelete)
318+
process.env.HEAVY_DEBUG && console.log('📸 Found keys to add', toAdd);
319+
320+
const metasToAdd = metas.filter(m => toAdd.includes(m.key));
209321
await Promise.all([
210-
deleteAttachmentRecords(adminforth, this.options, toDelete, adminUser),
211-
createAttachmentRecords(adminforth, this.options, recordId, toAdd, adminUser)
322+
deleteAttachmentRecords(adminforth, this.options, recordId, toDelete, adminUser),
323+
createAttachmentRecords(adminforth, this.options, recordId, metasToAdd, adminUser)
212324
]);
213325

326+
// Keep alt/title in sync for existing attachments too
327+
await updateAttachmentRecordsMetadata(adminforth, this.options, recordId, metas, adminUser);
328+
214329
return { ok: true };
215330

216331
}
@@ -225,9 +340,9 @@ export default class MarkdownPlugin extends AdminForthPlugin {
225340
Filters.EQ(this.options.attachments.attachmentResourceIdFieldName, resourceConfig.resourceId)
226341
]
227342
);
228-
const existingS3Paths = existingAparts.map((a: any) => a[this.options.attachments.attachmentFieldName]);
229-
process.env.HEAVY_DEBUG && console.log('📸 Found s3Paths to delete', existingS3Paths);
230-
await deleteAttachmentRecords(adminforth, this.options, existingS3Paths, adminUser);
343+
const existingKeys = existingAparts.map((a: any) => a[this.options.attachments.attachmentFieldName]);
344+
process.env.HEAVY_DEBUG && console.log('📸 Found keys to delete', existingKeys);
345+
await deleteAttachmentRecords(adminforth, this.options, record[editorRecordPkField.name], existingKeys, adminUser);
231346

232347
return { ok: true };
233348
}

types.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,5 +38,19 @@ export interface PluginOptions {
3838
* Why we force to store and ask for resource id if we already have record id? Because in amny use cases attachments resource is shared between multiple resources, and record id might be not be unique across resources, but resource id + record id will be always unique.
3939
*/
4040
attachmentResourceIdFieldName: string; // e.g. 'apartment_resource_id',
41+
42+
/**
43+
* Optional: field name in attachment resource where title of image will be stored.
44+
* When in markdown title of image is mentioned e.g. ![alt](image.jpg "title"), it will be parsed and stored in attachment resource.
45+
* If you will update title in markdown, it will be updated in attachment resource as well.
46+
*/
47+
attachmentTitleFieldName?: string; // e.g. 'title',
48+
49+
/**
50+
* Optional: field name in attachment resource where alt of image will be stored.
51+
* When in markdown alt of image is mentioned e.g. ![alt](image.jpg), it will be parsed and stored in attachment resource.
52+
* If you will update alt in markdown, it will be updated in attachment resource as well.
53+
*/
54+
attachmentAltFieldName?: string; // e.g. 'alt',
4155
},
4256
}

0 commit comments

Comments
 (0)