do not auto-load the first item in the reading pane
[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('/login', async (req, res): Promise<any> => {
64 });
65
66 apiPost('/feeds', async (req, res): Promise<any> => {
67   // get info about the feed
68   const url = req.body.link;
69
70   let parser: BaseParser;
71
72   // based on the url, we should figure out if this is a reddit or rss feed
73   parser = new RSSParser();
74
75   const feedData = await parser.parse(url);
76
77   const title = feedData.title;
78   const id = uuidv4();
79
80   // ingest teh feed, 
81
82   query.addFeed.run(id, title, url);
83
84   await ingestSingle(id);
85
86   res.setHeader('HX-Trigger', 'newFeed');
87
88   return {
89     id,
90     title,
91     url
92   }
93 });
94
95 apiGet('/feeds', async (req, res): Promise<any> => {
96   const feeds = query.getFeedList.all();
97   // get unread counts
98   const unread_count = query.getUnreadCountForAll.all();
99
100   const feedsWithUnread = feeds.map(feed => {
101     const unread = unread_count.filter(i => i.feed_id === feed.id);
102     return {
103       id: feed.id,
104       title: feed.title,
105       link: feed.link,
106       unread: (unread && unread.length) ? unread[0].unread : 0
107     }
108   });
109
110   return { 
111     feeds: feedsWithUnread
112   }
113 }, (output: any): string => {
114   const feeds = output.feeds;
115   return `<ul class="list">${feeds.map((feed: any, idx: number) =>{ 
116     const display = feed.unread ? `(${feed.unread})` : '';
117     const first = idx === 0;
118
119     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} 
120     <span class="unread-count">${display}</span>
121     </a></li>`
122   }).join("\n")}</ul>`;
123 });
124
125 function reasonable(date: Date): string {
126   const month = date.getMonth() < 10 ? `0${date.getMonth()}` : date.getMonth();
127   const day = date.getDate() < 10 ? `0${date.getDate()}` : date.getDate();
128   return `${date.getFullYear()}-${month}-${day}`;
129 }
130
131 apiGet('/feeds/:feed_id', async (req, res): Promise<any> => {
132   const id = req.params.feed_id;
133   return query.getFeedInfo.get(id);
134 }, (feed: any): string => {
135   return `
136   <b>Feed:</b> ${feed.title}<br>
137   <b>Link:</b> ${feed.link}<br>
138   `;
139 });
140
141 apiGet('/feeds/:feed_id/items', async (req, res): Promise<any> => {
142   const id = req.params.feed_id;
143   return {
144     items: query.getFeedsFor.all(id, 0),
145     info: query.getFeedInfo.get(id)
146   }
147 }, (feedData: any): string => {
148   return `
149       <div id="feed-info">
150       <div id="feed-actions">
151         <a href="#" class="btn" hx-post="/feeds/${feedData.info.id}/items/markAsRead" hx-trigger="click">Mark all as Read</a>
152         <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>
153       </div>
154       <b>Feed: </b>${feedData.info.title}<br>
155       </div>
156       <div>
157         <table><thead><tr><th style="width: 80%">Title</th><th>Publish Date</th></head></table>
158       </div>
159       <ul class="scrollable list" id="feed-item-list">
160   ${feedData.items.map((item: any, index: number) => {
161     const read = !!item.read_at;
162     const first = index === 0;
163     return `<li>
164       <a href="#" class="${first ? 'active': ''} ${read ? '': 'unread'}" data-actions="activate" hx-get="/feeds/${item.feed_id}/items/${item.id}" hx-trigger="click" hx-target="#reading-pane" data-feed-item-id="${item.id}" data-feed-id="${item.feed_id}">${item.title}
165       <span class="date">${reasonable(new Date(item.pub_date * 1000))}</span>
166       </a>
167     </li>
168   `}).join("\n")}
169   </ul>
170   `;
171 });
172
173 apiPost('/feeds/:feed_id/items/markAsRead', async (req, res): Promise<any> => {
174   const id = req.params.feed_id;
175   query.readAllItems.run(id);
176   return;
177 });
178
179 apiGet('/feeds/:feed_id/items/:item_id', async (req, res) => {
180   const feed_id = req.params.feed_id;
181   const item_id = req.params.item_id;
182
183   query.readItem.run(Date.now(), item_id);
184   return query.getFeedItemInfo.get(item_id, feed_id);
185
186 }, (output: any): string => {
187   return `
188   <div id="meta">
189   <b>Title:</b> ${output.title}<br>
190   <b>Link:</b> <a href="${output.link}" target="_blank">${output.link}</a><br>
191   <b>Posted:</b> ${new Date(output.pub_date * 1000)}<brj
192   <b>Read: </b> ${new Date(output.read_at || undefined)}
193   </div>
194   <div class="scrollable" id="feed-content">${output.content}</div>
195   `
196   return output.content;
197 });
198
199 apiDelete('/feeds/:feed_id', async (req, res) => {
200   const id = req.params.feed_id;
201
202   query.deleteFeed.run(id);
203   res.setHeader('HX-Trigger', 'newFeed');
204   return;
205 });
206
207 async function periodicIngest() {
208   await ingest();
209   setTimeout(periodicIngest, 1000 * 60 * 10);
210 }
211
212 periodicIngest();
213
214 app.listen(process.env.PORT || 8000, () => {
215   console.log('Listening on port', process.env.PORT || 8000);
216 });