1- import { mkdir , readFile , writeFile } from 'node:fs/promises'
1+ import { mkdir , readFile , readdir , unlink , writeFile } from 'node:fs/promises'
22import { dirname , resolve } from 'node:path'
33import { fileURLToPath } from 'node:url'
44import { createRequire } from 'node:module'
@@ -110,7 +110,15 @@ const ensureDirectory = async (directoryPath) => {
110110 await mkdir ( directoryPath , { recursive : true } )
111111}
112112
113- const safeYamlScalar = ( value ) => JSON . stringify ( value ?? '' )
113+ const safeYamlScalar = ( value ) => {
114+ const text = String ( value ?? '' )
115+
116+ if ( text . includes ( '\n' ) ) {
117+ return JSON . stringify ( text )
118+ }
119+
120+ return `'${ text . replace ( / ' / g, "''" ) } '`
121+ }
114122
115123const formatFrontmatterValue = ( value , indent = 0 ) => {
116124 const prefix = ' ' . repeat ( indent )
@@ -192,6 +200,34 @@ const writeIfChanged = async (filePath, content) => {
192200 return true
193201}
194202
203+ const pruneGeneratedMarkdownFiles = async ( directoryPath , expectedFileNames ) => {
204+ const expected = new Set ( expectedFileNames )
205+
206+ let entries = [ ]
207+ try {
208+ entries = await readdir ( directoryPath , { withFileTypes : true } )
209+ } catch {
210+ return 0
211+ }
212+
213+ let deletedFiles = 0
214+
215+ for ( const entry of entries ) {
216+ if ( ! entry . isFile ( ) || ! entry . name . endsWith ( '.md' ) ) {
217+ continue
218+ }
219+
220+ if ( expected . has ( entry . name ) ) {
221+ continue
222+ }
223+
224+ await unlink ( resolve ( directoryPath , entry . name ) )
225+ deletedFiles += 1
226+ }
227+
228+ return deletedFiles
229+ }
230+
195231const getVideoId = ( video ) =>
196232 video && typeof video === 'object' && typeof video . id === 'string' && video . id . length > 0
197233 ? video . id
@@ -248,27 +284,78 @@ const sortVideosByDate = (videos) =>
248284 } )
249285
250286const dedupeEvents = ( events ) => {
251- const orderedIds = [ ]
252- const eventsById = new Map ( )
287+ const orderedKeys = [ ]
288+ const eventsByKey = new Map ( )
289+
290+ const getEventKey = ( event ) => {
291+ if ( ! event || typeof event !== 'object' ) {
292+ return null
293+ }
294+
295+ const link = typeof event . link === 'string' ? event . link . trim ( ) : ''
296+ if ( link ) {
297+ return `link:${ link } `
298+ }
299+
300+ const title = typeof event . title === 'string' ? normalizeForIdentity ( event . title ) : ''
301+ const date = typeof event . date === 'number' ? event . date : Number . NaN
302+ if ( title && Number . isFinite ( date ) ) {
303+ return `title-date:${ title } :${ date } `
304+ }
305+
306+ if ( event ?. sourceId ) {
307+ return `source:${ event . sourceId } `
308+ }
309+
310+ return null
311+ }
312+
313+ const choosePreferredEvent = ( current , incoming ) => {
314+ if ( ! current ) {
315+ return incoming
316+ }
317+
318+ const currentDescription = typeof current . description === 'string' && current . description . trim ( )
319+ const incomingDescription =
320+ typeof incoming . description === 'string' && incoming . description . trim ( )
321+ const currentLocation = typeof current . location === 'string' && current . location . trim ( )
322+ const incomingLocation = typeof incoming . location === 'string' && incoming . location . trim ( )
323+
324+ if ( ! currentDescription && incomingDescription ) {
325+ return { ...current , ...incoming }
326+ }
327+
328+ if ( ! currentLocation && incomingLocation ) {
329+ return { ...current , ...incoming }
330+ }
331+
332+ return { ...incoming , ...current }
333+ }
253334
254335 for ( const event of events ) {
255- if ( ! event ?. sourceId ) {
336+ const key = getEventKey ( event )
337+ if ( ! key ) {
256338 continue
257339 }
258340
259- if ( ! eventsById . has ( event . sourceId ) ) {
260- orderedIds . push ( event . sourceId )
341+ if ( ! eventsByKey . has ( key ) ) {
342+ orderedKeys . push ( key )
261343 }
262344
263- eventsById . set ( event . sourceId , {
264- ...eventsById . get ( event . sourceId ) ,
265- ...event ,
266- } )
345+ eventsByKey . set ( key , choosePreferredEvent ( eventsByKey . get ( key ) , event ) )
267346 }
268347
269- return orderedIds . map ( ( id ) => eventsById . get ( id ) )
348+ return orderedKeys . map ( ( key ) => eventsByKey . get ( key ) )
270349}
271350
351+ const normalizeForIdentity = ( value ) =>
352+ String ( value ?? '' )
353+ . normalize ( 'NFKD' )
354+ . replace ( / [ \u0300 - \u036f ] / g, '' )
355+ . toLowerCase ( )
356+ . replace ( / \s + / g, ' ' )
357+ . trim ( )
358+
272359const sortEventsByDate = ( events ) => [ ...events ] . sort ( ( a , b ) => a . date - b . date )
273360
274361const getYoutubeUploadsPlaylistId = ( channelId ) => {
@@ -619,18 +706,21 @@ const writeVideoContentFiles = async (members) => {
619706 await ensureDirectory ( groupDir )
620707
621708 const rawList = Array . isArray ( member . videoList ) ? member . videoList : [ ]
709+ const expectedFileNames = [ ]
710+
622711 for ( const [ index , videoRaw ] of rawList . entries ( ) ) {
623712 const video = normalizeVideoEntry ( groupId , groupName , groupLogo , videoRaw , index )
624713 if ( ! video ) {
625714 continue
626715 }
627716
628- const filePath = resolve (
629- groupDir ,
630- `${ toContentSlug ( video . sourceId , `${ groupId } -${ index + 1 } ` ) } .md` ,
631- )
717+ const fileName = `${ toContentSlug ( video . sourceId , `${ groupId } -${ index + 1 } ` ) } .md`
718+ expectedFileNames . push ( fileName )
719+ const filePath = resolve ( groupDir , fileName )
632720 writtenFiles += Number ( await writeIfChanged ( filePath , buildFrontmatterDocument ( video ) ) )
633721 }
722+
723+ await pruneGeneratedMarkdownFiles ( groupDir , expectedFileNames )
634724 }
635725
636726 return writtenFiles
@@ -644,8 +734,10 @@ const writeEventContentFiles = async (members, historicalEventsByGroup, rootEntr
644734 if ( rootEntry ) {
645735 const rootDir = resolve ( generatedEventsDir , 'root' )
646736 await ensureDirectory ( rootDir )
647- const filePath = resolve ( rootDir , `${ toContentSlug ( rootEntry . sourceId , 'root-next-event' ) } .md` )
737+ const rootFileName = `${ toContentSlug ( rootEntry . sourceId , 'root-next-event' ) } .md`
738+ const filePath = resolve ( rootDir , rootFileName )
648739 writtenFiles += Number ( await writeIfChanged ( filePath , buildFrontmatterDocument ( rootEntry ) ) )
740+ await pruneGeneratedMarkdownFiles ( rootDir , [ rootFileName ] )
649741 }
650742
651743 for ( const [ groupId , member ] of Object . entries ( members ) ) {
@@ -656,18 +748,21 @@ const writeEventContentFiles = async (members, historicalEventsByGroup, rootEntr
656748
657749 const events =
658750 historicalEventsByGroup . get ( groupId ) ?? getPersistedEventsForMember ( groupId , member )
751+ const expectedFileNames = [ ]
752+
659753 for ( const [ index , eventRaw ] of events . entries ( ) ) {
660754 const event = normalizeEventEntry ( groupId , groupName , groupLogo , eventRaw , index )
661755 if ( ! event ) {
662756 continue
663757 }
664758
665- const filePath = resolve (
666- groupDir ,
667- `${ toContentSlug ( event . sourceId , `${ groupId } -${ index + 1 } ` ) } .md` ,
668- )
759+ const fileName = `${ toContentSlug ( event . sourceId , `${ groupId } -${ index + 1 } ` ) } .md`
760+ expectedFileNames . push ( fileName )
761+ const filePath = resolve ( groupDir , fileName )
669762 writtenFiles += Number ( await writeIfChanged ( filePath , buildFrontmatterDocument ( event ) ) )
670763 }
764+
765+ await pruneGeneratedMarkdownFiles ( groupDir , expectedFileNames )
671766 }
672767
673768 return writtenFiles
0 commit comments