feat: 站点单页、时间线导入与行级 BBCode 短代码
首页右栏接入单页入口与公告置顶/全部页;Markdown 编辑器支持可视化时间线与 Git 导入;隐藏块改为 [hide]/[timeline] 命名闭合,并修复单页/公告 GORM 更新只改 updated_at 的问题。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -86,7 +86,13 @@ export type AnnouncementColor =
|
||||
| "orange"
|
||||
| "red"
|
||||
| "purple"
|
||||
| "gray";
|
||||
| "gray"
|
||||
| "teal"
|
||||
| "cyan"
|
||||
| "pink"
|
||||
| "amber"
|
||||
| "indigo"
|
||||
| "rose";
|
||||
|
||||
// 右栏站点公告摘要(标签/日期/标题)
|
||||
export interface AnnouncementSummary {
|
||||
@@ -95,6 +101,7 @@ export interface AnnouncementSummary {
|
||||
tag: string;
|
||||
tag_color: AnnouncementColor;
|
||||
created_at: string;
|
||||
pinned?: boolean;
|
||||
}
|
||||
|
||||
// 公告详情/后台管理完整对象
|
||||
@@ -105,10 +112,42 @@ export interface Announcement {
|
||||
tag: string;
|
||||
tag_color: AnnouncementColor;
|
||||
published: boolean;
|
||||
pinned: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface SidebarPageItem {
|
||||
id: number;
|
||||
slug: string;
|
||||
title: string;
|
||||
excerpt: string;
|
||||
}
|
||||
|
||||
export interface SitePage {
|
||||
id: number;
|
||||
slug: string;
|
||||
title: string;
|
||||
content: string;
|
||||
excerpt: string;
|
||||
published: boolean;
|
||||
show_in_sidebar: boolean;
|
||||
sort_order: number;
|
||||
allow_comments: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface SitePageInput {
|
||||
slug: string;
|
||||
title: string;
|
||||
content: string;
|
||||
excerpt?: string;
|
||||
published: boolean;
|
||||
show_in_sidebar: boolean;
|
||||
sort_order?: number;
|
||||
}
|
||||
|
||||
export interface NewUser {
|
||||
id: number;
|
||||
username: string;
|
||||
@@ -130,6 +169,8 @@ export interface OverviewResponse {
|
||||
active_users: ActiveUser[];
|
||||
boards: BoardWithCount[];
|
||||
announcements: AnnouncementSummary[];
|
||||
announcements_total?: number;
|
||||
sidebar_pages?: SidebarPageItem[];
|
||||
new_users: NewUser[];
|
||||
checkin?: CheckinStatus | null;
|
||||
}
|
||||
@@ -1509,11 +1550,15 @@ export async function apiDoCheckin(): Promise<CheckinStatus> {
|
||||
|
||||
// ===== 站点公告 =====
|
||||
|
||||
// SSR 公告详情(仅已发布;草稿/不存在由调用方按 404 处理)
|
||||
// SSR 公告详情(仅已发布;转发 cookie 以便按登录态脱敏 hide)
|
||||
export async function fetchAnnouncementDetail(
|
||||
id: string
|
||||
id: string,
|
||||
cookieHeader?: string
|
||||
): Promise<{ announcement: Announcement } | null> {
|
||||
const res = await fetch(`${API_BASE}/api/announcements/${id}`, ssrInit());
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/announcements/${id}`,
|
||||
ssrInit(cookieHeaders(cookieHeader))
|
||||
);
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) throw new Error("获取公告失败");
|
||||
return res.json();
|
||||
@@ -1550,6 +1595,7 @@ export interface AnnouncementInput {
|
||||
tag: string;
|
||||
tag_color: AnnouncementColor;
|
||||
published: boolean;
|
||||
pinned?: boolean;
|
||||
}
|
||||
|
||||
async function saveAnnouncement(
|
||||
@@ -1586,6 +1632,126 @@ export async function apiAdminDeleteAnnouncement(id: number): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiAdminToggleAnnouncementPin(
|
||||
id: number
|
||||
): Promise<{ announcement: Announcement }> {
|
||||
const res = await fetchWithRefresh(`/api/admin/announcements/${id}/pin`, {
|
||||
method: "POST",
|
||||
headers: clientHeaders(),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || "置顶失败");
|
||||
return data as { announcement: Announcement };
|
||||
}
|
||||
|
||||
/** SSR / 公开:分页公告列表 */
|
||||
export async function fetchAnnouncementsPage(
|
||||
page = 1,
|
||||
size = 20
|
||||
): Promise<{ announcements: Announcement[]; total: number; page: number }> {
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/announcements?page=${page}&size=${size}`,
|
||||
ssrInit()
|
||||
);
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || "获取公告失败");
|
||||
return data as { announcements: Announcement[]; total: number; page: number };
|
||||
}
|
||||
|
||||
export type TimelineGitItem = {
|
||||
date: string;
|
||||
title: string;
|
||||
body: string;
|
||||
source_url?: string;
|
||||
sha?: string;
|
||||
};
|
||||
|
||||
export async function apiTimelineFromGit(input: {
|
||||
urls: string[];
|
||||
follow_pages?: boolean;
|
||||
}): Promise<{ items: TimelineGitItem[]; warning?: string; error?: string }> {
|
||||
const res = await fetchWithRefresh("/api/timeline/from-git", {
|
||||
method: "POST",
|
||||
headers: clientHeaders({ "Content-Type": "application/json" }),
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || "导入失败");
|
||||
return data as { items: TimelineGitItem[]; warning?: string; error?: string };
|
||||
}
|
||||
|
||||
export async function fetchSitePageBySlug(
|
||||
slug: string
|
||||
): Promise<{ page: SitePage } | null> {
|
||||
const res = await fetch(`${API_BASE}/api/pages/${encodeURIComponent(slug)}`, ssrInit());
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) throw new Error("获取页面失败");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function apiAdminListSitePages(): Promise<{ pages: SitePage[] }> {
|
||||
const res = await fetchWithRefresh("/api/admin/pages", { headers: clientHeaders() });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || "获取单页失败");
|
||||
return data as { pages: SitePage[] };
|
||||
}
|
||||
|
||||
export async function fetchAdminSitePages(
|
||||
cookieHeader?: string
|
||||
): Promise<{ pages: SitePage[] }> {
|
||||
const res = await fetch(`${API_BASE}/api/admin/pages`, ssrInit(cookieHeaders(cookieHeader)));
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || "获取单页失败");
|
||||
return data as { pages: SitePage[] };
|
||||
}
|
||||
|
||||
async function saveSitePage(
|
||||
method: "POST" | "PUT",
|
||||
url: string,
|
||||
input: SitePageInput
|
||||
): Promise<{ page: SitePage }> {
|
||||
const res = await fetchWithRefresh(url, {
|
||||
method,
|
||||
headers: clientHeaders({ "Content-Type": "application/json" }),
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || "保存页面失败");
|
||||
return data as { page: SitePage };
|
||||
}
|
||||
|
||||
export function apiAdminCreateSitePage(input: SitePageInput) {
|
||||
return saveSitePage("POST", "/api/admin/pages", input);
|
||||
}
|
||||
|
||||
export function apiAdminUpdateSitePage(id: number, input: SitePageInput) {
|
||||
return saveSitePage("PUT", `/api/admin/pages/${id}`, input);
|
||||
}
|
||||
|
||||
export async function apiAdminDeleteSitePage(id: number): Promise<void> {
|
||||
const res = await fetchWithRefresh(`/api/admin/pages/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: clientHeaders(),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || "删除页面失败");
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiGetAdminSettings(): Promise<
|
||||
PublicSettings & { timeline_git_import?: string }
|
||||
> {
|
||||
const res = await fetchWithRefresh("/api/admin/settings", { headers: clientHeaders() });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || "获取设置失败");
|
||||
return {
|
||||
...normalizePublicSettings(data as Partial<PublicSettings>),
|
||||
timeline_git_import:
|
||||
typeof data.timeline_git_import === "string" ? data.timeline_git_import : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// ===== 用户管理(管理员) =====
|
||||
|
||||
// 后台用户列表项:email/最近活跃/登录 IP 仅管理员接口返回
|
||||
@@ -1901,6 +2067,7 @@ export type UpdateSiteSettingsBody = {
|
||||
attachment_max_mb?: number;
|
||||
attachment_max_count?: number;
|
||||
image_max_mb?: number;
|
||||
timeline_git_import?: string;
|
||||
};
|
||||
|
||||
// 更新站点设置(字段可选);accent 传空串恢复默认主题色
|
||||
|
||||
205
frontend/lib/directiveBlocks.ts
Normal file
205
frontend/lib/directiveBlocks.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
/** 统一拆分正文中的 [hide] / [timeline] 指令块(忽略代码围栏) */
|
||||
|
||||
import {
|
||||
splitHideSegments,
|
||||
type HideKind,
|
||||
type HideSegment,
|
||||
} from "@/lib/hideBlocks";
|
||||
import { parseTimelineBody, type TimelineItem } from "@/lib/timelineBlocks";
|
||||
|
||||
export type ContentSegment =
|
||||
| { type: "md"; text: string }
|
||||
| {
|
||||
type: "hide";
|
||||
kind: HideKind;
|
||||
points: number;
|
||||
locked: boolean;
|
||||
body: string;
|
||||
}
|
||||
| { type: "timeline"; items: TimelineItem[]; rawBody: string };
|
||||
|
||||
function isTimelineClose(line: string): boolean {
|
||||
return line.trim().toLowerCase() === "[/timeline]";
|
||||
}
|
||||
|
||||
function isTimelineOpen(trimmed: string): boolean {
|
||||
return trimmed.toLowerCase() === "[timeline]";
|
||||
}
|
||||
|
||||
/**
|
||||
* 先拆时间线,再对普通 md/hide 段做 hide 拆分。
|
||||
* 时间线与 hide 互不嵌套;解析失败时整段当普通 Markdown。
|
||||
*/
|
||||
export function splitContentSegments(content: string): ContentSegment[] {
|
||||
if (!content) return [{ type: "md", text: "" }];
|
||||
const lines = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
|
||||
const raw: Array<{ type: "md"; text: string } | { type: "timeline"; body: string }> = [];
|
||||
let buf: string[] = [];
|
||||
let inCode = false;
|
||||
let i = 0;
|
||||
|
||||
const flushMd = () => {
|
||||
if (buf.length === 0) return;
|
||||
raw.push({ type: "md", text: buf.join("\n") });
|
||||
buf = [];
|
||||
};
|
||||
|
||||
while (i < lines.length) {
|
||||
const trimmed = lines[i].trim();
|
||||
if (trimmed.startsWith("```")) {
|
||||
inCode = !inCode;
|
||||
buf.push(lines[i]);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (inCode) {
|
||||
buf.push(lines[i]);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isTimelineOpen(trimmed)) {
|
||||
flushMd();
|
||||
const bodyLines: string[] = [];
|
||||
let j = i + 1;
|
||||
let innerCode = false;
|
||||
let found = false;
|
||||
while (j < lines.length) {
|
||||
const inner = lines[j].trim();
|
||||
if (inner.startsWith("```")) {
|
||||
innerCode = !innerCode;
|
||||
bodyLines.push(lines[j]);
|
||||
j++;
|
||||
continue;
|
||||
}
|
||||
if (innerCode) {
|
||||
bodyLines.push(lines[j]);
|
||||
j++;
|
||||
continue;
|
||||
}
|
||||
if (isTimelineOpen(inner)) {
|
||||
// 嵌套视为失败:整段当 md
|
||||
buf.push(lines[i]);
|
||||
i++;
|
||||
found = false;
|
||||
break;
|
||||
}
|
||||
if (isTimelineClose(inner)) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
bodyLines.push(lines[j]);
|
||||
j++;
|
||||
}
|
||||
if (!found) {
|
||||
buf.push(lines[i]);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
raw.push({ type: "timeline", body: bodyLines.join("\n") });
|
||||
i = j + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
buf.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
flushMd();
|
||||
|
||||
const out: ContentSegment[] = [];
|
||||
for (const part of raw) {
|
||||
if (part.type === "timeline") {
|
||||
out.push({
|
||||
type: "timeline",
|
||||
items: parseTimelineBody(part.body),
|
||||
rawBody: part.body,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const hideSegs = splitHideSegments(part.text) as HideSegment[];
|
||||
for (const s of hideSegs) {
|
||||
if (s.type === "md") {
|
||||
out.push({ type: "md", text: s.text });
|
||||
} else {
|
||||
out.push({
|
||||
type: "hide",
|
||||
kind: s.kind,
|
||||
points: s.points,
|
||||
locked: s.locked,
|
||||
body: s.body,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return out.length > 0 ? out : [{ type: "md", text: content }];
|
||||
}
|
||||
|
||||
/** 光标是否落在某个 [timeline]…[/timeline] 块内;返回块起止与正文 */
|
||||
export function findTimelineBlockAt(
|
||||
content: string,
|
||||
cursor: number
|
||||
): { start: number; end: number; body: string } | null {
|
||||
const blocks = listTimelineBlocks(content);
|
||||
for (const b of blocks) {
|
||||
if (cursor >= b.start && cursor <= b.end) return b;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑器打开时间线面板时的目标块:
|
||||
* 1) 光标落在某块内 → 该块
|
||||
* 2) 正文恰有一块 → 该块(changelog 页常见:光标在标题区仍应更新已有时间线)
|
||||
* 3) 否则 null(新建插入)
|
||||
*/
|
||||
export function resolveTimelineBlockForEdit(
|
||||
content: string,
|
||||
cursor: number
|
||||
): { start: number; end: number; body: string } | null {
|
||||
const at = findTimelineBlockAt(content, cursor);
|
||||
if (at) return at;
|
||||
const blocks = listTimelineBlocks(content);
|
||||
if (blocks.length === 1) return blocks[0];
|
||||
return null;
|
||||
}
|
||||
|
||||
function listTimelineBlocks(
|
||||
content: string
|
||||
): Array<{ start: number; end: number; body: string }> {
|
||||
const lines = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
|
||||
const out: Array<{ start: number; end: number; body: string }> = [];
|
||||
let offset = 0;
|
||||
let inCode = false;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const lineStart = offset;
|
||||
const lineEnd = offset + line.length;
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.startsWith("```")) inCode = !inCode;
|
||||
if (!inCode && isTimelineOpen(trimmed)) {
|
||||
const openStart = lineStart;
|
||||
let j = i + 1;
|
||||
let innerCode = false;
|
||||
let closeEnd = -1;
|
||||
const bodyLines: string[] = [];
|
||||
let pos = lineEnd + 1;
|
||||
while (j < lines.length) {
|
||||
const l = lines[j];
|
||||
const t = l.trim();
|
||||
if (t.startsWith("```")) innerCode = !innerCode;
|
||||
if (!innerCode && isTimelineClose(t)) {
|
||||
closeEnd = pos + l.length;
|
||||
break;
|
||||
}
|
||||
bodyLines.push(l);
|
||||
pos += l.length + 1;
|
||||
j++;
|
||||
}
|
||||
if (closeEnd >= 0) {
|
||||
out.push({ start: openStart, end: closeEnd, body: bodyLines.join("\n") });
|
||||
}
|
||||
}
|
||||
offset = lineEnd + 1;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
/** 正文 :::hide 块解析(与 backend/markdown/hide.go 契约一致) */
|
||||
/** 正文 [hide]…[/hide] 块解析(与 backend/markdown/hide.go 契约一致) */
|
||||
|
||||
export type HideKind = "login" | "reply" | "points" | "password";
|
||||
|
||||
@@ -13,25 +13,34 @@ export type HideSegment =
|
||||
};
|
||||
|
||||
const HIDE_KINDS = new Set<HideKind>(["login", "reply", "points", "password"]);
|
||||
const HIDE_OPEN_RE = /^\[hide(?:\s+(.+))?\]$/i;
|
||||
|
||||
function isClose(line: string): boolean {
|
||||
return line.trim() === ":::";
|
||||
return line.trim().toLowerCase() === "[/hide]";
|
||||
}
|
||||
|
||||
/** 与 Go parseOpenMarker 对齐::::hide <kind> [points|password] [locked] */
|
||||
/** 与 Go parseOpenMarker 对齐:[hide <kind>[=arg] [locked]] */
|
||||
function parseOpen(trimmed: string): {
|
||||
kind: HideKind;
|
||||
points: number;
|
||||
locked: boolean;
|
||||
} | null {
|
||||
if (!trimmed.startsWith(":::hide")) return null;
|
||||
const parts = trimmed
|
||||
.slice(":::hide".length)
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter(Boolean);
|
||||
const m = trimmed.match(HIDE_OPEN_RE);
|
||||
if (!m) return null;
|
||||
const rest = (m[1] || "").trim();
|
||||
if (!rest) return null;
|
||||
const parts = rest.split(/\s+/).filter(Boolean);
|
||||
if (parts.length === 0) return null;
|
||||
const kind = parts[0] as HideKind;
|
||||
|
||||
let head = parts[0];
|
||||
let kind = head;
|
||||
let arg = "";
|
||||
const eq = head.indexOf("=");
|
||||
if (eq >= 0) {
|
||||
kind = head.slice(0, eq);
|
||||
arg = head.slice(eq + 1);
|
||||
}
|
||||
kind = kind.toLowerCase() as HideKind;
|
||||
if (!HIDE_KINDS.has(kind)) return null;
|
||||
|
||||
let idx = 1;
|
||||
@@ -39,20 +48,21 @@ function parseOpen(trimmed: string): {
|
||||
let locked = false;
|
||||
|
||||
if (kind === "points") {
|
||||
if (idx >= parts.length) return null;
|
||||
const n = Number(parts[idx]);
|
||||
if (!arg) return null;
|
||||
const n = Number(arg);
|
||||
if (!Number.isFinite(n) || n < 1 || n > 100000) return null;
|
||||
points = n;
|
||||
idx++;
|
||||
} else if (kind === "password") {
|
||||
// 脱敏::::hide password locked;原文::::hide password <pwd> [locked]
|
||||
if (idx < parts.length && parts[idx] !== "locked") {
|
||||
idx++;
|
||||
// 脱敏:[hide password locked];原文密码不在前端解析保留
|
||||
if (arg) {
|
||||
/* skip plaintext */
|
||||
}
|
||||
} else if (arg) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (; idx < parts.length; idx++) {
|
||||
if (parts[idx] === "locked") locked = true;
|
||||
if (parts[idx].toLowerCase() === "locked") locked = true;
|
||||
else return null;
|
||||
}
|
||||
return { kind, points, locked };
|
||||
|
||||
140
frontend/lib/timelineBlocks.ts
Normal file
140
frontend/lib/timelineBlocks.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
/** 正文 [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());
|
||||
}
|
||||
Reference in New Issue
Block a user