sitegen/src/file-viewer/format.ts

265 lines
7.6 KiB
TypeScript
Raw Normal View History

2025-06-15 23:42:10 -07:00
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 `<a href="${
match
.replace(/https?:\/\/paperclover\.net\/+/, "/")
.replace(/\/\/+/g, "/")
}">${match}</a>`;
}
return `<a href="${
match.replace(/\/\/+/g, "/")
}" target="_blank" rel="noopener noreferrer">${match}</a>`;
}
// Case 2: domain URLs without protocol
if (match.startsWith(findDomain)) {
return `<a href="${
match.replace(findDomain + "/", "/").replace(/\/\/+/g, "/")
}">${match}</a>`;
}
// Case 3: /file/ URLs
if (match.startsWith("/file/")) {
return `<a href="${match}">${match}</a>`;
}
// 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 `<a href="/file/${siblingFile.path}">${match}</a>`;
}
// 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 `<a href="/file/${currentDir}/${filename}">${match}</a>`;
}
}
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 `<a href="/file/${
file.path.replace(/^\//, "").replace(/\/\/+/g, "/")
}">${match}</a>`;
}
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<string, string> = {};
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,
'<img src="$1" alt="attachment" class="convo-img" width="300" />',
);
if (trimmed === "---" && speaker === "me") {
trimmed = "<hr/>";
}
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 `<div class="s-${speaker}">${
lines
.map((line) => `<div class="line">${line}</div>`)
.join("\n")
}</div>`;
})
.join("\n");
}
export function highlightHashComments(text: string) {
const lines = text.split("\n");
return lines
.map((line) => {
if (line.startsWith("#")) {
return `<div style="color: var(--primary);">${line}</div>`;
}
return `<div>${line.trimEnd() || "&nbsp;"}</div>`;
})
.join("\n");
}