refresh redesign

This commit is contained in:
MDeeApp
2025-10-24 23:28:22 +02:00
parent c7cb02cf2d
commit 6ef62f069c
2 changed files with 372 additions and 21 deletions

View File

@@ -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 });
}