Add annotation page gallery
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
import { mkdir, open, readdir, stat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
export type AnnotationPageInfo = {
|
||||
name: string;
|
||||
title: string;
|
||||
size: number;
|
||||
modifiedAt: string;
|
||||
href: string;
|
||||
};
|
||||
|
||||
const annotationPagesRoot = path.resolve(
|
||||
process.env.ANNOTATION_PAGES_DIR?.trim() || "/home/mi/annotation-pages",
|
||||
);
|
||||
|
||||
export async function listAnnotationPages(): Promise<AnnotationPageInfo[]> {
|
||||
await mkdir(annotationPagesRoot, { recursive: true });
|
||||
const entries = await readdir(annotationPagesRoot, { withFileTypes: true });
|
||||
const pages = await Promise.all(
|
||||
entries
|
||||
.filter((entry) => entry.isFile() && isSafeHtmlName(entry.name))
|
||||
.map(async (entry) => {
|
||||
const filePath = path.join(annotationPagesRoot, entry.name);
|
||||
const [fileStat, title] = await Promise.all([
|
||||
stat(filePath),
|
||||
readHtmlTitle(filePath, entry.name),
|
||||
]);
|
||||
return {
|
||||
name: entry.name,
|
||||
title,
|
||||
size: fileStat.size,
|
||||
modifiedAt: fileStat.mtime.toISOString(),
|
||||
href: `/annotations/${encodeURIComponent(entry.name)}`,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return pages.sort(
|
||||
(left, right) =>
|
||||
Date.parse(right.modifiedAt) - Date.parse(left.modifiedAt) ||
|
||||
left.name.localeCompare(right.name, "zh-CN"),
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveAnnotationPage(name: string): string | null {
|
||||
if (!isSafeHtmlName(name)) return null;
|
||||
const resolved = path.resolve(annotationPagesRoot, name);
|
||||
if (path.dirname(resolved) !== annotationPagesRoot) return null;
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function isSafeHtmlName(name: string): boolean {
|
||||
return (
|
||||
name === path.basename(name) &&
|
||||
!name.startsWith(".") &&
|
||||
/\.html?$/i.test(name)
|
||||
);
|
||||
}
|
||||
|
||||
async function readHtmlTitle(filePath: string, fileName: string) {
|
||||
const handle = await open(filePath, "r");
|
||||
try {
|
||||
const buffer = Buffer.alloc(16 * 1024);
|
||||
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
||||
const head = buffer.subarray(0, bytesRead).toString("utf8");
|
||||
const match = head.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
|
||||
const title = match?.[1]
|
||||
?.replace(/<[^>]+>/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
return title ? decodeHtmlEntities(title) : stripHtmlExtension(fileName);
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
function stripHtmlExtension(fileName: string) {
|
||||
return fileName.replace(/\.html?$/i, "");
|
||||
}
|
||||
|
||||
function decodeHtmlEntities(value: string) {
|
||||
return value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll(""", '"')
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
Reference in New Issue
Block a user