264 lines
7.6 KiB
TypeScript
264 lines
7.6 KiB
TypeScript
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() || " "}</div>`;
|
|
})
|
|
.join("\n");
|
|
}
|