refresh redesign
This commit is contained in:
@@ -70,6 +70,87 @@ const dbPath = path.join(__dirname, 'data', 'tracker.db');
|
||||
const db = new Database(dbPath);
|
||||
db.pragma('foreign_keys = ON');
|
||||
|
||||
const SSE_RETRY_INTERVAL_MS = 5000;
|
||||
const SSE_HEARTBEAT_INTERVAL_MS = 30000;
|
||||
const sseClients = new Map();
|
||||
let nextSseClientId = 1;
|
||||
|
||||
function scheduleAsync(fn) {
|
||||
if (typeof setImmediate === 'function') {
|
||||
setImmediate(fn);
|
||||
} else {
|
||||
setTimeout(fn, 0);
|
||||
}
|
||||
}
|
||||
|
||||
function removeSseClient(clientId) {
|
||||
const client = sseClients.get(clientId);
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
sseClients.delete(clientId);
|
||||
if (client.heartbeat) {
|
||||
clearInterval(client.heartbeat);
|
||||
}
|
||||
}
|
||||
|
||||
function addSseClient(res) {
|
||||
const clientId = nextSseClientId++;
|
||||
const client = {
|
||||
id: clientId,
|
||||
res,
|
||||
heartbeat: setInterval(() => {
|
||||
if (res.writableEnded) {
|
||||
removeSseClient(clientId);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
res.write('event: heartbeat\ndata: {}\n\n');
|
||||
} catch (error) {
|
||||
removeSseClient(clientId);
|
||||
}
|
||||
}, SSE_HEARTBEAT_INTERVAL_MS)
|
||||
};
|
||||
sseClients.set(clientId, client);
|
||||
return client;
|
||||
}
|
||||
|
||||
function broadcastSseEvent(payload) {
|
||||
if (!payload) {
|
||||
return;
|
||||
}
|
||||
|
||||
let serialized;
|
||||
try {
|
||||
serialized = JSON.stringify(payload);
|
||||
} catch (error) {
|
||||
console.warn('Failed to serialize SSE payload:', error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
const message = `data: ${serialized}\n\n`;
|
||||
|
||||
for (const [clientId, client] of sseClients.entries()) {
|
||||
const target = client && client.res;
|
||||
if (!target || target.writableEnded) {
|
||||
removeSseClient(clientId);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
target.write(message);
|
||||
} catch (error) {
|
||||
removeSseClient(clientId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function queuePostBroadcast(postId, options = {}) {
|
||||
if (!postId) {
|
||||
return;
|
||||
}
|
||||
scheduleAsync(() => broadcastPostChangeById(postId, options));
|
||||
}
|
||||
|
||||
function ensureColumn(table, column, definition) {
|
||||
const info = db.prepare(`PRAGMA table_info(${table})`).all();
|
||||
if (!info.some((row) => row.name === column)) {
|
||||
@@ -833,7 +914,7 @@ db.prepare(`
|
||||
WHERE last_change IS NULL
|
||||
`).run();
|
||||
|
||||
function touchPost(postId) {
|
||||
function touchPost(postId, reason = null) {
|
||||
if (!postId) {
|
||||
return;
|
||||
}
|
||||
@@ -842,6 +923,7 @@ function touchPost(postId) {
|
||||
} catch (error) {
|
||||
console.warn(`Failed to update last_change for post ${postId}:`, error.message);
|
||||
}
|
||||
queuePostBroadcast(postId, { reason: reason || 'touch' });
|
||||
}
|
||||
|
||||
function normalizeExistingPostUrls() {
|
||||
@@ -1816,6 +1898,56 @@ function mapPostRow(post) {
|
||||
};
|
||||
}
|
||||
|
||||
function broadcastPostChange(post, options = {}) {
|
||||
if (!post || !post.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
type: 'post-upsert',
|
||||
post
|
||||
};
|
||||
|
||||
if (options && options.reason) {
|
||||
payload.reason = options.reason;
|
||||
}
|
||||
|
||||
broadcastSseEvent(payload);
|
||||
}
|
||||
|
||||
function broadcastPostChangeById(postId, options = {}) {
|
||||
if (!postId) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const row = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId);
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
const postPayload = mapPostRow(row);
|
||||
broadcastPostChange(postPayload, options);
|
||||
} catch (error) {
|
||||
console.warn(`Failed to broadcast post ${postId}:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function broadcastPostDeletion(postId, options = {}) {
|
||||
if (!postId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
type: 'post-deleted',
|
||||
postId
|
||||
};
|
||||
|
||||
if (options && options.reason) {
|
||||
payload.reason = options.reason;
|
||||
}
|
||||
|
||||
broadcastSseEvent(payload);
|
||||
}
|
||||
|
||||
app.get('/api/bookmarks', (req, res) => {
|
||||
try {
|
||||
const rows = listBookmarksStmt.all();
|
||||
@@ -1890,6 +2022,34 @@ app.delete('/api/bookmarks/:bookmarkId', (req, res) => {
|
||||
});
|
||||
|
||||
// Get all posts
|
||||
app.get('/api/events', (req, res) => {
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Cache-Control', 'no-cache, no-transform');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.setHeader('X-Accel-Buffering', 'no');
|
||||
if (typeof res.flushHeaders === 'function') {
|
||||
res.flushHeaders();
|
||||
} else {
|
||||
res.write('\n');
|
||||
}
|
||||
|
||||
res.write(`retry: ${SSE_RETRY_INTERVAL_MS}\n\n`);
|
||||
|
||||
const client = addSseClient(res);
|
||||
const initialPayload = {
|
||||
type: 'connected',
|
||||
clientId: client.id
|
||||
};
|
||||
res.write(`data: ${JSON.stringify(initialPayload)}\n\n`);
|
||||
|
||||
const cleanup = () => {
|
||||
removeSseClient(client.id);
|
||||
};
|
||||
|
||||
req.on('close', cleanup);
|
||||
res.on('close', cleanup);
|
||||
});
|
||||
|
||||
app.get('/api/posts', (req, res) => {
|
||||
try {
|
||||
const posts = db.prepare(`
|
||||
@@ -1925,6 +2085,7 @@ app.get('/api/posts/by-url', (req, res) => {
|
||||
const alternates = collectPostAlternateUrls(post.url, [normalizedUrl]);
|
||||
if (alternates.length) {
|
||||
storePostUrls(post.id, post.url, alternates);
|
||||
touchPost(post.id, 'alternate-urls');
|
||||
}
|
||||
|
||||
res.json(mapPostRow(post));
|
||||
@@ -1957,6 +2118,9 @@ app.post('/api/search-posts', (req, res) => {
|
||||
const alternateUrls = collectPostAlternateUrls(trackedPost.url, normalizedUrls);
|
||||
storePostUrls(trackedPost.id, trackedPost.url, alternateUrls);
|
||||
removeSearchSeenEntries([trackedPost.url, ...alternateUrls]);
|
||||
if (alternateUrls.length) {
|
||||
touchPost(trackedPost.id, 'alternate-urls');
|
||||
}
|
||||
return res.json({ seen_count: 0, should_hide: false, tracked: true });
|
||||
}
|
||||
|
||||
@@ -2104,7 +2268,9 @@ app.post('/api/posts/:postId/screenshot', (req, res) => {
|
||||
db.prepare('UPDATE posts SET screenshot_path = ?, last_change = CURRENT_TIMESTAMP WHERE id = ?').run(fileName, postId);
|
||||
|
||||
const updatedPost = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId);
|
||||
res.json(mapPostRow(updatedPost));
|
||||
const formattedPost = mapPostRow(updatedPost);
|
||||
res.json(formattedPost);
|
||||
broadcastPostChange(formattedPost, { reason: 'screenshot-updated' });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
@@ -2121,9 +2287,10 @@ app.get('/api/posts/:postId/screenshot', (req, res) => {
|
||||
if (!post || !post.screenshot_path) {
|
||||
// Return placeholder image
|
||||
if (fs.existsSync(placeholderPath)) {
|
||||
res.set('Cache-Control', 'public, max-age=86400');
|
||||
res.set('Cache-Control', 'no-store');
|
||||
return res.sendFile(placeholderPath);
|
||||
}
|
||||
res.set('Cache-Control', 'no-store');
|
||||
return res.status(404).json({ error: 'Screenshot not found' });
|
||||
}
|
||||
|
||||
@@ -2131,9 +2298,10 @@ app.get('/api/posts/:postId/screenshot', (req, res) => {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
// Return placeholder image
|
||||
if (fs.existsSync(placeholderPath)) {
|
||||
res.set('Cache-Control', 'public, max-age=86400');
|
||||
res.set('Cache-Control', 'no-store');
|
||||
return res.sendFile(placeholderPath);
|
||||
}
|
||||
res.set('Cache-Control', 'no-store');
|
||||
return res.status(404).json({ error: 'Screenshot not found' });
|
||||
}
|
||||
|
||||
@@ -2190,7 +2358,7 @@ app.post('/api/posts', (req, res) => {
|
||||
|
||||
if (normalizedPostText && (!existingByHash.post_text || !existingByHash.post_text.trim())) {
|
||||
updatePostTextColumnsStmt.run(normalizedPostText, postTextHash, existingByHash.id);
|
||||
touchPost(existingByHash.id);
|
||||
touchPost(existingByHash.id, 'post-text-normalized');
|
||||
existingByHash = db.prepare('SELECT * FROM posts WHERE id = ?').get(existingByHash.id);
|
||||
}
|
||||
|
||||
@@ -2250,7 +2418,9 @@ app.post('/api/posts', (req, res) => {
|
||||
storePostUrls(id, normalizedUrl, alternateUrls);
|
||||
removeSearchSeenEntries([normalizedUrl, ...alternateUrls]);
|
||||
|
||||
res.json(mapPostRow(post));
|
||||
const formattedPost = mapPostRow(post);
|
||||
res.json(formattedPost);
|
||||
broadcastPostChange(formattedPost, { reason: 'created' });
|
||||
} catch (error) {
|
||||
if (error.message.includes('UNIQUE constraint failed')) {
|
||||
res.status(409).json({ error: 'Post with this URL already exists' });
|
||||
@@ -2385,7 +2555,9 @@ app.put('/api/posts/:postId', (req, res) => {
|
||||
}
|
||||
removeSearchSeenEntries(Array.from(cleanupUrls));
|
||||
|
||||
res.json(mapPostRow(updatedPost));
|
||||
const formattedPost = mapPostRow(updatedPost);
|
||||
res.json(formattedPost);
|
||||
broadcastPostChange(formattedPost, { reason: 'updated' });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
@@ -2465,7 +2637,7 @@ app.post('/api/posts/:postId/check', (req, res) => {
|
||||
didChange = true;
|
||||
recalcCheckedCount(postId);
|
||||
if (didChange) {
|
||||
touchPost(postId);
|
||||
touchPost(postId, 'profile-status-update');
|
||||
}
|
||||
|
||||
const updatedPost = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId);
|
||||
@@ -2492,6 +2664,9 @@ app.post('/api/posts/:postId/urls', (req, res) => {
|
||||
removeSearchSeenEntries([post.url, ...alternateUrls]);
|
||||
|
||||
const storedAlternates = selectAlternateUrlsForPostStmt.all(post.id).map(row => row.url);
|
||||
if (alternateUrls.length) {
|
||||
touchPost(post.id, 'alternate-urls');
|
||||
}
|
||||
res.json({
|
||||
success: true,
|
||||
primary_url: post.url,
|
||||
@@ -2587,7 +2762,7 @@ app.post('/api/check-by-url', (req, res) => {
|
||||
didChange = true;
|
||||
recalcCheckedCount(post.id);
|
||||
if (didChange) {
|
||||
touchPost(post.id);
|
||||
touchPost(post.id, 'check-by-url');
|
||||
}
|
||||
|
||||
const updatedPost = db.prepare('SELECT * FROM posts WHERE id = ?').get(post.id);
|
||||
@@ -2674,7 +2849,7 @@ app.post('/api/posts/:postId/profile-status', (req, res) => {
|
||||
|
||||
recalcCheckedCount(postId);
|
||||
if (didChange) {
|
||||
touchPost(postId);
|
||||
touchPost(postId, 'profile-status-update');
|
||||
}
|
||||
|
||||
const updatedPost = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId);
|
||||
@@ -2710,7 +2885,7 @@ app.patch('/api/posts/:postId', (req, res) => {
|
||||
|
||||
const contentKey = extractFacebookContentKey(normalizedUrl);
|
||||
// Update URL
|
||||
db.prepare('UPDATE posts SET url = ?, content_key = ? WHERE id = ?').run(normalizedUrl, contentKey || null, postId);
|
||||
db.prepare('UPDATE posts SET url = ?, content_key = ?, last_change = CURRENT_TIMESTAMP WHERE id = ?').run(normalizedUrl, contentKey || null, postId);
|
||||
|
||||
const alternateCandidates = [];
|
||||
if (existingPost.url && existingPost.url !== normalizedUrl) {
|
||||
@@ -2720,15 +2895,19 @@ app.patch('/api/posts/:postId', (req, res) => {
|
||||
const alternateUrls = collectPostAlternateUrls(normalizedUrl, alternateCandidates);
|
||||
storePostUrls(postId, normalizedUrl, alternateUrls);
|
||||
removeSearchSeenEntries([normalizedUrl, ...alternateUrls]);
|
||||
queuePostBroadcast(postId, { reason: 'url-updated' });
|
||||
return res.json({ success: true, url: normalizedUrl });
|
||||
}
|
||||
|
||||
if (is_successful !== undefined) {
|
||||
const successValue = is_successful ? 1 : 0;
|
||||
db.prepare('UPDATE posts SET is_successful = ? WHERE id = ?').run(successValue, postId);
|
||||
db.prepare('UPDATE posts SET is_successful = ?, last_change = CURRENT_TIMESTAMP WHERE id = ?').run(successValue, postId);
|
||||
|
||||
const updatedPost = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId);
|
||||
return res.json(mapPostRow(updatedPost));
|
||||
const formattedPost = mapPostRow(updatedPost);
|
||||
res.json(formattedPost);
|
||||
broadcastPostChange(formattedPost, { reason: 'success-flag' });
|
||||
return;
|
||||
}
|
||||
|
||||
return res.status(400).json({ error: 'No valid update parameter provided' });
|
||||
@@ -2763,6 +2942,7 @@ app.delete('/api/posts/:postId', (req, res) => {
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
broadcastPostDeletion(postId, { reason: 'deleted' });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user