首页右栏接入单页入口与公告置顶/全部页;Markdown 编辑器支持可视化时间线与 Git 导入;隐藏块改为 [hide]/[timeline] 命名闭合,并修复单页/公告 GORM 更新只改 updated_at 的问题。 Co-authored-by: Cursor <cursoragent@cursor.com>
141 lines
3.9 KiB
TypeScript
141 lines
3.9 KiB
TypeScript
/** 正文 [timeline]…[/timeline] 块解析与序列化 */
|
|
|
|
export type TimelineItem = {
|
|
date: string;
|
|
title: string;
|
|
body: string;
|
|
sourceUrl?: string;
|
|
/** 导入勾选;手填项默认 true */
|
|
selected?: boolean;
|
|
};
|
|
|
|
export type TimelineDayGroup = {
|
|
date: string;
|
|
items: TimelineItem[];
|
|
};
|
|
|
|
const GIT_COMMENT_RE = /^<!--\s*git:\s*(https?:\/\/\S+)\s*-->\s*$/i;
|
|
const HEADING_RE = /^##\s+(\d{4}-\d{2}-\d{2})\s+(.+)$/;
|
|
|
|
/** 去掉控制字符与 RTL 覆盖,截断长度 */
|
|
export function sanitizeTimelineText(raw: string, max: number): string {
|
|
let s = raw
|
|
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "")
|
|
.replace(/[\u202A-\u202E\u2066-\u2069]/g, "");
|
|
if ([...s].length > max) {
|
|
s = [...s].slice(0, max).join("");
|
|
}
|
|
return s;
|
|
}
|
|
|
|
function isDirectiveLine(t: string): boolean {
|
|
const lower = t.toLowerCase();
|
|
return (
|
|
lower === "[/hide]" ||
|
|
lower === "[/timeline]" ||
|
|
lower === "[timeline]" ||
|
|
lower.startsWith("[hide") ||
|
|
t.startsWith("<!-- git:")
|
|
);
|
|
}
|
|
|
|
/** 避免导入文本拆坏指令块:危险独立行加前缀空格 */
|
|
export function neutralizeDirectiveLines(text: string): string {
|
|
return text
|
|
.split("\n")
|
|
.map((line) => {
|
|
const t = line.trim();
|
|
if (isDirectiveLine(t)) {
|
|
return " " + line;
|
|
}
|
|
return line;
|
|
})
|
|
.join("\n");
|
|
}
|
|
|
|
/** 标题行:去掉换行,防止拆项 */
|
|
export function sanitizeTimelineTitle(title: string): string {
|
|
return sanitizeTimelineText(title.replace(/[\r\n]+/g, " ").trim(), 200);
|
|
}
|
|
|
|
export function parseTimelineBody(body: string): TimelineItem[] {
|
|
const lines = body.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
|
|
const items: TimelineItem[] = [];
|
|
let cur: TimelineItem | null = null;
|
|
let bodyBuf: string[] = [];
|
|
|
|
const flush = () => {
|
|
if (!cur) return;
|
|
let sourceUrl: string | undefined;
|
|
const kept: string[] = [];
|
|
for (const line of bodyBuf) {
|
|
const m = line.trim().match(GIT_COMMENT_RE);
|
|
if (m) {
|
|
sourceUrl = m[1];
|
|
continue;
|
|
}
|
|
kept.push(line);
|
|
}
|
|
cur.body = kept.join("\n").replace(/^\n+/, "").replace(/\n+$/, "");
|
|
if (sourceUrl) cur.sourceUrl = sourceUrl;
|
|
items.push(cur);
|
|
cur = null;
|
|
bodyBuf = [];
|
|
};
|
|
|
|
for (const line of lines) {
|
|
const m = line.match(HEADING_RE);
|
|
if (m) {
|
|
flush();
|
|
cur = {
|
|
date: m[1],
|
|
title: sanitizeTimelineTitle(m[2]),
|
|
body: "",
|
|
selected: true,
|
|
};
|
|
continue;
|
|
}
|
|
if (cur) bodyBuf.push(line);
|
|
}
|
|
flush();
|
|
return items;
|
|
}
|
|
|
|
export function serializeTimelineBlock(items: TimelineItem[]): string {
|
|
const parts = items
|
|
.filter((it) => it.selected !== false)
|
|
.map((it) => {
|
|
const date = /^\d{4}-\d{2}-\d{2}$/.test(it.date)
|
|
? it.date
|
|
: new Date().toISOString().slice(0, 10);
|
|
const title = sanitizeTimelineTitle(it.title) || "未命名";
|
|
const body = neutralizeDirectiveLines(sanitizeTimelineText(it.body || "", 2000));
|
|
const lines = [`## ${date} ${title}`];
|
|
if (body.trim()) lines.push(body.trimEnd());
|
|
if (it.sourceUrl && /^https:\/\//i.test(it.sourceUrl)) {
|
|
lines.push(`<!-- git: ${it.sourceUrl} -->`);
|
|
}
|
|
return lines.join("\n");
|
|
});
|
|
return `[timeline]\n${parts.join("\n\n")}\n[/timeline]`;
|
|
}
|
|
|
|
export function groupTimelineByDate(items: TimelineItem[]): TimelineDayGroup[] {
|
|
const map = new Map<string, TimelineItem[]>();
|
|
const order: string[] = [];
|
|
for (const it of items) {
|
|
const d = it.date || "未知日期";
|
|
if (!map.has(d)) {
|
|
map.set(d, []);
|
|
order.push(d);
|
|
}
|
|
map.get(d)!.push(it);
|
|
}
|
|
order.sort((a, b) => (a < b ? 1 : a > b ? -1 : 0));
|
|
return order.map((date) => ({ date, items: map.get(date)! }));
|
|
}
|
|
|
|
export function isLikelyMergeCommit(title: string): boolean {
|
|
return /^merge\b/i.test(title.trim()) || /^合并\b/.test(title.trim());
|
|
}
|