|
1 | 1 | /// <cts-enable /> |
2 | 2 | import { |
| 3 | + Cell, |
3 | 4 | cell, |
4 | 5 | Default, |
5 | 6 | derive, |
6 | 7 | fetchData, |
7 | | - lift, |
| 8 | + handler, |
8 | 9 | NAME, |
9 | 10 | recipe, |
10 | 11 | str, |
11 | 12 | UI, |
12 | 13 | } from "commontools"; |
13 | | -import { DOMParser, type Element } from "./dom-parser.ts"; |
| 14 | +import { type FeedItem, parseRSSFeed } from "./rss-utils.ts"; |
14 | 15 |
|
15 | 16 | interface Settings { |
16 | 17 | feedUrl: Default<string, "">; |
17 | 18 | limit: Default<number, 100>; |
18 | 19 | } |
19 | 20 |
|
20 | | -type FeedItem = { |
21 | | - id: string; |
22 | | - title: string; |
23 | | - link: string; |
24 | | - description: string; |
25 | | - pubDate: string; |
26 | | - author: string; |
27 | | - content: string; |
28 | | -}; |
29 | | - |
30 | | -function parseRSSFeed( |
31 | | - textXML: string, |
32 | | - maxResults: number = 100, |
33 | | - existingIds: Set<string>, |
34 | | -): FeedItem[] { |
35 | | - const parser = new DOMParser(); |
36 | | - const doc = parser.parseFromString(textXML, "text/xml"); |
37 | | - // Helper function to get text content from an element |
38 | | - const getTextContent = (element: Element | null, tagName: string) => { |
39 | | - const el = element?.getElementsByTagName(tagName)[0]; |
40 | | - return el?.textContent?.trim() || ""; |
41 | | - }; |
42 | | - |
43 | | - // Helper function to get attribute value |
44 | | - const getAttributeValue = ( |
45 | | - element: Element | null, |
46 | | - tagName: string, |
47 | | - attrName: string, |
48 | | - ) => { |
49 | | - const el = element?.getElementsByTagName(tagName)[0]; |
50 | | - return el?.getAttribute(attrName) || ""; |
51 | | - }; |
52 | | - |
53 | | - const retrievedItems: FeedItem[] = []; |
54 | | - |
55 | | - // Check if it's an Atom feed |
56 | | - const isAtom = doc.getElementsByTagName("feed").length !== 0; |
57 | | - |
58 | | - if (isAtom) { |
59 | | - // Parse Atom feed |
60 | | - const entries = doc.getElementsByTagName("entry"); |
61 | | - |
62 | | - for (let i = 0; i < Math.min(entries.length, maxResults); i++) { |
63 | | - const entry = entries[i]; |
64 | | - |
65 | | - // In Atom, id is mandatory |
66 | | - const id = getTextContent(entry, "id") || Math.random().toString(36); |
67 | | - |
68 | | - // Skip if we already have this item |
69 | | - if (existingIds.has(id)) { |
70 | | - continue; |
71 | | - } |
72 | | - |
73 | | - // Parse link - in Atom links are elements with href attributes |
74 | | - const link = getAttributeValue(entry, "link", "href"); |
75 | | - |
76 | | - // For content, check content tag first, then summary |
77 | | - const content = getTextContent(entry, "content") || |
78 | | - getTextContent(entry, "summary"); |
79 | | - |
80 | | - // For author, it might be nested as <author><name>Author</name></author> |
81 | | - let author = ""; |
82 | | - const authorEl = entry.getElementsByTagName("author")[0]; |
83 | | - if (authorEl) { |
84 | | - author = getTextContent(authorEl, "name"); |
85 | | - } |
86 | | - |
87 | | - // For pubDate, Atom uses <published> or <updated> |
88 | | - const pubDate = getTextContent(entry, "published") || |
89 | | - getTextContent(entry, "updated"); |
90 | | - |
91 | | - retrievedItems.push({ |
92 | | - id, |
93 | | - title: getTextContent(entry, "title"), |
94 | | - link, |
95 | | - description: getTextContent(entry, "summary"), |
96 | | - pubDate, |
97 | | - author, |
98 | | - content, |
99 | | - }); |
100 | | - } |
101 | | - } else { |
102 | | - // Parse RSS feed |
103 | | - const rssItems = doc.getElementsByTagName("item"); |
104 | | - |
105 | | - for (let i = 0; i < Math.min(rssItems.length, maxResults); i++) { |
106 | | - const item = rssItems[i]; |
107 | | - |
108 | | - const id = getTextContent(item, "guid") || |
109 | | - getTextContent(item, "link") || |
110 | | - Math.random().toString(36); |
111 | | - |
112 | | - if (existingIds.has(id)) { |
113 | | - continue; |
114 | | - } |
115 | | - |
116 | | - retrievedItems.push({ |
117 | | - id, |
118 | | - title: getTextContent(item, "title"), |
119 | | - link: getTextContent(item, "link"), |
120 | | - description: getTextContent(item, "description"), |
121 | | - pubDate: getTextContent(item, "pubDate"), |
122 | | - author: getTextContent(item, "author"), |
123 | | - content: getTextContent(item, "content:encoded") || |
124 | | - getTextContent(item, "description"), |
125 | | - }); |
126 | | - } |
127 | | - } |
128 | | - |
129 | | - return retrievedItems; |
130 | | -} |
131 | | - |
132 | | -const feedUpdater = lift<{ |
133 | | - items: FeedItem[]; |
| 21 | +const feedUpdater = handler<never, { |
| 22 | + items: Cell<FeedItem[]>; |
134 | 23 | settings: Settings; |
135 | | -}>(({ settings, items }) => { |
| 24 | +}>((_, { items, settings }) => { |
136 | 25 | if (!settings.feedUrl) { |
137 | 26 | console.warn("no feed URL provided"); |
138 | 27 | return; |
139 | 28 | } |
140 | 29 |
|
141 | 30 | const query = fetchData({ url: settings.feedUrl, mode: "text" }); |
142 | 31 | return derive( |
143 | | - { items, result: query.result, limit: settings.limit }, |
144 | | - ({ result, limit, items }) => { |
145 | | - if (!result || typeof result !== "string") return; |
| 32 | + { items, query, limit: settings.limit }, |
| 33 | + ({ query, limit, items }) => { |
| 34 | + if (!query.result || typeof query.result !== "string") return; |
146 | 35 | const newEntries = parseRSSFeed( |
147 | | - result as string, |
| 36 | + query.result as string, |
148 | 37 | limit, |
149 | | - new Set(items.map((item) => item.id)), |
| 38 | + new Set(items.get().map((item) => item.id)), |
150 | 39 | ); |
151 | 40 | items.push(...newEntries); |
152 | 41 | }, |
|
0 commit comments