feat: feed can appear in reader and/or river view
authorxangelo <me@xangelo.ca>
Thu, 12 Jun 2025 14:46:13 +0000 (10:46 -0400)
committerxangelo <me@xangelo.ca>
Thu, 12 Jun 2025 14:46:13 +0000 (10:46 -0400)
Feeds can now appear in reader, river, or both views. This is helpful
for some feeds that update hundreds of times a day that should be "river
only" or feeds that update monthly and are better in "reader" view

knexfile.ts
migrations/20250612142143_add_feed_view_flags.ts [new file with mode: 0644]
package.json
src/public/reader.html
src/server.ts
src/types.ts

index bae408b14c101eaf692263d1c3002f5ce7ece1f4..673cc38b84c91ec9a437d0e75f9825f1e9beb5bb 100644 (file)
@@ -6,6 +6,11 @@ module.exports = {
     client: 'better-sqlite3',
     connection: {
       filename: join(__dirname, 'data.db')
-    }
+    },
+    migrations: {
+      directory: join(__dirname, 'migrations'),
+      extension: 'ts'
+    },
+    useNullAsDefault: true
   },
 };
diff --git a/migrations/20250612142143_add_feed_view_flags.ts b/migrations/20250612142143_add_feed_view_flags.ts
new file mode 100644 (file)
index 0000000..7bfae16
--- /dev/null
@@ -0,0 +1,18 @@
+import type { Knex } from "knex";
+
+
+export async function up(knex: Knex): Promise<void> {
+    await knex.schema.alterTable('feeds', (table) => {
+        table.boolean('show_in_river').defaultTo(true).notNullable();
+        table.boolean('show_in_reader').defaultTo(true).notNullable();
+    });
+}
+
+
+export async function down(knex: Knex): Promise<void> {
+    await knex.schema.alterTable('feeds', (table) => {
+        table.dropColumn('show_in_river');
+        table.dropColumn('show_in_reader');
+    });
+}
+
index af4dbfe6485a3180cdc444437aa8a244c1e4b761..5292afd9743b561e48b3c30fcec93d890d1dfcd3 100644 (file)
@@ -4,7 +4,10 @@
   "version": "0.0.1",
   "scripts": {
     "dev": "yarn nodemon src/server.ts",
-    "build": "yarn tsc && cp -R src/public dist/src"
+    "build": "yarn tsc && cp -R src/public dist/src",
+    "migrate": "knex migrate:latest --knexfile knexfile.ts",
+    "migrate:rollback": "knex migrate:rollback --knexfile knexfile.ts",
+    "migrate:make": "knex migrate:make --knexfile knexfile.ts"
   },
   "dependencies": {
     "better-sqlite3": "^11.10.0",
index c4798ebdc3318be716be4e3d0048bb0cb9169865..e02043d3a9522b8d8ee84c5bfc2429d3d1d55a9f 100644 (file)
@@ -1,37 +1,39 @@
 <!doctype html>
 <html lang="en">
-  <head>
-    <title>River</title>
-    <meta charset="utf-8">
-    <meta name="viewport" content="width=device-width, initial-scale=1">
-    <link rel="stylesheet" href="https://classless.de/classless.css">
-    <link rel="stylesheet" href="./style.css">
-    <script src="https://unpkg.com/htmx.org@1.9.10"></script>
-  </head>
-  <body>
-    <nav>
-      <ul>
-        <li><a href="/">River View</a></li>
-        <li><a href="/reader.html">Reader View</a></li>
-      </ul>
-    </nav>
-    <header>
-      <h1>River of News</h1>
-      <p>
-        <strong>An auto-updating list of news</strong>
-      </p>
-    </header>
-    <main class="rss-reader row">
-      <div class="sidebar col-3" hx-get="/feeds" hx-trigger="load"></div>
-      <div class="feed-entries col"></div>
-    </main>
-    <footer>
-      <hr>
-      <p>
-        Another project by <a href="https://xangelo">xangelo</a>
-      </p>
-    </footer>
-  </body>
+
+<head>
+  <title>River</title>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <link rel="stylesheet" href="https://classless.de/classless.css">
+  <link rel="stylesheet" href="./style.css">
+  <script src="https://unpkg.com/htmx.org@1.9.10"></script>
+</head>
+
+<body>
+  <nav>
+    <ul>
+      <li><a href="/">River View</a></li>
+      <li><a href="/reader.html">Reader View</a></li>
+    </ul>
+  </nav>
+  <header>
+    <h1>River of News</h1>
+    <p>
+      <strong>An auto-updating list of news</strong>
+    </p>
+  </header>
+  <main class="rss-reader row">
+    <div class="sidebar col-3" hx-get="/feeds?filter=reader" hx-trigger="load"></div>
+    <div class="feed-entries col"></div>
+  </main>
+  <footer>
+    <hr>
+    <p>
+      Another project by <a href="https://xangelo">xangelo</a>
+    </p>
+  </footer>
+</body>
 <script>
   const keyName = 'lastCheck';
   const headerName = 'last-check';
     const secret = localStorage.getItem('secret');
     event.detail.headers[headerName] = val;
     event.detail.headers['x-secret'] = secret;
-    if(val) {
+    if (val) {
       event.detail.headers['entry-count'] = document.querySelectorAll('.item').length;
     }
   });
   document.body.addEventListener('htmx:afterRequest', evt => {
     localStorage.setItem(keyName, evt.detail.xhr.getResponseHeader(headerName));
   });
-  </script>
-</html>
+</script>
+
+</html>
\ No newline at end of file
index ec9b4d560e60da259582672d2f8f06cae12d1196..50a8844fe7f272be4708a641702b99e4a5ee5311 100644 (file)
@@ -12,7 +12,12 @@ const db = knex({
   debug: process.env.DEBUG === 'true',
   connection: {
     filename: join('./data.db')
-  }
+  },
+  migrations: {
+    directory: join(__dirname, '../migrations'),
+    extension: 'ts'
+  },
+  useNullAsDefault: true
 });
 const parser = new Parser();
 const app = express();
@@ -20,25 +25,25 @@ const app = express();
 const FEED_REFRESH_RATE = parseInt(process.env.FEED_REFRESH_RATE || '0');
 
 function timestamp(obj: any): number {
-  if(obj.getTime) {
-    return Math.floor(obj.getTime()/1000)
+  if (obj.getTime) {
+    return Math.floor(obj.getTime() / 1000)
   }
-  else if(obj.toString() === obj) {
+  else if (obj.toString() === obj) {
     return Math.floor(new Date(obj).getTime() / 1000)
   }
-  else if(!isNaN(obj)) {
-    if(obj.toString().length === 10) {
+  else if (!isNaN(obj)) {
+    if (obj.toString().length === 10) {
       return obj;
     }
     else {
-      return Math.floor(obj/1000);
+      return Math.floor(obj / 1000);
     }
   }
 }
 
 function contentExtractor(feed: FeedSchema, item): string {
   let str = '';
-  switch(feed.title) {
+  switch (feed.title) {
     case 'TechCrunch':
       str = item.contentSnippet;
       break;
@@ -47,7 +52,7 @@ function contentExtractor(feed: FeedSchema, item): string {
       break;
     case 'The Register':
       str = item.summary;
-    break;
+      break;
   }
 
   str = str.replace(/<[^>]+>/g, '');
@@ -67,14 +72,14 @@ async function queryFeeds() {
 
   console.log(`Querying ${feedsToQuery.length} feeds`);
 
-  for(let feed of feedsToQuery) {
+  for (let feed of feedsToQuery) {
     console.log(`Querying ${feed.title}(${feed.url})`);
     try {
       const data = await parser.parseURL(feed.url);
 
       const items: FeedEntrySchema<any>[] = data.items.map(item => {
         const id = createHash('sha256');
-        id.update(item.guid || item.id || Date.now()+Math.random());
+        id.update(item.guid || item.id || Date.now() + Math.random());
         return {
           id: id.digest('hex'),
           title: item.title,
@@ -92,7 +97,7 @@ async function queryFeeds() {
         }
       });
 
-      for(let item of items) {
+      for (let item of items) {
         // inserting one at a time so that we can ignore duplicates for now
         // eventually we'll need to do some kind of merge
         await db('feed_entry').insert(item).onConflict().ignore();
@@ -106,7 +111,7 @@ async function queryFeeds() {
 
 
     }
-    catch(e) {
+    catch (e) {
       console.log(e);
       console.log('Continuing..');
     }
@@ -118,6 +123,24 @@ async function queryFeeds() {
   setTimeout(queryFeeds, 1000 * 60);
 }
 
+// Migration runner function
+async function runMigrations() {
+  try {
+    console.log('Running database migrations...');
+    const [batchNo, log] = await db.migrate.latest();
+
+    if (log.length === 0) {
+      console.log('Database is already up to date');
+    } else {
+      console.log(`Batch ${batchNo} run: ${log.length} migrations`);
+      log.forEach((migration) => console.log(`- ${migration}`));
+    }
+  } catch (error) {
+    console.error('Migration failed:', error);
+    process.exit(1);
+  }
+}
+
 app.use(express.static(join(__dirname, 'public')));
 app.use(express.json());
 
@@ -134,9 +157,9 @@ app.get('/river', async (req, res) => {
   const freshReadInHours = parseInt(process.env.FRESH_READ_IN_HOURS) || 6;
   let createdAt = Math.floor(timestamp(new Date()) - ((60 * 60) * freshReadInHours));
 
-  if(!displayedItems) {
+  if (!displayedItems) {
     const temp = parseInt(header);
-    if(!isNaN(temp)) {
+    if (!isNaN(temp)) {
       firstLoad = false;
       createdAt = temp;
     }
@@ -156,7 +179,7 @@ app.get('/river', async (req, res) => {
     .limit(100)
     .orderBy('feed_entry.pub_date', 'desc');
 
-  if(entries[0] && entries[0].created_at) {
+  if (entries[0] && entries[0].created_at) {
     res.append('last-check', entries[0].created_at.toString());
   }
   else {
@@ -169,7 +192,7 @@ app.get('/river', async (req, res) => {
   let groupIndex = -1;
 
   entries.forEach(entry => {
-    if(entry.feed_id !== prevId) {
+    if (entry.feed_id !== prevId) {
       prevId = entry.feed_id;
       groupIndex++;
       groups.push([]);
@@ -186,7 +209,7 @@ ${group.map(renderFeedItem).join("\n")}
 });
 
 app.post('/feeds', async (req, res) => {
-  if(req.body.key !== process.env.ADMIN_KEY) {
+  if (req.body.key !== process.env.ADMIN_KEY) {
     res.sendStatus(400).end();
     return;
   }
@@ -205,17 +228,22 @@ app.post('/feeds', async (req, res) => {
 });
 
 app.get('/feeds', async (req, res) => {
-  const feedList: FeedSchemaWithUnread[] = await db.raw(`
-select 
-  f.*,
-  sum(fe.is_read) as read,
-  count(fe.feed_id) as total
-from feeds f 
-join feed_entry fe on fe.feed_id = f.id
-group by fe.feed_id
-`);
+  const filter = req.query.filter;
+  let query = `
+  select 
+    f.*,
+    sum(fe.is_read) as read,
+    count(fe.feed_id) as total
+  from feeds f 
+  join feed_entry fe on fe.feed_id = f.id
+  group by fe.feed_id
+  `;
+  if (filter === 'reader') {
+    query += ` having f.show_in_reader = 1`;
+  }
+  const feedList: FeedSchemaWithUnread[] = await db.raw(query);
 
-  if(req.accepts('html')) {
+  if (req.accepts('html')) {
     res.send(renderReaderAppFeedList(feedList));
     return;
   }
@@ -241,7 +269,7 @@ where f.id = ?
 group by fe.feed_id
 `, [req.params.feed_id]);
 
-  if(req.accepts('html')) {
+  if (req.accepts('html')) {
     res.send(renderReaderAppFeedEntries(page, feedData.pop(), feedEntries))
     return;
   }
@@ -251,7 +279,7 @@ group by fe.feed_id
 app.post('/feed_entry/:feed_entry_id', async (req, res) => {
   const authSecret = req.header('x-secret');
 
-  if(authSecret === process.env.ADMIN_KEY) {
+  if (authSecret === process.env.ADMIN_KEY) {
     const item: FeedWithEntrySchema[] = await db('feed_entry').update({
       is_read: true
     }).where({
@@ -266,7 +294,7 @@ app.post('/feed_entry/:feed_entry_id', async (req, res) => {
 });
 
 app.delete('/feeds/:feed_id', async (req, res) => {
-  if(req.query.key !== process.env.ADMIN_KEY) {
+  if (req.query.key !== process.env.ADMIN_KEY) {
     res.sendStatus(400).end();
     return;
   }
@@ -277,9 +305,22 @@ app.delete('/feeds/:feed_id', async (req, res) => {
   res.json(await db.select('*').from('feeds').orderBy('created_at'));
 });
 
-app.listen(process.env.API_PORT, () => {
-  console.log(`Listening on port ${process.env.API_PORT}`);
-  console.log(`Feed refresh rate: ${FEED_REFRESH_RATE}m`);
-  queryFeeds();
+// Start the application
+async function startApp() {
+  // Run migrations first
+  await runMigrations();
+
+  // Then start the server
+  app.listen(process.env.API_PORT, () => {
+    console.log(`Listening on port ${process.env.API_PORT}`);
+    console.log(`Feed refresh rate: ${FEED_REFRESH_RATE}m`);
+    queryFeeds();
+  });
+}
+
+// Start the application
+startApp().catch(error => {
+  console.error('Failed to start application:', error);
+  process.exit(1);
 });
 
index abb7549745cb1d095265d671de0ed66b4c8f2f7d..eeb42f203a99d84bf5a393ac15bb63ffa4f6112a 100644 (file)
@@ -8,6 +8,8 @@ export type FeedSchema = {
   url: string;
   title: string;
   favicon: string;
+  show_in_river: boolean;
+  show_in_reader: boolean;
 } & KnexTimestamps;
 
 export type FeedSchemaWithUnread = FeedSchema & {