@@ -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 ( / ^ h t t p s : \/ \/ [ ^ \/ ] + \/ + / , '' ) ;
126+
127+ function getAttachmentMetas ( markdown : string ) : AttachmentMeta [ ] {
110128 if ( ! markdown ) {
111129 return [ ] ;
112130 }
113131
114- const s3PathRegex = / ! \[ .* ?\] \( ( h t t p s : \/ \/ .* ?\/ .* ?) ( \? .* ) ? \) / g;
115-
116- const matches = [ ...markdown . matchAll ( s3PathRegex ) ] ;
132+ // Minimal image syntax:  or  or 
133+ // We only track https URLs and only those that look like S3/AWS public URLs.
134+ const imageRegex = / ! \[ ( [ ^ \] ] * ) \] \( \s * ( h t t p s : \/ \/ [ ^ \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 ( / ^ h t t p s : \/ \/ [ ^ \/ ] + \/ + / , '' ) ;
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 }
0 commit comments