Files
dispatch/backend/routes/feeds.js
T
2026-07-10 15:48:21 +02:00

130 lines
5.2 KiB
JavaScript

const express = require('express');
const router = express.Router();
const db = require('../db');
const { fetchFeedMeta, fetchAndStoreFeed } = require('../feedFetcher');
router.get('/', (req, res) => {
const feeds = db.prepare(`
SELECT f.*,
(SELECT COUNT(*) FROM articles a WHERE a.feed_id = f.id AND a.is_read = 0) AS unread_count,
(SELECT COUNT(*) FROM articles a WHERE a.feed_id = f.id) AS total_count
FROM feeds f ORDER BY f.sort_order, f.title COLLATE NOCASE
`).all();
res.json(feeds);
});
router.post('/', async (req, res) => {
const { url, folder_id } = req.body;
if (!url || !url.trim()) return res.status(400).json({ error: 'Feed URL is required' });
const cleanUrl = url.trim();
const existing = db.prepare('SELECT * FROM feeds WHERE url = ?').get(cleanUrl);
if (existing) return res.status(409).json({ error: 'This feed is already in your list', feed: existing });
try {
const meta = await fetchFeedMeta(cleanUrl);
const info = db.prepare(`
INSERT INTO feeds (url, title, site_url, description, folder_id)
VALUES (?, ?, ?, ?, ?)
`).run(cleanUrl, meta.title, meta.siteUrl, meta.description, folder_id || null);
const feed = db.prepare('SELECT * FROM feeds WHERE id = ?').get(info.lastInsertRowid);
fetchAndStoreFeed(feed).catch(() => {});
res.status(201).json(feed);
} catch (err) {
res.status(422).json({ error: `Couldn't read that feed. Check the URL and try again. (${err.message})` });
}
});
router.put('/:id', (req, res) => {
const { title, folder_id, fetch_interval_minutes } = req.body;
const feed = db.prepare('SELECT * FROM feeds WHERE id = ?').get(req.params.id);
if (!feed) return res.status(404).json({ error: 'Feed not found' });
db.prepare(`UPDATE feeds SET
title = COALESCE(?, title),
folder_id = ?,
fetch_interval_minutes = COALESCE(?, fetch_interval_minutes)
WHERE id = ?`).run(
title, folder_id === undefined ? feed.folder_id : folder_id, fetch_interval_minutes, req.params.id
);
res.json(db.prepare('SELECT * FROM feeds WHERE id = ?').get(req.params.id));
});
router.delete('/:id', (req, res) => {
db.prepare('DELETE FROM feeds WHERE id = ?').run(req.params.id);
res.status(204).end();
});
router.post('/:id/refresh', async (req, res) => {
const feed = db.prepare('SELECT * FROM feeds WHERE id = ?').get(req.params.id);
if (!feed) return res.status(404).json({ error: 'Feed not found' });
const result = await fetchAndStoreFeed(feed);
res.json(result);
});
router.post('/refresh-all', async (req, res) => {
const feeds = db.prepare('SELECT * FROM feeds').all();
const results = [];
for (const feed of feeds) {
results.push({ feedId: feed.id, ...(await fetchAndStoreFeed(feed)) });
}
res.json(results);
});
// OPML export
router.get('/export/opml', (req, res) => {
const feeds = db.prepare('SELECT f.*, fo.name AS folder_name FROM feeds f LEFT JOIN folders fo ON f.folder_id = fo.id').all();
const byFolder = {};
const noFolder = [];
for (const f of feeds) {
if (f.folder_name) {
byFolder[f.folder_name] = byFolder[f.folder_name] || [];
byFolder[f.folder_name].push(f);
} else {
noFolder.push(f);
}
}
const esc = (s) => String(s || '').replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
let body = '';
for (const [folderName, list] of Object.entries(byFolder)) {
body += `<outline text="${esc(folderName)}" title="${esc(folderName)}">\n`;
for (const f of list) {
body += ` <outline type="rss" text="${esc(f.title)}" title="${esc(f.title)}" xmlUrl="${esc(f.url)}" htmlUrl="${esc(f.site_url)}"/>\n`;
}
body += `</outline>\n`;
}
for (const f of noFolder) {
body += `<outline type="rss" text="${esc(f.title)}" title="${esc(f.title)}" xmlUrl="${esc(f.url)}" htmlUrl="${esc(f.site_url)}"/>\n`;
}
const opml = `<?xml version="1.0" encoding="UTF-8"?>\n<opml version="2.0">\n<head><title>Dispatch Feed Export</title></head>\n<body>\n${body}</body>\n</opml>`;
res.set('Content-Type', 'text/x-opml');
res.set('Content-Disposition', 'attachment; filename="dispatch-feeds.opml"');
res.send(opml);
});
// OPML import
router.post('/import/opml', express.text({ type: '*/*', limit: '5mb' }), async (req, res) => {
const xml = req.body;
const urlMatches = [...xml.matchAll(/xmlUrl="([^"]+)"/g)].map(m => m[1].replace(/&amp;/g, '&'));
const titleMatches = [...xml.matchAll(/xmlUrl="[^"]+"\s+(?:htmlUrl="[^"]*"\s*)?/g)];
const results = [];
for (const url of urlMatches) {
const existing = db.prepare('SELECT id FROM feeds WHERE url = ?').get(url);
if (existing) { results.push({ url, status: 'skipped' }); continue; }
try {
const meta = await fetchFeedMeta(url);
const info = db.prepare('INSERT INTO feeds (url, title, site_url, description) VALUES (?, ?, ?, ?)')
.run(url, meta.title, meta.siteUrl, meta.description);
const feed = db.prepare('SELECT * FROM feeds WHERE id = ?').get(info.lastInsertRowid);
fetchAndStoreFeed(feed).catch(() => {});
results.push({ url, status: 'added' });
} catch (err) {
results.push({ url, status: 'failed', error: err.message });
}
}
res.json({ imported: results.length, results });
});
module.exports = router;