export function formatSize(bytes: number) { if (bytes < 1024) return `${bytes} bytes`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; if (bytes < 1024 * 1024 * 1024) { return `${(bytes / 1024 / 1024).toFixed(1)} MB`; } return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`; } export function formatDate(date: Date) { // YYYY-MM-DD, format in PST timezone return date.toLocaleDateString("sv", { timeZone: "America/Los_Angeles" }); } export function formatShortDate(date: Date) { // YY-MM-DD, format in PST timezone return formatDate(date).slice(2); } export function formatDuration(seconds: number) { const minutes = Math.floor(seconds / 60); const remainingSeconds = seconds % 60; return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`; } export const escapeUri = (uri: string) => encodeURIComponent(uri) .replace(/%2F/gi, "/") .replace(/%3A/gi, ":") .replace(/%2B/gi, "+") .replace(/%40/gi, "@") .replace(/%2D/gi, "-") .replace(/%5F/gi, "_") .replace(/%2E/gi, ".") .replace(/%2C/gi, ","); import type { MediaFile } from "../db.ts"; import { escapeHTML } from "../framework/bun-polyfill.ts"; const findDomain = "paperclover.net"; // Returns escaped HTML // Features: // - autolink detection // - via \bpaperclover.net/[a-zA-Z0-9_\.+-]+ // - via \b/file/[a-zA-Z0-9_\.+-]+ // - via \bhttps://... // - via name of a sibling file's basename // - reformat (c) into © // // This formatter was written with AI. export function highlightLinksInTextView( text: string, siblingFiles: MediaFile[] = [], ) { const siblingLookup = Object.fromEntries( siblingFiles .filter((f) => f.basename !== "readme.txt") .map((f) => [f.basename, f]), ); // First escape the HTML to prevent XSS let processedText = escapeHTML(text); // Replace (c) with © processedText = processedText.replace(/\(c\)/gi, "©"); // Process all URL patterns in a single pass to avoid nested links // This regex matches: // 1. https:// or http:// URLs // 2. domain URLs without protocol (e.g., paperclover.net/path) // 3. /file/ URLs // 4. ./ relative paths // We'll use a function to determine what kind of URL it is and format accordingly const urlRegex = new RegExp( "(" + // Group 1: https:// or http:// URLs "\\bhttps?:\\/\\/[a-zA-Z0-9_\\.\\-]+\\.[a-zA-Z0-9_\\.\\-]+[a-zA-Z0-9_\\.\\-\\/\\?=&%+#]*" + "|" + // Group 2: domain URLs without protocol findDomain + "\\/\\/[a-zA-Z0-9_\\.\\+\\-]+" + "|" + // Group 3: /file/ URLs "\\/file\\/[a-zA-Z0-9_\\.\\+\\-\\/]+" + ")\\b" + "|" + // Group 4: ./ relative paths (not word-bounded) "(?<=\\s|^)\\.\\/[\\w\\-\\.]+", "g", ); processedText = processedText.replace(urlRegex, (match: string) => { // Case 1: https:// or http:// URLs if (match.startsWith("http")) { if (match.includes(findDomain)) { return `${match}`; } return `${match}`; } // Case 2: domain URLs without protocol if (match.startsWith(findDomain)) { return `${match}`; } // Case 3: /file/ URLs if (match.startsWith("/file/")) { return `${match}`; } // Case 4: ./ relative paths if (match.startsWith("./")) { const filename = match.substring(2); // Check if the filename exists in sibling files const siblingFile = siblingFiles.find((f) => f.basename === filename); if (siblingFile) { return `${match}`; } // If no exact match but we have sibling files, try to create a reasonable link if (siblingFiles.length > 0) { const currentDir = siblingFiles[0].path .split("/") .slice(0, -1) .join("/"); return `${match}`; } } return match; }); // Match sibling file names (only if they're not already part of a link) if (siblingFiles.length > 0) { // Create a regex pattern that matches any of the sibling file basenames // We need to escape special regex characters in the filenames const escapedBasenames = siblingFiles.map((f) => f.basename.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") ); // Join all basenames with | for the regex alternation const pattern = new RegExp(`\\b(${escapedBasenames.join("|")})\\b`, "g"); // We need to be careful not to replace text that's already in a link // So we'll split the text by HTML tags and only process the text parts const parts = processedText.split(/(<[^>]*>)/); for (let i = 0; i < parts.length; i += 2) { // Only process text parts (even indices), not HTML tags (odd indices) if (i < parts.length) { parts[i] = parts[i].replace(pattern, (match: string) => { const file = siblingLookup[match]; if (file) { return `${match}`; } return match; }); } } processedText = parts.join(""); } return processedText; } export function highlightConvo(text: string) { text = text.replace(/^#mode=convo\n/, ""); const lines = text.split("\n"); const paras: { speaker: string | null; lines: string[] }[] = []; let currentPara: string[] = []; let currentSpeaker: string | null = null; let firstSpeaker = null; const speakers: Record = {}; const getSpeaker = (s: string) => { if (s[1] === " " && speakers[s[0]]) { return s[0]; } return null; }; for (const line of lines) { let trimmed = line.trim(); if (line.startsWith("#")) { // parse #X=Y const [_, speaker, color] = trimmed.match(/^#(.)=(.*)$/)!; speakers[speaker] = color; continue; } if (trimmed === "") { continue; } let speaker = getSpeaker(trimmed); if (speaker) { trimmed = trimmed.substring(speaker.length).trimStart(); speaker = speakers[speaker]; } else { speaker = "me"; } trimmed = trimmed.replace( /\[IMG:(\/file\/[^\]]+)\]/g, 'attachment', ); if (trimmed === "---" && speaker === "me") { trimmed = "
"; } if (speaker === currentSpeaker) { currentPara.push(trimmed); } else { if (currentPara.length > 0) { paras.push({ speaker: currentSpeaker, lines: currentPara, }); currentPara = []; } currentPara = [trimmed]; currentSpeaker = speaker; firstSpeaker ??= speaker; } } if (currentPara.length > 0) { paras.push({ speaker: currentSpeaker, lines: currentPara, }); } return paras .map(({ speaker, lines }) => { return `
${ lines .map((line) => `
${line}
`) .join("\n") }
`; }) .join("\n"); } export function highlightHashComments(text: string) { const lines = text.split("\n"); return lines .map((line) => { if (line.startsWith("#")) { return `
${line}
`; } return `
${line.trimEnd() || " "}
`; }) .join("\n"); }