Add versioned annotation submissions
This commit is contained in:
@@ -14,8 +14,8 @@ export async function GET(
|
||||
return new Response("Invalid annotation page", { status: 400 });
|
||||
|
||||
try {
|
||||
const content = await readFile(filePath);
|
||||
return new Response(content, {
|
||||
const content = await readFile(filePath, "utf8");
|
||||
return new Response(injectSubmissionBridge(content, name), {
|
||||
headers: {
|
||||
"cache-control": "no-store",
|
||||
"content-type": "text/html; charset=utf-8",
|
||||
@@ -26,3 +26,44 @@ export async function GET(
|
||||
return new Response("Annotation page not found", { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
function injectSubmissionBridge(content: string, pageName: string) {
|
||||
if (!content.includes("</body>") || !content.includes("mergedRows")) {
|
||||
return content;
|
||||
}
|
||||
const safePageName = JSON.stringify(pageName).replaceAll("<", "\\u003c");
|
||||
const bridge = `
|
||||
<script>
|
||||
window.__annotationSubmissionBridge = {
|
||||
pageName: ${safePageName},
|
||||
read: () => mergedRows().map(row => ({
|
||||
requestId: String(row.request_id || ""),
|
||||
result: String(row.GSB || ""),
|
||||
note: String(row["人工备注"] || "")
|
||||
})),
|
||||
apply: items => {
|
||||
for (const row of rows || []) {
|
||||
if (row?.request_id) delete saved[row.request_id];
|
||||
}
|
||||
for (const item of items || []) {
|
||||
if (!item || !item.requestId) continue;
|
||||
saved[item.requestId] = { GSB: item.result || "", note: item.note || "" };
|
||||
}
|
||||
persist();
|
||||
render();
|
||||
},
|
||||
applyMissing: items => {
|
||||
for (const item of items || []) {
|
||||
if (!item || !item.requestId || saved[item.requestId]) continue;
|
||||
saved[item.requestId] = { GSB: item.result || "", note: item.note || "" };
|
||||
}
|
||||
persist();
|
||||
render();
|
||||
},
|
||||
notify: message => toast(message)
|
||||
};
|
||||
</script>
|
||||
<script src="/annotation-submissions.js"></script>
|
||||
`;
|
||||
return content.replace(/<\/body>/i, `${bridge}</body>`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import {
|
||||
createAnnotationSubmission,
|
||||
readAnnotationSubmission,
|
||||
readAnnotationSubmissions,
|
||||
} from "@/lib/annotation-pages";
|
||||
import { getCurrentAccount } from "@/lib/claw-auth";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ name: string }> },
|
||||
) {
|
||||
try {
|
||||
const { name } = await params;
|
||||
const revisionId = new URL(request.url).searchParams.get("revision");
|
||||
if (revisionId) {
|
||||
const submission = await readAnnotationSubmission(name, revisionId);
|
||||
if (!submission) return errorResponse("提交记录不存在", 404);
|
||||
return jsonResponse({ submission });
|
||||
}
|
||||
return jsonResponse(await readAnnotationSubmissions(name));
|
||||
} catch (error) {
|
||||
return errorResponse(errorMessage(error), 404);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ name: string }> },
|
||||
) {
|
||||
const account = await getCurrentAccount();
|
||||
if (!account) return errorResponse("请先登录工作台再提交", 401);
|
||||
try {
|
||||
const { name } = await params;
|
||||
const body = (await request.json()) as { annotations?: unknown };
|
||||
const submission = await createAnnotationSubmission(
|
||||
name,
|
||||
account,
|
||||
body.annotations,
|
||||
);
|
||||
return jsonResponse({ submission }, 201);
|
||||
} catch (error) {
|
||||
return errorResponse(errorMessage(error), 400);
|
||||
}
|
||||
}
|
||||
|
||||
function jsonResponse(value: unknown, status = 200) {
|
||||
return Response.json(value, {
|
||||
status,
|
||||
headers: { "cache-control": "no-store" },
|
||||
});
|
||||
}
|
||||
|
||||
function errorResponse(error: string, status: number) {
|
||||
return jsonResponse({ error }, status);
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : "操作失败";
|
||||
}
|
||||
@@ -1,4 +1,13 @@
|
||||
import { mkdir, open, readdir, stat } from "node:fs/promises";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import {
|
||||
mkdir,
|
||||
open,
|
||||
readdir,
|
||||
readFile,
|
||||
rename,
|
||||
stat,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
export type AnnotationPageInfo = {
|
||||
@@ -9,9 +18,43 @@ export type AnnotationPageInfo = {
|
||||
href: string;
|
||||
};
|
||||
|
||||
export type AnnotationValue = {
|
||||
requestId: string;
|
||||
result: "G" | "S" | "B" | "";
|
||||
note: string;
|
||||
};
|
||||
|
||||
export type AnnotationSubmission = {
|
||||
id: string;
|
||||
version: number;
|
||||
pageName: string;
|
||||
submittedAt: string;
|
||||
submittedBy: { id: string; username: string };
|
||||
summary: {
|
||||
total: number;
|
||||
completed: number;
|
||||
G: number;
|
||||
S: number;
|
||||
B: number;
|
||||
};
|
||||
annotations: AnnotationValue[];
|
||||
};
|
||||
|
||||
export type AnnotationSubmissionSummary = Omit<
|
||||
AnnotationSubmission,
|
||||
"annotations"
|
||||
>;
|
||||
|
||||
const annotationPagesRoot = path.resolve(
|
||||
/* turbopackIgnore: true */
|
||||
process.env.ANNOTATION_PAGES_DIR?.trim() || "/home/mi/annotation-pages",
|
||||
);
|
||||
const annotationSubmissionsRoot = path.resolve(
|
||||
/* turbopackIgnore: true */
|
||||
process.env.ANNOTATION_SUBMISSIONS_DIR?.trim() ||
|
||||
path.join(annotationPagesRoot, ".submissions"),
|
||||
);
|
||||
const submissionWriteQueues = new Map<string, Promise<unknown>>();
|
||||
|
||||
export async function listAnnotationPages(): Promise<AnnotationPageInfo[]> {
|
||||
await mkdir(annotationPagesRoot, { recursive: true });
|
||||
@@ -49,6 +92,85 @@ export function resolveAnnotationPage(name: string): string | null {
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export async function readAnnotationSubmissions(pageName: string) {
|
||||
const pagePath = resolveAnnotationPage(pageName);
|
||||
if (!pagePath) throw new Error("无效的标注页面");
|
||||
await stat(pagePath);
|
||||
|
||||
const pageRoot = submissionPageRoot(pageName);
|
||||
const latest = await readSubmissionFile(path.join(pageRoot, "latest.json"));
|
||||
const revisionsRoot = path.join(pageRoot, "revisions");
|
||||
let names: string[] = [];
|
||||
try {
|
||||
names = await readdir(revisionsRoot);
|
||||
} catch {
|
||||
return { latest, history: [] as AnnotationSubmissionSummary[] };
|
||||
}
|
||||
|
||||
const history = (
|
||||
await Promise.all(
|
||||
names
|
||||
.filter((name) => name.endsWith(".json"))
|
||||
.map((name) => readSubmissionFile(path.join(revisionsRoot, name))),
|
||||
)
|
||||
)
|
||||
.filter((item): item is AnnotationSubmission => item !== null)
|
||||
.sort((left, right) => right.version - left.version)
|
||||
.slice(0, 100)
|
||||
.map(withoutAnnotations);
|
||||
|
||||
return { latest, history };
|
||||
}
|
||||
|
||||
export async function readAnnotationSubmission(
|
||||
pageName: string,
|
||||
submissionId: string,
|
||||
) {
|
||||
if (!/^[a-zA-Z0-9_-]{8,120}$/.test(submissionId)) return null;
|
||||
const pagePath = resolveAnnotationPage(pageName);
|
||||
if (!pagePath) throw new Error("无效的标注页面");
|
||||
await stat(pagePath);
|
||||
return readSubmissionFile(
|
||||
path.join(
|
||||
submissionPageRoot(pageName),
|
||||
"revisions",
|
||||
`${submissionId}.json`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export async function createAnnotationSubmission(
|
||||
pageName: string,
|
||||
account: { id: string; username: string },
|
||||
input: unknown,
|
||||
) {
|
||||
const pagePath = resolveAnnotationPage(pageName);
|
||||
if (!pagePath) throw new Error("无效的标注页面");
|
||||
await stat(pagePath);
|
||||
const annotations = normalizeAnnotations(input);
|
||||
|
||||
return enqueueSubmissionWrite(pageName, async () => {
|
||||
const pageRoot = submissionPageRoot(pageName);
|
||||
const revisionsRoot = path.join(pageRoot, "revisions");
|
||||
await mkdir(revisionsRoot, { recursive: true });
|
||||
const latest = await readSubmissionFile(path.join(pageRoot, "latest.json"));
|
||||
const submittedAt = new Date().toISOString();
|
||||
const id = `${submittedAt.replace(/\D/g, "").slice(0, 17)}-${randomUUID().slice(0, 8)}`;
|
||||
const submission: AnnotationSubmission = {
|
||||
id,
|
||||
version: (latest?.version ?? 0) + 1,
|
||||
pageName,
|
||||
submittedAt,
|
||||
submittedBy: account,
|
||||
summary: summarizeAnnotations(annotations),
|
||||
annotations,
|
||||
};
|
||||
await atomicWriteJson(path.join(revisionsRoot, `${id}.json`), submission);
|
||||
await atomicWriteJson(path.join(pageRoot, "latest.json"), submission);
|
||||
return submission;
|
||||
});
|
||||
}
|
||||
|
||||
function isSafeHtmlName(name: string): boolean {
|
||||
return (
|
||||
name === path.basename(name) &&
|
||||
@@ -57,6 +179,93 @@ function isSafeHtmlName(name: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function submissionPageRoot(pageName: string) {
|
||||
const digest = createHash("sha256")
|
||||
.update(pageName)
|
||||
.digest("hex")
|
||||
.slice(0, 24);
|
||||
return path.join(annotationSubmissionsRoot, digest);
|
||||
}
|
||||
|
||||
function normalizeAnnotations(input: unknown): AnnotationValue[] {
|
||||
if (!Array.isArray(input)) throw new Error("annotations 必须是数组");
|
||||
if (input.length > 20_000) throw new Error("单次提交最多包含 20000 条标注");
|
||||
const seen = new Set<string>();
|
||||
return input.map((raw, index) => {
|
||||
if (!raw || typeof raw !== "object") {
|
||||
throw new Error(`第 ${index + 1} 条标注格式错误`);
|
||||
}
|
||||
const value = raw as Record<string, unknown>;
|
||||
const requestId = String(value.requestId ?? value.request_id ?? "").trim();
|
||||
const result = String(value.result ?? value.GSB ?? "").trim();
|
||||
const note = String(value.note ?? value.人工备注 ?? "").trim();
|
||||
if (!requestId || requestId.length > 200) {
|
||||
throw new Error(`第 ${index + 1} 条缺少有效 request_id`);
|
||||
}
|
||||
if (seen.has(requestId)) throw new Error(`request_id 重复:${requestId}`);
|
||||
if (!["", "G", "S", "B"].includes(result)) {
|
||||
throw new Error(`第 ${index + 1} 条 GSB 只能是 G、S、B 或空`);
|
||||
}
|
||||
if (note.length > 5000) throw new Error(`第 ${index + 1} 条备注过长`);
|
||||
seen.add(requestId);
|
||||
return {
|
||||
requestId,
|
||||
result: result as AnnotationValue["result"],
|
||||
note,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function summarizeAnnotations(annotations: AnnotationValue[]) {
|
||||
const summary = {
|
||||
total: annotations.length,
|
||||
completed: 0,
|
||||
G: 0,
|
||||
S: 0,
|
||||
B: 0,
|
||||
};
|
||||
for (const annotation of annotations) {
|
||||
if (!annotation.result) continue;
|
||||
summary.completed += 1;
|
||||
summary[annotation.result] += 1;
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
async function readSubmissionFile(filePath: string) {
|
||||
try {
|
||||
return JSON.parse(await readFile(filePath, "utf8")) as AnnotationSubmission;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function withoutAnnotations(
|
||||
submission: AnnotationSubmission,
|
||||
): AnnotationSubmissionSummary {
|
||||
const { annotations: _annotations, ...summary } = submission;
|
||||
return summary;
|
||||
}
|
||||
|
||||
async function atomicWriteJson(filePath: string, value: unknown) {
|
||||
const temporaryPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
|
||||
await writeFile(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
||||
await rename(temporaryPath, filePath);
|
||||
}
|
||||
|
||||
function enqueueSubmissionWrite<T>(pageName: string, task: () => Promise<T>) {
|
||||
const previous = submissionWriteQueues.get(pageName) ?? Promise.resolve();
|
||||
const current = previous.then(task, task);
|
||||
submissionWriteQueues.set(pageName, current);
|
||||
const cleanup = () => {
|
||||
if (submissionWriteQueues.get(pageName) === current) {
|
||||
submissionWriteQueues.delete(pageName);
|
||||
}
|
||||
};
|
||||
void current.then(cleanup, cleanup);
|
||||
return current;
|
||||
}
|
||||
|
||||
async function readHtmlTitle(filePath: string, fileName: string) {
|
||||
const handle = await open(filePath, "r");
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
(() => {
|
||||
const bridge = window.__annotationSubmissionBridge;
|
||||
if (!bridge?.pageName || typeof bridge.read !== "function") return;
|
||||
|
||||
const endpoint = `/api/annotations/${encodeURIComponent(bridge.pageName)}/submissions`;
|
||||
const toolbar = document.querySelector(".toolbar");
|
||||
if (!toolbar) return;
|
||||
|
||||
const style = document.createElement("style");
|
||||
style.textContent = `
|
||||
.annotation-submit-status { color: #687385; font-size: 12px; white-space: nowrap; }
|
||||
.annotation-history-dialog { width: min(680px, calc(100vw - 32px)); max-height: min(720px, calc(100vh - 32px)); padding: 0; border: 1px solid #d9dee7; border-radius: 8px; box-shadow: 0 24px 80px rgba(15, 23, 42, .2); }
|
||||
.annotation-history-dialog::backdrop { background: rgba(15, 23, 42, .32); }
|
||||
.annotation-history-head { position: sticky; top: 0; z-index: 1; display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 16px 18px; border-bottom: 1px solid #e5e9ef; background: #fff; }
|
||||
.annotation-history-head h2 { margin: 0; font-size: 17px; }
|
||||
.annotation-history-close { width: 32px; height: 32px; border: 0; border-radius: 5px; background: #f1f4f8; font-size: 20px; color: #556274; }
|
||||
.annotation-history-list { margin: 0; padding: 0; list-style: none; overflow: auto; }
|
||||
.annotation-history-item { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 14px; align-items: center; padding: 14px 18px; border-bottom: 1px solid #edf0f4; }
|
||||
.annotation-history-item:last-child { border-bottom: 0; }
|
||||
.annotation-history-title { font-weight: 650; font-size: 14px; }
|
||||
.annotation-history-meta { margin-top: 5px; color: #687385; font-size: 12px; }
|
||||
.annotation-history-summary { margin-top: 7px; display: flex; flex-wrap: wrap; gap: 10px; color: #4b5666; font-size: 12px; }
|
||||
.annotation-history-actions { display: flex; gap: 7px; }
|
||||
.annotation-history-empty { padding: 40px 18px; text-align: center; color: #687385; }
|
||||
@media (max-width: 820px) { .annotation-submit-status { display: none; } }
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
|
||||
const historyButton = button("提交记录", "secondary");
|
||||
const submitButton = button("提交", "primary");
|
||||
const status = document.createElement("span");
|
||||
status.className = "annotation-submit-status";
|
||||
toolbar.append(status, historyButton, submitButton);
|
||||
|
||||
const dialog = document.createElement("dialog");
|
||||
dialog.className = "annotation-history-dialog";
|
||||
dialog.innerHTML = `
|
||||
<div class="annotation-history-head">
|
||||
<h2>提交记录</h2>
|
||||
<button class="annotation-history-close" aria-label="关闭">×</button>
|
||||
</div>
|
||||
<ol class="annotation-history-list"></ol>
|
||||
`;
|
||||
document.body.appendChild(dialog);
|
||||
dialog.querySelector(".annotation-history-close").onclick = () =>
|
||||
dialog.close();
|
||||
dialog.addEventListener("click", (event) => {
|
||||
if (event.target === dialog) dialog.close();
|
||||
});
|
||||
|
||||
let history = [];
|
||||
historyButton.onclick = async () => {
|
||||
dialog.showModal();
|
||||
await refreshHistory();
|
||||
};
|
||||
submitButton.onclick = async () => {
|
||||
submitButton.disabled = true;
|
||||
submitButton.textContent = "提交中";
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ annotations: bridge.read() }),
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok) throw new Error(payload.error || "提交失败");
|
||||
history = [
|
||||
summaryOf(payload.submission),
|
||||
...history.filter((item) => item.id !== payload.submission.id),
|
||||
];
|
||||
setStatus(payload.submission);
|
||||
setRevisionUrl(payload.submission.id);
|
||||
bridge.notify?.(`已提交第 ${payload.submission.version} 版`);
|
||||
} catch (error) {
|
||||
bridge.notify?.(error instanceof Error ? error.message : "提交失败");
|
||||
} finally {
|
||||
submitButton.disabled = false;
|
||||
submitButton.textContent = "提交";
|
||||
}
|
||||
};
|
||||
|
||||
async function refreshHistory() {
|
||||
const list = dialog.querySelector(".annotation-history-list");
|
||||
list.innerHTML = '<li class="annotation-history-empty">正在读取</li>';
|
||||
try {
|
||||
const payload = await fetchJson(endpoint);
|
||||
history = payload.history || [];
|
||||
renderHistory();
|
||||
} catch (error) {
|
||||
list.innerHTML = `<li class="annotation-history-empty">${escapeHtml(error instanceof Error ? error.message : "读取失败")}</li>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderHistory() {
|
||||
const list = dialog.querySelector(".annotation-history-list");
|
||||
if (!history.length) {
|
||||
list.innerHTML =
|
||||
'<li class="annotation-history-empty">还没有提交记录</li>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = "";
|
||||
for (const item of history) {
|
||||
const li = document.createElement("li");
|
||||
li.className = "annotation-history-item";
|
||||
li.innerHTML = `
|
||||
<div>
|
||||
<div class="annotation-history-title">第 ${item.version} 版 · ${escapeHtml(item.submittedBy?.username || "未知用户")}</div>
|
||||
<div class="annotation-history-meta">${formatTime(item.submittedAt)}</div>
|
||||
<div class="annotation-history-summary">
|
||||
<span>已标 ${item.summary?.completed || 0}/${item.summary?.total || 0}</span>
|
||||
<span>G ${item.summary?.G || 0}</span>
|
||||
<span>S ${item.summary?.S || 0}</span>
|
||||
<span>B ${item.summary?.B || 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="annotation-history-actions">
|
||||
<button class="btn secondary" data-action="copy" type="button">复制链接</button>
|
||||
<button class="btn secondary" data-action="view" type="button">查看</button>
|
||||
</div>
|
||||
`;
|
||||
li.querySelector('[data-action="copy"]').onclick = async () => {
|
||||
const url = revisionUrl(item.id);
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
bridge.notify?.("版本链接已复制");
|
||||
} catch {
|
||||
window.prompt("复制版本链接", url);
|
||||
}
|
||||
};
|
||||
li.querySelector('[data-action="view"]').onclick = async () => {
|
||||
try {
|
||||
const payload = await fetchJson(
|
||||
`${endpoint}?revision=${encodeURIComponent(item.id)}`,
|
||||
);
|
||||
bridge.apply(payload.submission.annotations || []);
|
||||
dialog.close();
|
||||
setStatus(payload.submission, "正在查看");
|
||||
setRevisionUrl(payload.submission.id);
|
||||
bridge.notify?.(`已打开第 ${payload.submission.version} 版`);
|
||||
} catch (error) {
|
||||
bridge.notify?.(error instanceof Error ? error.message : "读取失败");
|
||||
}
|
||||
};
|
||||
list.appendChild(li);
|
||||
}
|
||||
}
|
||||
|
||||
async function initialize() {
|
||||
try {
|
||||
const requestedRevision = new URLSearchParams(window.location.search).get(
|
||||
"revision",
|
||||
);
|
||||
if (requestedRevision) {
|
||||
const payload = await fetchJson(
|
||||
`${endpoint}?revision=${encodeURIComponent(requestedRevision)}`,
|
||||
);
|
||||
bridge.apply(payload.submission.annotations || []);
|
||||
setStatus(payload.submission, "正在查看");
|
||||
return;
|
||||
}
|
||||
const payload = await fetchJson(endpoint);
|
||||
history = payload.history || [];
|
||||
if (payload.latest) {
|
||||
bridge.applyMissing(payload.latest.annotations || []);
|
||||
setStatus(payload.latest);
|
||||
} else {
|
||||
status.textContent = "尚未提交";
|
||||
}
|
||||
} catch {
|
||||
status.textContent = "提交记录暂不可用";
|
||||
}
|
||||
}
|
||||
|
||||
function setStatus(submission, prefix = "最新") {
|
||||
status.textContent = `${prefix}:第 ${submission.version} 版 · ${submission.submittedBy?.username || "未知用户"}`;
|
||||
}
|
||||
|
||||
function revisionUrl(revisionId) {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("revision", revisionId);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function setRevisionUrl(revisionId) {
|
||||
window.history.replaceState(null, "", revisionUrl(revisionId));
|
||||
}
|
||||
|
||||
function button(label, variant) {
|
||||
const element = document.createElement("button");
|
||||
element.type = "button";
|
||||
element.className = `btn ${variant}`;
|
||||
element.textContent = label;
|
||||
return element;
|
||||
}
|
||||
|
||||
async function fetchJson(url) {
|
||||
const response = await fetch(url, { cache: "no-store" });
|
||||
const payload = await response.json();
|
||||
if (!response.ok) throw new Error(payload.error || "请求失败");
|
||||
return payload;
|
||||
}
|
||||
|
||||
function summaryOf(submission) {
|
||||
const { annotations: _annotations, ...summary } = submission;
|
||||
return summary;
|
||||
}
|
||||
|
||||
function formatTime(value) {
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? "").replace(
|
||||
/[&<>"']/g,
|
||||
(character) =>
|
||||
({
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
})[character],
|
||||
);
|
||||
}
|
||||
|
||||
void initialize();
|
||||
})();
|
||||
Reference in New Issue
Block a user