1
+ // src/utils/rss-utils.ts
2
+
3
+ import { exec , execFile } from 'node:child_process'
4
+ import { promisify } from 'node:util'
5
+ import { l , err } from '../utils/logging'
6
+
7
+ import type { TranscriptServices } from './types/transcription'
8
+ import type { LLMServices } from './types/llms'
9
+ import type { ProcessingOptions , RSSItem , HandlerFunction } from './types/process'
10
+
11
+ export const execPromise = promisify ( exec )
12
+ export const execFilePromise = promisify ( execFile )
13
+
14
+ /**
15
+ * Validates RSS flags (e.g., --last, --skip, --order, --date, --lastDays) without requiring feed data.
16
+ *
17
+ * @param options - The command-line options provided by the user
18
+ * @throws Exits the process if any flag is invalid
19
+ */
20
+ export function validateRSSOptions ( options : ProcessingOptions ) : void {
21
+ if ( options . last !== undefined ) {
22
+ if ( ! Number . isInteger ( options . last ) || options . last < 1 ) {
23
+ err ( 'Error: The --last option must be a positive integer.' )
24
+ process . exit ( 1 )
25
+ }
26
+ if ( options . skip !== undefined || options . order !== undefined ) {
27
+ err ( 'Error: The --last option cannot be used with --skip or --order.' )
28
+ process . exit ( 1 )
29
+ }
30
+ }
31
+
32
+ if ( options . skip !== undefined && ( ! Number . isInteger ( options . skip ) || options . skip < 0 ) ) {
33
+ err ( 'Error: The --skip option must be a non-negative integer.' )
34
+ process . exit ( 1 )
35
+ }
36
+
37
+ if ( options . order !== undefined && ! [ 'newest' , 'oldest' ] . includes ( options . order ) ) {
38
+ err ( "Error: The --order option must be either 'newest' or 'oldest'." )
39
+ process . exit ( 1 )
40
+ }
41
+
42
+ if ( options . lastDays !== undefined ) {
43
+ if ( ! Number . isInteger ( options . lastDays ) || options . lastDays < 1 ) {
44
+ err ( 'Error: The --lastDays option must be a positive integer.' )
45
+ process . exit ( 1 )
46
+ }
47
+ if (
48
+ options . last !== undefined ||
49
+ options . skip !== undefined ||
50
+ options . order !== undefined ||
51
+ ( options . date && options . date . length > 0 )
52
+ ) {
53
+ err ( 'Error: The --lastDays option cannot be used with --last, --skip, --order, or --date.' )
54
+ process . exit ( 1 )
55
+ }
56
+ }
57
+
58
+ if ( options . date && options . date . length > 0 ) {
59
+ const dateRegex = / ^ \d { 4 } - \d { 2 } - \d { 2 } $ /
60
+ for ( const d of options . date ) {
61
+ if ( ! dateRegex . test ( d ) ) {
62
+ err ( `Error: Invalid date format "${ d } ". Please use YYYY-MM-DD format.` )
63
+ process . exit ( 1 )
64
+ }
65
+ }
66
+
67
+ if (
68
+ options . last !== undefined ||
69
+ options . skip !== undefined ||
70
+ options . order !== undefined
71
+ ) {
72
+ err ( 'Error: The --date option cannot be used with --last, --skip, or --order.' )
73
+ process . exit ( 1 )
74
+ }
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Filters RSS feed items based on user-supplied options (e.g., item URLs, date ranges, etc.).
80
+ *
81
+ * @param options - Configuration options to filter the feed items
82
+ * @param feedItemsArray - Parsed array of RSS feed items (raw JSON from XML parser)
83
+ * @param channelTitle - Title of the RSS channel (optional)
84
+ * @param channelLink - URL to the RSS channel (optional)
85
+ * @param channelImage - A fallback channel image URL (optional)
86
+ * @returns Filtered RSS items based on the provided options
87
+ */
88
+ export async function filterRSSItems (
89
+ options : ProcessingOptions ,
90
+ feedItemsArray ?: any ,
91
+ channelTitle ?: string ,
92
+ channelLink ?: string ,
93
+ channelImage ?: string
94
+ ) : Promise < RSSItem [ ] > {
95
+ const defaultDate = new Date ( ) . toISOString ( ) . substring ( 0 , 10 )
96
+ const unfilteredItems : RSSItem [ ] = ( feedItemsArray || [ ] )
97
+ . filter ( ( item : any ) => {
98
+ if ( ! item . enclosure || ! item . enclosure . type ) return false
99
+ const audioVideoTypes = [ 'audio/' , 'video/' ]
100
+ return audioVideoTypes . some ( ( type ) => item . enclosure . type . startsWith ( type ) )
101
+ } )
102
+ . map ( ( item : any ) => {
103
+ let publishDate : string
104
+ try {
105
+ const date = item . pubDate ? new Date ( item . pubDate ) : new Date ( )
106
+ publishDate = date . toISOString ( ) . substring ( 0 , 10 )
107
+ } catch {
108
+ publishDate = defaultDate
109
+ }
110
+
111
+ return {
112
+ showLink : item . enclosure ?. url || '' ,
113
+ channel : channelTitle || '' ,
114
+ channelURL : channelLink || '' ,
115
+ title : item . title || '' ,
116
+ description : '' ,
117
+ publishDate,
118
+ coverImage : item [ 'itunes:image' ] ?. href || channelImage || '' ,
119
+ }
120
+ } )
121
+
122
+ let itemsToProcess : RSSItem [ ] = [ ]
123
+
124
+ if ( options . item && options . item . length > 0 ) {
125
+ itemsToProcess = unfilteredItems . filter ( ( it ) =>
126
+ options . item ! . includes ( it . showLink )
127
+ )
128
+ } else if ( options . lastDays !== undefined ) {
129
+ const now = new Date ( )
130
+ const cutoff = new Date ( now . getTime ( ) - options . lastDays * 24 * 60 * 60 * 1000 )
131
+
132
+ itemsToProcess = unfilteredItems . filter ( ( it ) => {
133
+ const itDate = new Date ( it . publishDate )
134
+ return itDate >= cutoff
135
+ } )
136
+ } else if ( options . date && options . date . length > 0 ) {
137
+ const selectedDates = new Set ( options . date )
138
+ itemsToProcess = unfilteredItems . filter ( ( it ) =>
139
+ selectedDates . has ( it . publishDate )
140
+ )
141
+ } else if ( options . last ) {
142
+ itemsToProcess = unfilteredItems . slice ( 0 , options . last )
143
+ } else {
144
+ const sortedItems =
145
+ options . order === 'oldest'
146
+ ? unfilteredItems . slice ( ) . reverse ( )
147
+ : unfilteredItems
148
+ itemsToProcess = sortedItems . slice ( options . skip || 0 )
149
+ }
150
+
151
+ return itemsToProcess
152
+ }
153
+
154
+ /**
155
+ * A helper function that validates RSS action input and processes it if valid.
156
+ * Separately validates flags with {@link validateRSSOptions} and leaves feed-item filtering to {@link filterRSSItems}.
157
+ *
158
+ * @param options - The ProcessingOptions containing RSS feed details
159
+ * @param handler - The function to handle each RSS feed
160
+ * @param llmServices - The optional LLM service for processing
161
+ * @param transcriptServices - The chosen transcription service
162
+ * @throws An error if no valid RSS URLs are provided
163
+ * @returns A promise that resolves when all RSS feeds have been processed
164
+ */
165
+ export async function validateRSSAction (
166
+ options : ProcessingOptions ,
167
+ handler : HandlerFunction ,
168
+ llmServices ?: LLMServices ,
169
+ transcriptServices ?: TranscriptServices
170
+ ) : Promise < void > {
171
+ if ( options . item && ! Array . isArray ( options . item ) ) {
172
+ options . item = [ options . item ]
173
+ }
174
+ if ( typeof options . rss === 'string' ) {
175
+ options . rss = [ options . rss ]
176
+ }
177
+
178
+ validateRSSOptions ( options )
179
+
180
+ const rssUrls = options . rss
181
+ if ( ! rssUrls || rssUrls . length === 0 ) {
182
+ throw new Error ( `No valid RSS URLs provided for processing` )
183
+ }
184
+
185
+ for ( const rssUrl of rssUrls ) {
186
+ await handler ( options , rssUrl , llmServices , transcriptServices )
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Logs the processing status and item counts for RSS feeds.
192
+ *
193
+ * @param total - Total number of RSS items found.
194
+ * @param processing - Number of RSS items to process.
195
+ * @param options - Configuration options.
196
+ */
197
+ export function logRSSProcessingStatus (
198
+ total : number ,
199
+ processing : number ,
200
+ options : ProcessingOptions
201
+ ) : void {
202
+ if ( options . item && options . item . length > 0 ) {
203
+ l . dim ( `\n - Found ${ total } items in the RSS feed.` )
204
+ l . dim ( ` - Processing ${ processing } specified items.` )
205
+ } else if ( options . last ) {
206
+ l . dim ( `\n - Found ${ total } items in the RSS feed.` )
207
+ l . dim ( ` - Processing the last ${ options . last } items.` )
208
+ } else {
209
+ l . dim ( `\n - Found ${ total } item(s) in the RSS feed.` )
210
+ l . dim ( ` - Processing ${ processing } item(s) after skipping ${ options . skip || 0 } .\n` )
211
+ }
212
+ }
0 commit comments