From 6f0b62cf8de95b8bb193caa9f479345a49d7dc6a Mon Sep 17 00:00:00 2001 From: Kibi Kelburton Date: Tue, 26 May 2026 14:24:52 +0200 Subject: [PATCH] feat: implement API documentation, add database migrations for site features, and include comment file attachments in API responses --- public/s/js/sidebar-activity.js | 45 +++++++++++++++++++++++++++++---- src/inc/routes/comments.mjs | 42 +++++++++++++++++++++++++++--- 2 files changed, 78 insertions(+), 9 deletions(-) diff --git a/public/s/js/sidebar-activity.js b/public/s/js/sidebar-activity.js index 16f84dc..ca2f576 100644 --- a/public/s/js/sidebar-activity.js +++ b/public/s/js/sidebar-activity.js @@ -248,10 +248,27 @@ return `[video](${fullUrl})`; }); - // Use marked for each line individually - let mdSafe = processedLine.replace(/\*/g, '\\*').replace(/_/g, '\\_'); - const bs = String.fromCharCode(92); - mdSafe = mdSafe.split(bs + bs + '_').join(bs + bs + bs + '_'); + // Use marked for each line individually. + // Protect URLs and already-formed Markdown link/image tokens from the + // italic-prevention pass so that underscores in query params + // (e.g. ?v=_FcvmypiHg4) are never turned into ?v=\_FcvmypiHg4. + const mdProtected = []; + // Match [text](url) / ![alt](url) tokens AND bare http(s) URLs + let mdSafe = processedLine.replace( + /(!?\[[^\]]*\]\([^)]*\))|https?:\/\/\S+/g, + (match) => { + const idx = mdProtected.length; + mdProtected.push(match); + return `\x02MDURL${idx}\x03`; + } + ); + // Escape * and _ only in the non-URL portions + mdSafe = mdSafe + .replace(/\\/g, '\\\\') + .replace(/\*/g, '\\*') + .replace(/_/g, '\\_'); + // Restore protected URLs/tokens + mdSafe = mdSafe.replace(/\x02MDURL(\d+)\x03/g, (_, i) => mdProtected[+i]); let rendered = marked.parseInline ? marked.parseInline(mdSafe, { renderer: renderer }) : marked.parse(mdSafe, { renderer: renderer }).replace(/

|<\/p>/g, ''); @@ -376,9 +393,27 @@ const SIDEBAR_MAX_CHARS = 200; const SIDEBAR_MAX_EMOJIS = 12; + const renderCommentAttachments = (files, content = '') => { + if (!files || files.length === 0) return ''; + const items = files.map(f => { + const url = `/c/${f.dest}`; + if (content.includes(url)) return ''; // Skip if already rendered in content + if (f.mime.startsWith('image/')) { + return `${escapeHtml(f.original_filename || 'image')}`; + } else if (f.mime.startsWith('video/')) { + return `

`; + } else if (f.mime.startsWith('audio/')) { + return `
`; + } + return ''; + }).join(''); + return items ? `
${items}
` : ''; + }; + const renderActivityItem = (c) => { const rawContent = c.content || c.body || ''; const displayContent = renderCommentContent(rawContent, c.id, c.item_id); + const attachmentsHtml = renderCommentAttachments(c.files, rawContent); // Build avatar URL — same priority as the rest of the app let avatarSrc = '/a/default.png'; @@ -437,7 +472,7 @@ ${timeStr} -
${displayContent}
+
${displayContent}${attachmentsHtml}
${itemPreview} `; diff --git a/src/inc/routes/comments.mjs b/src/inc/routes/comments.mjs index 603d233..04a1c59 100644 --- a/src/inc/routes/comments.mjs +++ b/src/inc/routes/comments.mjs @@ -298,6 +298,7 @@ export default (router, tpl) => { const commentId = parseInt(newComment[0].id, 10); // Link uploaded files to this comment (if any) + let activityFiles = []; const fileIdsRaw = body.file_ids || ''; if (fileIdsRaw) { const fileIds = fileIdsRaw.split(',').map(id => parseInt(id, 10)).filter(id => !isNaN(id) && id > 0); @@ -311,6 +312,13 @@ export default (router, tpl) => { AND user_id = ${req.session.id} AND comment_id IS NULL `; + // Fetch the linked files to send with live notification and post response + activityFiles = await db` + SELECT id, comment_id, dest, mime, size, original_filename + FROM comment_files + WHERE comment_id = ${commentId} + ORDER BY id ASC + `; } catch (err) { console.error('[COMMENTS] Failed to link files to comment:', err); } @@ -458,7 +466,8 @@ export default (router, tpl) => { username_color: req.session.username_color, display_name: req.session.display_name || null, xd_score: xdRow?.xd_score ?? null, - video_time: newComment[0]?.video_time ?? null + video_time: newComment[0]?.video_time ?? null, + files: activityFiles }; // 1. Thread live update @@ -477,7 +486,8 @@ export default (router, tpl) => { avatar_file: req.session.avatar_file, username: req.session.user, username_color: req.session.username_color, - display_name: req.session.display_name || null + display_name: req.session.display_name || null, + files: activityFiles })); // Automatically subscribe user to the thread @@ -493,7 +503,10 @@ export default (router, tpl) => { headers: { 'Content-Type': 'application/json; charset=utf-8' }, body: JSON.stringify({ success: true, - comment: newComment[0], + comment: { + ...newComment[0], + files: activityFiles + }, xd_score: xdRow?.xd_score ?? null, is_new_subscription }) @@ -863,6 +876,26 @@ export default (router, tpl) => { LIMIT ${limit} OFFSET ${offset} `; + // Fetch comment file attachments + const filesMap = new Map(); + if (comments.length > 0) { + const commentIds = comments.map(c => c.id); + try { + const files = await db` + SELECT id, comment_id, dest, mime, size, original_filename + FROM comment_files + WHERE comment_id = ANY(${commentIds}::int[]) + ORDER BY id ASC + `; + for (const f of files) { + if (!filesMap.has(f.comment_id)) filesMap.set(f.comment_id, []); + filesMap.get(f.comment_id).push(f); + } + } catch (e) { + console.error('[ACTIVITY] Failed to fetch comment files:', e); + } + } + const processedComments = comments.map(c => { let ratingLabel = '?'; let ratingClass = 'untagged'; @@ -875,7 +908,8 @@ export default (router, tpl) => { content: (c.content || '').trim(), username_color: c.username_color, item_rating_class: ratingClass, - item_rating_label: ratingLabel + item_rating_label: ratingLabel, + files: filesMap.get(c.id) || [] // created_at stays as the raw ISO timestamp so the frontend f0ckTimeAgo can localize it }; });