f14064906fb45858d6421aaec4803e769d5780a3
[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
9
10 const app = express();
11
12 app.use(bodyParser.json());
13 app.use(bodyParser.urlencoded({ extended: true}));
14 app.use(express.static(join(__dirname, '..', 'html')));
15 app.use((req, res, next) => {
16   console.log(`${req.method} ${req.path}`);
17   next();
18 });
19
20 type WrappedApiHandler = (req: Request, res: Response) => Promise<any>;
21
22 function apiWrapper(method: 'get' | 'post' | 'delete', endpoint: string, fn: WrappedApiHandler, view?: (args: any) => string) : void {
23   app[method](endpoint, async(req, res) => {
24     try {
25       const output = await fn(req, res);
26       if(req.headers['content-type'] === 'application/json') {
27         res.json(output);
28         return;
29       }
30
31       if(view) {
32         const viewOutput = view(output);
33         if(viewOutput.length) {
34           res.send(viewOutput);
35           return;
36         }
37       }
38
39       res.status(204);
40     }
41     catch(e) {
42       console.error(e);
43       res.status(500);
44     }
45     finally {
46       res.end();
47     }
48   });
49 }
50
51 function apiGet(endpoint: string, fn: WrappedApiHandler, view?: (arr: any) => string): void {
52   apiWrapper('get', endpoint, fn, view);
53 }
54
55 function apiPost(endpoint: string, fn: WrappedApiHandler, view?: (arr: any) => string): void {
56   apiWrapper('post', endpoint, fn, view);
57 }
58 function apiDelete(endpoint: string, fn: WrappedApiHandler, view?: (arr: any) => string): void {
59   apiWrapper('delete', endpoint, fn, view);
60 }
61
62 apiPost('/feeds', async (req, res): Promise<any> => {
63   // get info about the feed
64   const url = req.body.link;
65
66   const rss = new RSSParser();
67   const feedData = await rss.parse(url);
68
69   const title = feedData.title;
70   const id = uuidv4();
71
72   // ingest teh feed, 
73
74   query.addFeed.run(id, title, url);
75
76   await ingestSingle(id);
77
78   res.setHeader('HX-Trigger', 'newFeed');
79
80   return {
81     id,
82     title,
83     url
84   }
85 });
86
87 apiGet('/feeds', async (req, res): Promise<any> => {
88   const feeds = query.getFeedList.all();
89   // get unread counts
90   const unread_count = query.getUnreadCountForAll.all();
91
92   const feedsWithUnread = feeds.map(feed => {
93     const unread = unread_count.filter(i => i.feed_id === feed.id);
94     return {
95       id: feed.id,
96       title: feed.title,
97       link: feed.link,
98       unread: (unread && unread.length) ? unread[0].unread : 0
99     }
100   });
101
102   return { 
103     feeds: feedsWithUnread
104   }
105 }, (output: any): string => {
106   const feeds = output.feeds;
107   return `<ul class="list">${feeds.map((feed: any, idx: number) =>{ 
108     const display = feed.unread ? `(${feed.unread})` : '';
109     const first = idx === 0;
110
111     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="#feed-item-list" data-feed-id="${feed.id}">${feed.title} 
112     <span class="unread-count">${display}</span>
113     </a></li>`
114   }).join("\n")}</ul>`;
115 });
116
117 function reasonable(date: Date): string {
118   const month = date.getMonth() < 10 ? `0${date.getMonth()}` : date.getMonth();
119   const day = date.getDate() < 10 ? `0${date.getDate()}` : date.getDate();
120   return `${date.getFullYear()}-${month}-${day}`;
121 }
122
123 apiGet('/feeds/:feed_id/items', async (req, res): Promise<any> => {
124   const id = req.params.feed_id;
125
126   return query.getFeedsFor.all(id, 0);
127 }, (feedData: any): string => {
128   return `
129   ${feedData.map((item: any, index: number) => {
130     const read = !!item.read_at;
131     return `<li>
132       <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}
133       <span class="date">${reasonable(new Date(item.pub_date * 1000))}</span>
134       </a>
135     </li>
136   `}).join("\n")}
137   `;
138 });
139
140 apiGet('/feeds/:feed_id/items/:item_id', async (req, res) => {
141   const feed_id = req.params.feed_id;
142   const item_id = req.params.item_id;
143
144   query.readItem.run(Date.now(), item_id);
145   return query.getFeedItemInfo.get(item_id, feed_id);
146
147 }, (output: any): string => {
148   return `
149   <div id="meta">
150   <b>Title:</b> ${output.title}<br>
151   <b>Link:</b> <a href="${output.link}" target="_blank">${output.link}</a><br>
152   <b>Posted:</b> ${new Date(output.pub_date * 1000)}<brj
153   <b>Read: </b> ${new Date(output.read_at || undefined)}
154   </div>
155   <div class="scrollable" id="feed-content">${output.content}</div>
156   `
157   return output.content;
158 });
159
160 async function periodicIngest() {
161   await ingest();
162   setTimeout(periodicIngest, 1000 * 60 * 10);
163 }
164
165 periodicIngest();
166
167 app.listen(process.env.PORT || 8000, () => {
168   console.log('Listening on port', process.env.PORT || 8000);
169 });