c2a3fdaf1163b43d80da4e7dd6ce0f1525fac57a
[rss-reader.git] / src / server.ts
1 import express, {Request, Response} from 'express';
2 import { join } from 'path';
3 import { query } from './lib/db';
4 import { v4 as uuidv4 } from 'uuid';
5 import { ingest, ingestSingle } from './ingester';
6 import {RSSParser} from './parsers/rss';
7 import bodyParser from 'body-parser';
8 import {BaseParser} from './parsers/base';
9
10
11 const app = express();
12
13 app.use(bodyParser.json());
14 app.use(bodyParser.urlencoded({ extended: true}));
15 app.use(express.static(join(__dirname, '..', 'html')));
16 app.use((req, res, next) => {
17   console.log(`${req.method} ${req.path}`);
18   next();
19 });
20
21 type WrappedApiHandler = (req: Request, res: Response) => Promise<any>;
22
23 function apiWrapper(method: 'get' | 'post' | 'delete', endpoint: string, fn: WrappedApiHandler, view?: (args: any) => string) : void {
24   app[method](endpoint, async(req, res) => {
25     try {
26       const output = await fn(req, res);
27       if(req.headers['content-type'] === 'application/json') {
28         res.json(output);
29         return;
30       }
31
32       if(view) {
33         const viewOutput = view(output);
34         if(viewOutput.length) {
35           res.send(viewOutput);
36           return;
37         }
38       }
39
40       res.status(204);
41     }
42     catch(e) {
43       console.error(e);
44       res.status(500);
45     }
46     finally {
47       res.end();
48     }
49   });
50 }
51
52 function apiGet(endpoint: string, fn: WrappedApiHandler, view?: (arr: any) => string): void {
53   apiWrapper('get', endpoint, fn, view);
54 }
55
56 function apiPost(endpoint: string, fn: WrappedApiHandler, view?: (arr: any) => string): void {
57   apiWrapper('post', endpoint, fn, view);
58 }
59 function apiDelete(endpoint: string, fn: WrappedApiHandler, view?: (arr: any) => string): void {
60   apiWrapper('delete', endpoint, fn, view);
61 }
62
63 apiPost('/feeds', async (req, res): Promise<any> => {
64   // get info about the feed
65   const url = req.body.link;
66
67   let parser: BaseParser;
68
69   // based on the url, we should figure out if this is a reddit or rss feed
70   parser = new RSSParser();
71
72   const feedData = await parser.parse(url);
73
74   const title = feedData.title;
75   const id = uuidv4();
76
77   // ingest teh feed, 
78
79   query.addFeed.run(id, title, url);
80
81   await ingestSingle(id);
82
83   res.setHeader('HX-Trigger', 'newFeed');
84
85   return {
86     id,
87     title,
88     url
89   }
90 });
91
92 apiGet('/feeds', async (req, res): Promise<any> => {
93   const feeds = query.getFeedList.all();
94   // get unread counts
95   const unread_count = query.getUnreadCountForAll.all();
96
97   const feedsWithUnread = feeds.map(feed => {
98     const unread = unread_count.filter(i => i.feed_id === feed.id);
99     return {
100       id: feed.id,
101       title: feed.title,
102       link: feed.link,
103       unread: (unread && unread.length) ? unread[0].unread : 0
104     }
105   });
106
107   return { 
108     feeds: feedsWithUnread
109   }
110 }, (output: any): string => {
111   const feeds = output.feeds;
112   return `<ul class="list">${feeds.map((feed: any, idx: number) =>{ 
113     const display = feed.unread ? `(${feed.unread})` : '';
114     const first = idx === 0;
115
116     return `<li><a href="#" class="${first ? 'active' : ''} ${feed.unread ? 'unread' : ''}" data-actions="activate" hx-get="/feeds/${feed.id}/items" hx-trigger="${first ? 'load,' : ''}click" hx-target="#list-pane" data-feed-id="${feed.id}">${feed.title} 
117     <span class="unread-count">${display}</span>
118     </a></li>`
119   }).join("\n")}</ul>`;
120 });
121
122 function reasonable(date: Date): string {
123   const month = date.getMonth() < 10 ? `0${date.getMonth()}` : date.getMonth();
124   const day = date.getDate() < 10 ? `0${date.getDate()}` : date.getDate();
125   return `${date.getFullYear()}-${month}-${day}`;
126 }
127
128 apiGet('/feeds/:feed_id', async (req, res): Promise<any> => {
129   const id = req.params.feed_id;
130   return query.getFeedInfo.get(id);
131 }, (feed: any): string => {
132   return `
133   <b>Feed:</b> ${feed.title}<br>
134   <b>Link:</b> ${feed.link}<br>
135   `;
136 });
137
138 apiGet('/feeds/:feed_id/items', async (req, res): Promise<any> => {
139   const id = req.params.feed_id;
140   return {
141     items: query.getFeedsFor.all(id, 0),
142     info: query.getFeedInfo.get(id)
143   }
144 }, (feedData: any): string => {
145   return `
146       <div id="feed-info">
147       <div id="feed-actions">
148         <a href="#" class="btn" hx-post="/feeds/${feedData.info.id}/items/markAsRead" hx-trigger="click">Mark all as Read</a>
149         <a href="#" class="btn" hx-delete="/feeds/${feedData.info.id}" hx-trigger="click" hx-confirm="Are you sure you want to delete this feed?">Delete Feed</a>
150       </div>
151       <b>Feed: </b>${feedData.info.title}<br>
152       </div>
153       <div>
154         <table><thead><tr><th style="width: 80%">Title</th><th>Publish Date</th></head></table>
155       </div>
156       <ul class="scrollable list" id="feed-item-list">
157   ${feedData.items.map((item: any, index: number) => {
158     const read = !!item.read_at;
159     return `<li>
160       <a href="#" class="${index === 0 ? 'active': ''} ${read ? '': 'unread'}" data-actions="activate" hx-get="/feeds/${item.feed_id}/items/${item.id}" hx-trigger="${index === 0 ? 'load,': ''}click" hx-target="#reading-pane" data-feed-item-id="${item.id}" data-feed-id="${item.feed_id}">${item.title}
161       <span class="date">${reasonable(new Date(item.pub_date * 1000))}</span>
162       </a>
163     </li>
164   `}).join("\n")}
165   </ul>
166   `;
167 });
168
169 apiPost('/feeds/:feed_id/items/markAsRead', async (req, res): Promise<any> => {
170   const id = req.params.feed_id;
171   query.readAllItems.run(id);
172   return;
173 });
174
175 apiGet('/feeds/:feed_id/items/:item_id', async (req, res) => {
176   const feed_id = req.params.feed_id;
177   const item_id = req.params.item_id;
178
179   query.readItem.run(Date.now(), item_id);
180   return query.getFeedItemInfo.get(item_id, feed_id);
181
182 }, (output: any): string => {
183   return `
184   <div id="meta">
185   <b>Title:</b> ${output.title}<br>
186   <b>Link:</b> <a href="${output.link}" target="_blank">${output.link}</a><br>
187   <b>Posted:</b> ${new Date(output.pub_date * 1000)}<brj
188   <b>Read: </b> ${new Date(output.read_at || undefined)}
189   </div>
190   <div class="scrollable" id="feed-content">${output.content}</div>
191   `
192   return output.content;
193 });
194
195 apiDelete('/feeds/:feed_id', async (req, res) => {
196   const id = req.params.feed_id;
197
198   query.deleteFeed.run(id);
199   res.setHeader('HX-Trigger', 'newFeed');
200   return;
201 });
202
203 async function periodicIngest() {
204   await ingest();
205   setTimeout(periodicIngest, 1000 * 60 * 10);
206 }
207
208 periodicIngest();
209
210 app.listen(process.env.PORT || 8000, () => {
211   console.log('Listening on port', process.env.PORT || 8000);
212 });