前言
聊作记录,供未来的自己参考
第一步:
OneNote导出日记到docx
第二步:
转换为完整的md文档
convert_onenote.py
import os
import re
import uuid
import shutil
import subprocess
from pathlib import Path
# --- 配置区 ---
DOCX_FILE = "【你的docx文件名】"
OUTPUT_MD = "diary_full.md"
FINAL_IMAGES_DIR = "images" # 最终存放图片的文件夹
TEMP_MEDIA_DIR = "temp_media" # Pandoc 临时提取目录
def _normalize_path(p: str) -> str:
"""
统一路径格式,方便做字典键:
- 去掉前面的 ./ 或 .\\
- 用 / 作为分隔符
"""
p = p.strip()
p = p.replace("\\", "/")
if p.startswith("./"):
p = p[2:]
return p
def run_conversion():
# 自动选择要转换的 docx 文件:
# - 如果当前目录下只有一个 .docx 文件,则优先使用那个文件;
# - 否则回退到上面的 DOCX_FILE 常量。
cwd = Path(__file__).resolve().parent
docx_candidates = sorted(cwd.glob("*.docx"))
if len(docx_candidates) == 1:
docx_file = docx_candidates[0].name
else:
docx_file = DOCX_FILE
if not (cwd / docx_file).exists():
print(f"错误:未找到要转换的 DOCX 文件:{docx_file}")
if len(docx_candidates) > 1:
print("当前目录下存在多个 docx 文件,请修改脚本中的 DOCX_FILE 或整理多余文件:")
for p in docx_candidates:
print(f" - {p.name}")
return
# 1. 创建必要文件夹
if not os.path.exists(FINAL_IMAGES_DIR):
os.makedirs(FINAL_IMAGES_DIR)
print(f"正在转换 {docx_file}...")
# 2. 执行 Pandoc 转换
# --extract-media 会在 temp_media/media 下生成 image1.png 等
try:
subprocess.run(
[
"pandoc",
docx_file,
"-f",
"docx",
"-t",
"gfm", # 使用 GitHub 风格 Markdown,兼容性好
"--extract-media=" + TEMP_MEDIA_DIR,
"--wrap=none",
"-o",
OUTPUT_MD,
],
check=True,
)
except subprocess.CalledProcessError as e:
print(f"Pandoc 运行失败: {e}")
return
# 3. 读取生成的 Markdown 并处理图片路径
if not os.path.exists(OUTPUT_MD):
print("错误:未找到生成的 Markdown 文件")
return
with open(OUTPUT_MD, "r", encoding="utf-8") as f:
content = f.read()
# ---- 3.1 收集 Pandoc 生成的图片路径(Markdown 和 HTML 两种写法)----
# Markdown 形式: {...}
md_img_pattern = re.compile(
r"!\[(?P<alt>[^\]]*)]\((?P<path>[^)]+)\)"
)
# HTML 形式: <img src="temp_media/media/image1.png" alt="...">
html_img_pattern = re.compile(
r'<img\s+[^>]*?src="(?P<path>[^"]+)"(?P<attrs>[^>]*)>'
)
path_map = {} # 规范化路径 -> (原文件名, 新文件名)
# 从 Markdown 图片里收集
for m in md_img_pattern.finditer(content):
raw_path = m.group("path")
norm_path = _normalize_path(raw_path)
if not norm_path.startswith(f"{TEMP_MEDIA_DIR}/"):
continue
filename = os.path.basename(norm_path)
if norm_path not in path_map:
ext = os.path.splitext(filename)[1]
new_name = f"{uuid.uuid4()}{ext}"
path_map[norm_path] = (filename, new_name)
# 从 HTML 图片里收集
for m in html_img_pattern.finditer(content):
raw_path = m.group("path")
norm_path = _normalize_path(raw_path)
if not norm_path.startswith(f"{TEMP_MEDIA_DIR}/"):
continue
filename = os.path.basename(norm_path)
if norm_path not in path_map:
ext = os.path.splitext(filename)[1]
new_name = f"{uuid.uuid4()}{ext}"
path_map[norm_path] = (filename, new_name)
print(f"找到 {len(path_map)} 张图片,正在复制到 {FINAL_IMAGES_DIR} ...")
# ---- 3.2 移动并重命名实际图片文件 ----
for norm_path, (filename, new_name) in path_map.items():
old_file_path = os.path.join(TEMP_MEDIA_DIR, "media", filename)
new_file_path = os.path.join(FINAL_IMAGES_DIR, new_name)
if os.path.exists(old_file_path):
shutil.move(old_file_path, new_file_path)
# 规范化后的路径 -> 新的 Markdown 路径(images/xxxx.png)
norm_to_md_path = {
norm_path: f"{FINAL_IMAGES_DIR}/{new_name}"
for norm_path, (_, new_name) in path_map.items()
}
# ---- 3.3 把 Markdown 里引用 temp_media 的图片,路径改成 images/UUID.xxx ----
def _replace_md_img(m: re.Match) -> str:
alt = m.group("alt")
raw_path = m.group("path")
norm_path = _normalize_path(raw_path)
new_rel_path = norm_to_md_path.get(norm_path)
if not new_rel_path:
return m.group(0)
return f""
content = md_img_pattern.sub(_replace_md_img, content)
# ---- 3.4 把 HTML <img> 标签也转成 Markdown 形式 ----
# 例如:<img src="temp_media/media/image1.png" alt="xxx" ...>
# 变成:
def _replace_html_img(m: re.Match) -> str:
raw_path = m.group("path")
norm_path = _normalize_path(raw_path)
new_rel_path = norm_to_md_path.get(norm_path)
if not new_rel_path:
return m.group(0)
# 尝试取 alt 文本
attrs = m.group("attrs") or ""
alt_match = re.search(r'alt="([^"]*)"', attrs)
alt_text = alt_match.group(1) if alt_match else ""
return f""
content = html_img_pattern.sub(_replace_html_img, content)
# 4. 写回修改后的 Markdown
with open(OUTPUT_MD, "w", encoding="utf-8") as f:
f.write(content)
# 5. 清理临时文件夹
if os.path.exists(TEMP_MEDIA_DIR):
shutil.rmtree(TEMP_MEDIA_DIR)
print(f"--- 转换完成 ---")
print(f"文件:{OUTPUT_MD}")
print(f"图片库:{FINAL_IMAGES_DIR}")
if __name__ == "__main__":
run_conversion()
第三步:
调用API识别、提取人名
extract_names.py
import argparse
import json
import logging
import os
import re
from datetime import datetime
from pathlib import Path
import time
from typing import Any
import requests
# ===== 配置区域:根据自己的情况修改 =====
# OpenAI 兼容接口的基础地址,例如:
# - 官方: https://api.openai.com/v1
# - 第三方:自行填写对应的 base_url
BASE_URL = "【BaseURL】"
# API Key:默认从环境变量 OPENAI_API_KEY 读取,也可以直接在此处填写
API_KEY = os.getenv("OPENAI_API_KEY") or "【API Key】"
# 使用的模型名称,例如 "gpt-4.1-mini"、"gpt-4o-mini" 等
MODEL = "【模型名称】"
# 采样温度
TEMPERATURE = 0.0
# 最大生成 token 数(可按自己的模型限制调整)
MAX_TOKENS = 10240
# 日记所在的目录(相对于本脚本所在目录)
DAYS_DIR = "days"
# 结果 / 日志 / 重试日志
RESULT_PATH = "result.json"
LOG_PATH = "log.log"
RETRY_LOG_PATH = "retry_log.log"
# 请求超时与重试次数
REQUEST_TIMEOUT = 60
RETRY_TIMES = 3
def setup_logging() -> None:
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[
logging.FileHandler(LOG_PATH, encoding="utf-8"),
logging.StreamHandler(),
],
)
def build_prompt(text: str, file_rel: str) -> tuple[str, str]:
system = (
"你是一个帮助标注日记文本中「人物提及」的助手。"
"人物包括:具体的人名(中文或外文)、昵称、亲属称谓、职务称谓等。"
"不要把单独的「我」视为人物提及,也不要标注代词「你」「他」「她」等,除非它们是某个固定昵称的一部分。"
"你的任务是在不改变原文本内容含义的前提下,提取包含人物提及的片段,并在片段中用 $$ 标记人物提及。"
)
user = f"""下面是一篇日记的完整内容。请找出文本中所有「人物提及」的每一次出现。
要求:
1. 「人物提及」包括:
- 具体的人的名字
- 指代具体人的称呼或昵称
2. 明确不要标注:
- 单独的「我」
- 普通代词「你」「他」「她」「他们」等
2. 每一次出现都要单独记录(即使是同一个人,也要按出现次数分别记录)。
3. 对于每一次出现,请截取一小段包含该人物提及的原文片段(建议总长度 30~80 个字符),
并在片段中用两个美元符号 $$ 将人物提及包裹起来,例如:
原句:我打了张三,他恨我
某一次出现的片段可以是:"...$$张三$$,他恨我"
4. 片段要求:
- 每个片段中必须且只能出现一处用 $$ 包裹的人物提及(即一对 $$...$$)。
- 片段中的其他文字必须保持与原文完全一致,不要增删、替换、重写或改动(包括空格和换行)。
5. 请按照人物在原文中出现的先后顺序返回结果。
6. 如果整篇日记中没有任何人物提及,请返回一个空的 items 数组。
7. 最终输出必须是一个 JSON 对象,结构为:
items,
...
]
}}
- 不要输出任何多余的说明、解释或注释。
文件名:{file_rel}
日记全文如下:
{text}
"""
return system, user
def call_openai(text: str, file_rel: str) -> list[dict]:
system_content, user_content = build_prompt(text, file_rel)
url = BASE_URL.rstrip("/") + "/chat/completions"
headers = {
"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json",
}
payload = {
"model": MODEL,
"messages": [
{"role": "system", "content": system_content},
{"role": "user", "content": user_content},
],
"temperature": TEMPERATURE,
"max_tokens": MAX_TOKENS,
}
for attempt in range(1, RETRY_TIMES + 1):
try:
resp = requests.post(url, headers=headers, json=payload, timeout=REQUEST_TIMEOUT)
resp.raise_for_status()
data = resp.json()
# 兼容不同返回格式,提取文本内容
choice = data.get("choices", [{}])[0]
content = ""
if "message" in choice and isinstance(choice["message"], dict):
content = choice["message"].get("content", "") or ""
elif "text" in choice:
content = choice.get("text") or ""
if not isinstance(content, str):
raise ValueError(f"Unexpected content type: {type(content)}")
raw_content = content.strip()
# 去掉可能的 ```json ... ``` 或 ``` ... ``` 代码块包裹
if raw_content.startswith("```"):
# 形如 ```json\n{...}\n``` 或 ```\n{...}\n```
parts = raw_content.split("```")
# parts[0] == "",中间部分可能包含语言标记
if len(parts) >= 3:
middle = parts[1]
# 去掉语言标记(如 "json\n" 或 "markdown\n")
nl_pos = middle.find("\n")
if nl_pos != -1:
raw_content = middle[nl_pos + 1 :].strip()
else:
raw_content = middle.strip()
# 如果前后有多余文字,尝试从第一个 { 到最后一个 } 截取出 JSON 主体
if not raw_content.lstrip().startswith("{"):
start = raw_content.find("{")
end = raw_content.rfind("}")
if start != -1 and end != -1 and end > start:
raw_content = raw_content[start : end + 1]
parsed = json.loads(raw_content)
# 支持两种结构:
# 1) {"items": [...]}
# 2) 直接是 [...]
if isinstance(parsed, dict):
model_items = parsed.get("items", [])
else:
model_items = parsed
if not isinstance(model_items, list):
raise ValueError("items is not a list")
# 从每个片段中解析 $$ 包裹的人名和局部上下文
raw_items: list[dict] = []
CONTEXT_WINDOW = 40
for seg in model_items:
if isinstance(seg, dict):
marked = str(seg.get("marked") or seg.get("text") or "")
else:
marked = str(seg)
n = len(marked)
i = 0
while i < n:
start = marked.find("$$", i)
if start == -1:
break
end = marked.find("$$", start + 2)
if end == -1:
break
name = marked[start + 2 : end]
if name.strip():
before_start = max(0, start - CONTEXT_WINDOW)
after_end = min(n, end + 2 + CONTEXT_WINDOW)
before_raw = marked[before_start:start]
after_raw = marked[end + 2 : after_end]
# 去掉上下文中残留的标记符号,增强匹配鲁棒性
before_clean = before_raw.replace("$$", "").replace("$", "")
after_clean = after_raw.replace("$$", "").replace("$", "")
raw_items.append(
{
"name": name,
"before": before_clean,
"after": after_clean,
}
)
i = end + 2
return raw_items
except Exception as e: # noqa: BLE001
# 为了排查问题,打印前一段模型原始输出
snippet = ""
try:
snippet = (raw_content if "raw_content" in locals() else content)[0:400]
except Exception: # noqa: BLE001
snippet = "<unavailable>"
logging.exception(
"Error calling API for %s (attempt %d/%d): %s; raw content snippet: %r",
file_rel,
attempt,
RETRY_TIMES,
e,
snippet,
)
if attempt == RETRY_TIMES:
raise
time.sleep(2 * attempt)
return []
def index_to_line_col(text: str, index: int) -> tuple[int, int]:
"""将文本中的全局下标(0-based)转换为行号和列号。
行号从 1 开始,列号从 0 开始。
"""
line = text.count("\n", 0, index) + 1
last_nl = text.rfind("\n", 0, index)
col = index if last_nl == -1 else index - last_nl - 1
return line, col
def locate_mentions(text: str, file_rel: str, raw_items: list[dict]) -> list[dict]:
"""根据模型返回的 before/name/after,在原文中精确定位出现位置。
匹配策略分两步:
1. 先尝试用完整的 before + name + after 做精确匹配(尽量复用模型给出的上下文);
2. 如果失败,再退化到“软提示”模式:before/after 只使用末尾/开头一小段作为提示,
在全文里按顺序查找 name,如果提示对不上则继续找下一个;再失败就仅按 name 查找。
"""
results: list[dict] = []
search_start = 0
# 最多使用多少字符作为前后文匹配提示
HINT_LEN = 20
for raw in raw_items:
name = str(raw.get("name", "")).strip()
before_raw = str(raw.get("before") or "")
after_raw = str(raw.get("after") or "")
# 再次确保上下文中没有 $$ 标记,避免影响匹配
if "$" in before_raw or "$" in after_raw:
before_raw = before_raw.replace("$$", "").replace("$", "")
after_raw = after_raw.replace("$$", "").replace("$", "")
if not name:
continue
# ---------- 第一步:尝试用完整 before + name + after 精确匹配 ----------
pattern = before_raw + name + after_raw
found_index = -1
if pattern:
idx = text.find(pattern, search_start)
if idx == -1:
# 如果从当前起点没找到,就从全文再找一遍
idx = text.find(pattern)
if idx != -1:
# pattern 命中时,人物起点是 pattern 中 name 的起始位置
found_index = idx + len(before_raw)
# ---------- 第二步:如果精确匹配失败,使用软提示 / 仅按 name 查找 ----------
if found_index == -1:
# 只取前后文的一小段作为匹配提示,避免整段不完全一致导致匹配失败
before_hint = before_raw[-HINT_LEN:]
after_hint = after_raw[:HINT_LEN]
idx = search_start
# 先尝试使用提示信息进行严格匹配
while True:
idx = text.find(name, idx)
if idx == -1:
break
ok = True
if before_hint:
before_in_text = text[max(0, idx - len(before_hint)) : idx]
if not before_in_text.endswith(before_hint):
ok = False
if ok and after_hint:
after_in_text = text[idx + len(name) : idx + len(name) + len(after_hint)]
if not after_in_text.startswith(after_hint):
ok = False
if ok:
found_index = idx
break
idx += 1
# 如果基于前后文依然没找到,就退化为只按 name 顺序查找
if found_index == -1:
fallback_idx = text.find(name, search_start)
if fallback_idx == -1:
logging.warning("Cannot locate mention in %s for item %r", file_rel, raw)
continue
found_index = fallback_idx
global_index = found_index
line, col = index_to_line_col(text, global_index)
before_ctx = text[max(0, global_index - 10) : global_index]
after_ctx = text[global_index + len(name) : global_index + len(name) + 10]
results.append(
{
"file": file_rel.replace("\\", "/"),
"name": name,
"index": global_index,
"line": line,
"col": col,
"before": before_ctx,
"after": after_ctx,
}
)
# 下一次搜索从当前人物提及的结尾之后开始
search_start = global_index + len(name)
return results
def load_existing_result(result_path: Path) -> dict[str, Any]:
"""加载已有的 result.json,返回 {meta, items} 结构。"""
if not result_path.exists():
return {"meta": {}, "items": []}
with result_path.open("r", encoding="utf-8") as f:
data = json.load(f)
if isinstance(data, dict):
items = data.get("items", [])
if not isinstance(items, list):
items = []
meta = data.get("meta", {}) or {}
else:
items = data if isinstance(data, list) else []
meta = {}
return {"meta": meta, "items": items}
def _normalize_rel_from_any_path(raw_path: str, base_dir: Path) -> str | None:
"""将日志行里的路径(绝对或相对)统一转换为相对于 base_dir 的 .md 路径。"""
p = Path(raw_path)
if not p.is_absolute():
full = (base_dir / p).resolve()
else:
full = p.resolve()
if not full.name.endswith(".md"):
return None
try:
rel = full.relative_to(base_dir).as_posix()
except ValueError:
# 不在当前目录树下的,直接用文件名
rel = full.name
return rel
def parse_retry_files_from_log(log_path: Path, base_dir: Path) -> list[str]:
"""从 log.log 中解析需要重试的文件(包含 ERROR 或 WARNING 的行涉及的 .md 文件)。"""
if not log_path.exists():
return []
content = log_path.read_text(encoding="utf-8", errors="ignore")
retry_files: set[str] = set()
# 匹配绝对路径或以 days/ 开头的相对路径
pattern = re.compile(r"([A-Za-z]:[\\/].*?\.md|days[\\/].*?\.md)")
for line in content.splitlines():
if "[ERROR]" not in line and "[WARNING]" not in line:
continue
for m in pattern.finditer(line):
rel = _normalize_rel_from_any_path(m.group(1), base_dir)
if rel is not None:
retry_files.add(rel)
return sorted(retry_files)
def parse_retry_files_from_retry_log(retry_log_path: Path, base_dir: Path) -> list[str]:
"""从 retre_log.log 中解析需要重试的文件(“最近一次” Retry from log 之后 FAILED 的 .md 文件)。"""
if not retry_log_path.exists():
return []
content = retry_log_path.read_text(encoding="utf-8", errors="ignore")
retry_files: set[str] = set()
lines = content.splitlines()
# 只考虑“最近一次 Retry from log.” 之后的 FAILED 记录,避免全量重试
last_retry_idx = -1
for idx, line in enumerate(lines):
if "Retry from log." in line:
last_retry_idx = idx
if last_retry_idx != -1:
iter_lines = lines[last_retry_idx + 1 :]
else:
iter_lines = lines
for line in iter_lines:
if "FAILED" not in line:
continue
# 格式类似:[timestamp] FAILED days/2017-xx-xx.md error=...
parts = line.split()
try:
idx = parts.index("FAILED")
except ValueError:
continue
if idx + 1 >= len(parts):
continue
raw_path = parts[idx + 1]
# 去掉可能的逗号或结尾符号
raw_path = raw_path.strip().strip(",")
rel = _normalize_rel_from_any_path(raw_path, base_dir)
if rel is not None:
retry_files.add(rel)
return sorted(retry_files)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Extract name mentions from diary markdown files.")
group = parser.add_mutually_exclusive_group()
group.add_argument(
"--file",
help="只处理指定的单个文件;可以是绝对路径,也可以是相对于脚本目录或 days 目录的路径。",
)
group.add_argument(
"--retry-failed",
action="store_true",
help="根据现有的 log.log 中的 WARNING/ERROR 记录,只重试相关的文件。",
)
return parser.parse_args()
def resolve_single_file(base_dir: Path, days_dir: Path, file_arg: str) -> Path | None:
"""根据 --file 参数解析出实际文件路径。"""
candidate = Path(file_arg)
if not candidate.is_absolute():
# 先按相对于脚本所在目录解析
candidate = base_dir / candidate
if not candidate.exists():
# 再尝试相对于 days 目录
candidate = days_dir / file_arg
if candidate.exists() and candidate.is_file():
return candidate
logging.error("Specified file not found: %s", file_arg)
return None
def main() -> None:
args = parse_args()
setup_logging()
base_dir = Path(__file__).resolve().parent
days_dir = base_dir / DAYS_DIR
result_path = base_dir / RESULT_PATH
log_path = base_dir / LOG_PATH
retry_log_path = base_dir / RETRY_LOG_PATH
logging.info("Start extracting names. days_dir=%s, result=%s", days_dir, result_path)
if not days_dir.exists():
logging.error("Days directory not found: %s", days_dir)
return
# 确定本次需要处理的文件列表
files: list[Path] = []
previous_result: dict[str, Any] | None = None
previous_items: list[dict] = []
if args.file:
# 单文件模式:仅处理指定文件,结果会合并到已有 result.json
target = resolve_single_file(base_dir, days_dir, args.file)
if not target:
return
files = [target]
previous_result = load_existing_result(result_path)
previous_items = list(previous_result["items"])
logging.info("Single file mode: %s", target)
elif args.retry_failed:
# 重试模式:优先从 retre_log.log 中解析 FAILED 文件;
# 如果没有 FAILED 记录,则回退到从 log.log 中解析含 ERROR/WARNING 的文件。
retry_rel_paths = parse_retry_files_from_retry_log(retry_log_path, base_dir)
if not retry_rel_paths:
retry_rel_paths = parse_retry_files_from_log(log_path, base_dir)
if not retry_rel_paths:
logging.info(
"No FAILED entries in %s and no ERROR/WARNING entries in %s, nothing to retry.",
retry_log_path,
log_path,
)
return
files = [base_dir / rel for rel in retry_rel_paths]
previous_result = load_existing_result(result_path)
previous_items = list(previous_result["items"])
logging.info("Retry mode: %d files will be retried based on %s.", len(files), log_path)
# 记录本次重试计划到 retre_log.log
try:
with retry_log_path.open("a", encoding="utf-8") as rf:
rf.write(
f"[{datetime.utcnow().isoformat()}Z] Retry from log. files={retry_rel_paths}\n"
)
except Exception: # noqa: BLE001
logging.exception("Failed to write retry log %s", retry_log_path)
else:
# 全量模式:处理 days 目录下所有 .md 文件,覆盖原有 result.json
files = sorted(days_dir.glob("*.md"))
if not files:
logging.warning("No .md files found in %s", days_dir)
previous_items = []
all_items: list[dict] = []
processed_files: list[str] = []
for path in files:
rel = path.relative_to(base_dir).as_posix()
processed_files.append(rel)
logging.info("Processing %s", rel)
try:
text = path.read_text(encoding="utf-8")
except Exception as e: # noqa: BLE001
logging.exception("Failed to read file %s", rel)
continue
try:
raw_items = call_openai(text, rel)
located = locate_mentions(text, rel, raw_items)
logging.info("Found %d mentions in %s", len(located), rel)
all_items.extend(located)
# 在重试模式下,将结果附加记录到 retre_log.log,方便还原进度
if args.retry_failed:
try:
with retry_log_path.open("a", encoding="utf-8") as rf:
rf.write(
f"[{datetime.utcnow().isoformat()}Z] OK {rel} items={len(located)}\n"
)
except Exception: # noqa: BLE001
logging.exception("Failed to append to retry log %s", retry_log_path)
except Exception as e: # noqa: BLE001
logging.exception("Failed to process file %s", rel)
if args.retry_failed:
try:
with retry_log_path.open("a", encoding="utf-8") as rf:
rf.write(
f"[{datetime.utcnow().isoformat()}Z] FAILED {rel} error={e}\n"
)
except Exception: # noqa: BLE001
logging.exception("Failed to append to retry log %s", retry_log_path)
continue
# 合并旧结果(单文件模式 / 重试模式)
if previous_result is not None:
# 先删除旧结果中这些文件的记录
processed_set = set(processed_files)
kept = [item for item in previous_items if item.get("file") not in processed_set]
all_items = kept + all_items
result = {
"meta": {
"created_at": datetime.utcnow().isoformat() + "Z",
"base_url": BASE_URL,
"model": MODEL,
"temperature": TEMPERATURE,
"days_dir": DAYS_DIR,
"mode": (
"single_file"
if args.file
else "retry_failed"
if args.retry_failed
else "all"
),
},
"items": all_items,
}
try:
with result_path.open("w", encoding="utf-8") as f:
json.dump(result, f, ensure_ascii=False, indent=2)
logging.info("Saved %d items to %s", len(all_items), result_path)
except Exception: # noqa: BLE001
logging.exception("Failed to write %s", result_path)
if __name__ == "__main__":
main()
用法:
正常:
python extract_names.py重试错误部分:python extract_names.py --retry-failed测试单文件:python extract_names.py --file days/【文件名】
第四步
打开check.html,导入result.json进行检查
check.html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>日记人名识别检查</title>
</head>
<body>
<h2>日记人名识别检查(result.json → result_clean.json)</h2>
<p>
1. 点击“选择 result.json 文件”加载识别结果。<br />
2. 在表格中检查每一条记录,点击“删除”可以去掉识别错误的条目。<br />
3. 点击“下载 result_clean.json”保存清洗后的结果。<br />
</p>
<div>
<input type="file" id="fileInput" accept=".json" />
<button id="downloadBtn" disabled>下载 result_clean.json</button>
</div>
<p id="summary"></p>
<table border="1" cellpadding="4" cellspacing="0">
<thead>
<tr>
<th>#</th>
<th>文件</th>
<th>行:列</th>
<th>人名</th>
<th>前后文(前10 / 人名 / 后10)</th>
<th>操作</th>
</tr>
</thead>
<tbody id="tableBody"></tbody>
</table>
<script>
let items = [];
const fileInput = document.getElementById("fileInput");
const downloadBtn = document.getElementById("downloadBtn");
const tableBody = document.getElementById("tableBody");
const summary = document.getElementById("summary");
fileInput.addEventListener("change", function (event) {
const file = event.target.files[0];
if (!file) {
return;
}
const reader = new FileReader();
reader.onload = function (e) {
try {
const text = e.target.result;
const data = JSON.parse(text);
// 兼容两种结构:
// 1) { meta: {...}, items: [...] }
// 2) 直接是 [...]
if (Array.isArray(data)) {
items = data;
} else if (data && Array.isArray(data.items)) {
items = data.items;
} else {
alert("JSON 格式不符合预期,需要包含 items 数组。");
return;
}
renderTable();
downloadBtn.disabled = false;
} catch (err) {
console.error(err);
alert("读取或解析 JSON 失败。");
}
};
reader.readAsText(file, "utf-8");
});
function renderTable() {
tableBody.innerHTML = "";
items.forEach((item, index) => {
const tr = document.createElement("tr");
const tdIndex = document.createElement("td");
tdIndex.textContent = index + 1;
tr.appendChild(tdIndex);
const tdFile = document.createElement("td");
tdFile.textContent = item.file || "";
tr.appendChild(tdFile);
const tdPos = document.createElement("td");
const line = item.line != null ? item.line : "";
const col = item.col != null ? item.col : "";
tdPos.textContent = line !== "" || col !== "" ? line + ":" + col : "";
tr.appendChild(tdPos);
const tdName = document.createElement("td");
tdName.textContent = item.name || "";
tr.appendChild(tdName);
const tdContext = document.createElement("td");
const before = item.before || "";
const after = item.after || "";
const name = item.name || "";
tdContext.textContent = before + "【" + name + "】" + after;
tr.appendChild(tdContext);
const tdAction = document.createElement("td");
const btnDelete = document.createElement("button");
btnDelete.textContent = "删除";
btnDelete.addEventListener("click", function () {
if (confirm("确定要删除这一条识别结果吗?")) {
items.splice(index, 1);
renderTable();
}
});
tdAction.appendChild(btnDelete);
tr.appendChild(tdAction);
tableBody.appendChild(tr);
});
summary.textContent = "当前条目数:" + items.length;
}
downloadBtn.addEventListener("click", function () {
// 导出的结构为 { meta: { ... }, items: [...] }
const output = {
meta: {
cleaned_at: new Date().toISOString(),
count: items.length,
},
items: items,
};
const blob = new Blob([JSON.stringify(output, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "result_clean.json";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
});
</script>
</body>
</html>
第五步
保存result_cleaned.json到目录
第六步
给人名打双链
apply_names.py
import json
import logging
import shutil
from pathlib import Path
# ===== 配置区域:根据自己的情况修改 =====
# 清洗之后的结果文件
RESULT_CLEAN_PATH = "result_clean.json"
# 原始日记所在目录(相对于本脚本所在目录)
DAYS_DIR = "days"
# 输出替换后日记的目录(相对于本脚本所在目录)
OUTPUT_DIR = "days_named"
# 日志文件
LOG_PATH = "log.log"
def setup_logging() -> None:
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[
logging.FileHandler(LOG_PATH, encoding="utf-8"),
logging.StreamHandler(),
],
)
def load_clean_items(path: Path) -> list[dict]:
if not path.exists():
raise FileNotFoundError(f"Clean result file not found: {path}")
with path.open("r", encoding="utf-8") as f:
data = json.load(f)
# 兼容两种结构:
# 1) {"items": [...]}
# 2) 直接是 [...]
if isinstance(data, dict) and "items" in data:
items = data["items"]
else:
items = data
if not isinstance(items, list):
raise ValueError("Invalid result_clean.json format: items must be a list")
return items
def group_by_file(items: list[dict]) -> dict[str, list[dict]]:
"""按文件分组,同时保留 index / before / after 等信息。"""
grouped: dict[str, list[dict]] = {}
for item in items:
file_path = item.get("file")
name = item.get("name")
index = item.get("index")
if file_path is None or name is None or index is None:
continue
try:
index_int = int(index)
except (TypeError, ValueError):
continue
grouped.setdefault(file_path, []).append(
{
"name": str(name),
"index": index_int,
"before": str(item.get("before") or ""),
"after": str(item.get("after") or ""),
}
)
return grouped
def locate_mention_for_apply(text: str, file_rel: str, item: dict) -> int | None:
"""在替换阶段,根据 name/index/before/after 再次定位人物提及位置。
目标:只要 extract_names.py 当时能在这个版本的文本里定位成功,
在文本没有被修改的前提下,这里也应当能找到同一处位置。
"""
name = item["name"]
index = int(item["index"])
before_raw = str(item.get("before") or "")
after_raw = str(item.get("after") or "")
name_len = len(name)
text_len = len(text)
# 1) 首选:如果索引位置上的文本刚好等于 name,直接使用
if 0 <= index <= text_len - name_len and text[index : index + name_len] == name:
return index
# 2) 退化到与 extract_names 中相同的“软提示 + 顺序查找”逻辑
HINT_LEN = 20
before_hint = before_raw[-HINT_LEN:]
after_hint = after_raw[:HINT_LEN]
def find_with_hints(start: int, end: int) -> int | None:
i = start
while True:
i = text.find(name, i, end)
if i == -1:
return None
ok = True
if before_hint:
before_in_text = text[max(0, i - len(before_hint)) : i]
if not before_in_text.endswith(before_hint):
ok = False
if ok and after_hint:
after_in_text = text[i + name_len : i + name_len + len(after_hint)]
if not after_in_text.startswith(after_hint):
ok = False
if ok:
return i
i += 1
# 2.1 优先在 index 附近搜索(避免多个相同 name 时匹配到别处)
if 0 <= index < text_len:
window = 500
start = max(0, index - window)
end = min(text_len, index + window)
pos = find_with_hints(start, end)
if pos is not None:
return pos
# 2.2 全文范围内按“软提示”查找
pos = find_with_hints(0, text_len)
if pos is not None:
return pos
# 3) 最后兜底:只按 name 查找第一处出现
any_idx = text.find(name)
if any_idx != -1:
logging.warning(
"Fallback to first occurrence for %r in %s at index %d",
item,
file_rel,
any_idx,
)
return any_idx
# 理论上如果文本未变化,这里不应发生
logging.error("Name %r not found in %s, cannot replace", name, file_rel)
return None
def apply_replacements_for_file(
base_dir: Path,
file_rel: str,
replacements: list[dict],
output_base: Path,
) -> None:
# 在新目录中的完整副本上进行修改,而不是直接改动原始 days 目录
src = output_base / Path(file_rel).name
if not src.exists():
logging.warning(
"Copied source file not found in output directory, skip: %s", file_rel
)
return
try:
text = src.read_text(encoding="utf-8")
except Exception: # noqa: BLE001
logging.exception("Cannot read %s", file_rel)
return
# 先为每个条目重新定位在当前文本中的位置
located: list[tuple[int, str]] = []
for rep in replacements:
pos = locate_mention_for_apply(text, file_rel, rep)
if pos is None:
continue
located.append((pos, rep["name"]))
# 按位置从大到小排序,避免前面的替换影响后面位置
located.sort(key=lambda x: x[0], reverse=True)
for idx, name in located:
text = text[:idx] + f"[[{name}]]" + text[idx + len(name) :]
# 写回到已经复制好的 days_named 目录(仅保留文件名,不保留原始子目录结构)
try:
src.write_text(text, encoding="utf-8")
logging.info("Wrote %s", src)
except Exception: # noqa: BLE001
logging.exception("Failed to write %s", src)
def copy_all_days_to_output(base_dir: Path, output_base: Path) -> None:
"""先将 days 目录中的所有 .md 文件完整复制到输出目录,再在其上做替换。"""
days_dir = base_dir / DAYS_DIR
if not days_dir.exists():
logging.error("Days directory not found: %s", days_dir)
return
output_base.mkdir(parents=True, exist_ok=True)
for src in days_dir.glob("*.md"):
dst = output_base / src.name
try:
shutil.copy2(src, dst)
logging.info("Copied %s -> %s", src, dst)
except Exception: # noqa: BLE001
logging.exception("Failed to copy %s to %s", src, dst)
def main() -> None:
setup_logging()
base_dir = Path(__file__).resolve().parent
result_clean_path = base_dir / RESULT_CLEAN_PATH
output_base = base_dir / OUTPUT_DIR
# 第一步:先把 days 目录中的所有日记完整复制到输出目录
logging.info("Copying all diary files from %s to %s", DAYS_DIR, OUTPUT_DIR)
copy_all_days_to_output(base_dir, output_base)
logging.info("Loading cleaned results from %s", result_clean_path)
try:
items = load_clean_items(result_clean_path)
except Exception: # noqa: BLE001
logging.exception("Failed to load %s", result_clean_path)
return
grouped = group_by_file(items)
logging.info("Loaded %d cleaned items across %d files", len(items), len(grouped))
for file_rel, replacements in grouped.items():
logging.info("Applying %d replacements to %s", len(replacements), file_rel)
apply_replacements_for_file(base_dir, file_rel, replacements, output_base)
logging.info("All done. Output files are in %s", output_base)
if __name__ == "__main__":
main()
第七步
不显示图谱
update_days_named_headers.py
from pathlib import Path
import re
def process_file(path: Path, year: str) -> None:
"""Prepend header and update image paths in a single markdown file."""
text = path.read_text(encoding="utf-8")
# Detect if the header is already present (to make the script idempotent).
lines = text.splitlines(keepends=True)
has_header = bool(lines) and lines[0].strip() == "exclude-from-graph-view:: true"
# Update image paths.
updated = text
# Standard markdown images: 
updated = updated.replace("](images/", "](../assets/")
# Obsidian-style embeds: ![[images/...]]
updated = updated.replace("![[images/", "![[../assets/")
# Prepend header if missing.
header = f"exclude-from-graph-view:: true\n" f"tags:: 日记, {year}\n\n- "
if not has_header:
updated = header + updated
if updated != text:
path.write_text(updated, encoding="utf-8")
def main() -> None:
base_dir = Path(__file__).resolve().parent
days_named_dir = base_dir / "days_named"
md_paths = sorted(days_named_dir.glob("*.md"))
if not md_paths:
return
# 从每日文件名中提取年份(假设类似 2017-07-11.md)
m = re.match(r"(?P<year>\d{4})", md_paths[0].stem)
if not m:
raise ValueError(f"无法从文件名中提取年份: {md_paths[0].name}")
year = m.group("year")
# Update each daily note
for md_path in md_paths:
process_file(md_path, year)
# Generate index_<year>.md with one wikilink per line, e.g. [[2017-07-11]]
index_lines = []
for md_path in md_paths:
name_without_ext = md_path.stem # e.g. "2017-07-11"
index_lines.append(f"[[{name_without_ext}]]")
index_content = "\n".join(index_lines) + "\n"
# Put the index file inside the days_named folder
(days_named_dir / f"index_{year}.md").write_text(index_content, encoding="utf-8")
if __name__ == "__main__":
main()
第八步
md文件导入logseq,images文件放入assets文件夹
第九步
在Logseq中使用Ctrl+Shift+I打开控制台,执行以下命令,得到json
(async function exportEnhancedLogseqDB() {
console.log("正在解析图谱数据,请稍候...");
// 1. 查询所有页面及其包含的块、以及它们之间的引用关系
// 我们抓取:页面名、内容、所有的引用(refs)、以及所有的路径属性
const query = `
[:find (pull ?e [
:block/name
:block/original-name
:block/content
:block/uuid
{:block/refs [:block/name]}
{:block/_refs [:block/name :block/uuid :block/content]}
{:block/parent [:block/uuid]}
{:block/page [:block/name]}
])
:where
(or [?e :block/name] ;; 匹配页面
[?e :block/content])] ;; 匹配块
`;
const rawData = await window.logseq.api.datascript_query(query);
if (!rawData || rawData.length === 0) {
console.error("未能提取到数据。");
return;
}
// 2. 数据清洗与加工
const processedData = rawData.map(item => {
const entity = item[0];
// 提取 Linked References (即被哪些块引用了)
if (entity['_block/refs']) {
entity['linked_references'] = entity['_block/refs'].map(ref => ({
from_uuid: ref['block/uuid'],
content_preview: ref['block/content'],
from_page: ref['block/page'] ? ref['block/page']['block/name'] : 'unknown'
}));
delete entity['_block/refs']; // 清理原始反向引用字段
}
// 简化正向引用 (Refs)
if (entity['block/refs']) {
entity['outbound_links'] = entity['block/refs'].map(r => r['block/name']);
delete entity['block/refs'];
}
return entity;
});
// 3. 导出 JSON
const blob = new Blob([JSON.stringify(processedData, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `logseq_full_graph_${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
console.log("导出完成!包含页面、内容及双向引用关系。");
})();
第十步
将得到的json重命名为data.json,使用以下html浏览
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>Logseq 图谱二次加工可视化</title>
<script src="https://gw.alipayobjects.com/os/lib/antv/g6/4.8.24/dist/g6.min.js"></script>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/fuse.js@6.6.2"></script>
<style>
#mountNode {
background: #050816;
width: 100vw;
height: 100vh;
}
.detail-panel {
position: fixed;
right: 0;
top: 0;
width: 360px;
height: 100%;
background: #020617;
color: #e5e7eb;
box-shadow: -2px 0 18px rgba(0,0,0,0.6);
transform: translateX(100%);
transition: transform 0.25s ease-out;
z-index: 10;
overflow-y: auto;
border-left: 1px solid rgba(148,163,184,0.3);
}
.detail-panel.open {
transform: translateX(0);
}
.detail-panel h2 {
color: #e5e7eb;
}
.detail-panel h3 {
color: #cbd5f5;
}
.detail-panel a {
color: #60a5fa;
}
.detail-panel pre {
white-space: pre-wrap;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
.wikilink {
color: #facc15;
cursor: pointer;
text-decoration: underline dotted;
}
.wikilink:hover {
color: #fde68a;
}
</style>
</head>
<body class="bg-gray-900">
<div id="mountNode"></div>
<!-- 首屏漫游入口:居中的大搜索框 + 随机/全景按钮 -->
<div id="startOverlay" class="fixed inset-0 z-20 flex items-center justify-center bg-black/70">
<div class="bg-slate-900/95 border border-slate-700 rounded-xl w-[min(640px,90vw)] p-6 shadow-2xl">
<h2 class="text-xl font-bold text-slate-100 mb-4">开始探索你的关系图谱</h2>
<div class="mb-3">
<input
id="startSearchInput"
type="text"
placeholder="输入名称或别名,搜索一个节点开始漫游…"
class="w-full px-4 py-2 rounded-md bg-slate-950 border border-slate-600 text-slate-100 text-sm placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-sky-400 focus:border-sky-400"
autocomplete="off"
/>
</div>
<ul
id="startSearchResults"
class="max-h-60 overflow-y-auto bg-slate-950 border border-slate-700 rounded-md text-xs text-slate-100 shadow-lg hidden mb-3"
></ul>
<div class="flex flex-wrap gap-2 text-[12px] text-slate-200">
<button id="startRandomBtn" class="px-3 py-1.5 rounded border border-slate-600 bg-slate-900 hover:border-sky-400 hover:text-sky-200">
随机选一个节点开始漫游
</button>
<button id="startFullBtn" class="px-3 py-1.5 rounded border border-slate-600 bg-slate-900 hover:border-sky-400 hover:text-sky-200">
直接查看全景图
</button>
</div>
</div>
</div>
<!-- 顶部搜索框 & 漫游模式控制 -->
<div class="fixed left-4 top-4 z-20 flex flex-col gap-1 max-w-xs">
<div class="relative">
<input
id="searchInput"
type="text"
placeholder="搜索名称或别名..."
class="w-full px-3 py-1.5 rounded-md bg-slate-900/80 border border-slate-600 text-slate-100 text-xs placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-sky-400 focus:border-sky-400"
autocomplete="off"
/>
</div>
<ul
id="searchResults"
class="max-h-64 overflow-y-auto bg-slate-900/95 border border-slate-700 rounded-md text-xs text-slate-100 shadow-lg hidden"
></ul>
<div class="flex gap-2 mt-1 text-[11px] text-slate-200">
<button id="toggleRoam" class="px-2 py-1 rounded border border-slate-600 bg-slate-900/80 hover:border-sky-400 hover:text-sky-200">
漫游模式:关
</button>
<button id="randomRoam" class="px-2 py-1 rounded border border-slate-600 bg-slate-900/80 hover:border-sky-400 hover:text-sky-200">
随机节点
</button>
<button id="resetView" class="px-2 py-1 rounded border border-slate-600 bg-slate-900/80 hover:border-sky-400 hover:text-sky-200">
复位视图
</button>
</div>
</div>
<div id="detailPanel" class="detail-panel p-6">
<button onclick="closePanel()" class="mb-4 text-sm text-sky-400 hover:text-sky-300 transition">
← 返回图谱
</button>
<h2 id="title" class="text-2xl font-bold mb-1 break-words"></h2>
<div class="flex items-center justify-between mb-2 text-[11px] text-slate-400">
<div id="meta" class="flex-1"></div>
<button id="clearFocus" class="ml-2 px-1.5 py-0.5 rounded border border-slate-600 text-slate-300 hover:text-slate-100 hover:border-slate-300">
取消聚焦
</button>
</div>
<div class="flex items-center justify-between mb-2 text-[11px] text-slate-400">
<span>正文</span>
<button id="enlargeContent" class="px-1.5 py-0.5 rounded border border-slate-600 text-sky-300 hover:text-sky-100 hover:border-sky-400">
放大阅读
</button>
</div>
<div id="content" class="text-sm text-slate-200 leading-relaxed mb-4 whitespace-pre-wrap"></div>
<h3 class="font-bold border-t border-slate-700 pt-4 mt-4 text-sm">
被以下页面引用 (Linked References)
</h3>
<ul id="refs" class="list-disc ml-5 mt-2 text-xs text-sky-300 space-y-1"></ul>
<div id="refDetails" class="mt-3 space-y-2 text-xs text-slate-200"></div>
</div>
<!-- 右上角统计信息 -->
<div id="graphStats" class="fixed right-4 top-4 z-10 text-[11px] text-slate-300 bg-slate-900/80 border border-slate-700 rounded px-3 py-1.5 shadow-lg pointer-events-none">
节点: - | 孤立节点: - | 边: -
</div>
<!-- 正文放大阅读弹层 -->
<div id="contentModal" class="fixed inset-0 z-30 hidden items-center justify-center bg-black/80">
<div class="w-[80vw] max-w-5xl max-h-[80vh] bg-slate-900 border border-slate-700 rounded-lg p-4 overflow-y-auto shadow-2xl">
<div class="flex items-center justify-between mb-3 gap-3">
<h2 id="modalTitle" class="text-lg font-bold text-slate-100 break-words flex-1"></h2>
<div class="flex items-center gap-2 text-[11px] text-slate-300">
<span class="hidden sm:inline">字号:</span>
<button id="fontSmaller" class="px-1.5 py-0.5 border border-slate-600 rounded hover:border-slate-300">A-</button>
<button id="fontReset" class="px-1.5 py-0.5 border border-slate-600 rounded hover:border-slate-300">A</button>
<button id="fontLarger" class="px-1.5 py-0.5 border border-slate-600 rounded hover:border-slate-300">A+</button>
<button id="closeModal" class="ml-2 text-xs text-slate-400 hover:text-slate-100 px-2 py-1 border border-slate-600 rounded">
关闭
</button>
</div>
</div>
<div id="modalContent" class="text-sm text-slate-200 whitespace-pre-wrap leading-relaxed"></div>
</div>
</div>
<script>
let globalGraph = null;
let globalSearchIndex = []; // 全量索引(不随漫游子图变化)
let globalFuse = null; // 全量搜索(不随漫游子图变化)
let globalNameToId = new Map(); // 规范化名称/别名 -> 节点 id(全量)
let currentNodeLinkedDetails = []; // 当前侧栏节点的引用详情(按块)
let globalAdjacency = {}; // id -> Set(邻居)
let globalCurrentCenterId = null; // 当前高亮节点 id
let globalRoamMode = false; // 当前是否处于漫游模式
let globalLabelVisibleIds = null; // 当前需要强制显示标签的节点 id 集合(center + 一层)
let currentContentHtml = ''; // 当前节点正文的 HTML
let currentTitleText = ''; // 当前节点标题
let currentRefPageBlocks = []; // 当前选中的引用页面下的块列表
let modalFontScale = 1; // 放大阅读字号缩放(会从 localStorage 中恢复)
let roamStarted = false; // 是否已经从首屏开始了漫游或进入全景
const isMobile = (typeof window !== 'undefined') && (('ontouchstart' in window) || (navigator.maxTouchPoints > 0));
const escapeHtml = (str) => String(str || '')
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
const renderWikiLinks = (text) => {
const escaped = escapeHtml(text);
return escaped.replace(/\[\[([^\]]+?)\]\]/g, (m, p1) => {
const name = p1.trim();
if (!name) return m;
return `<span class="wikilink" data-wikilink="${escapeHtml(name)}">[[${escapeHtml(name)}]]</span>`;
});
};
const normalizeName = (name) => String(name || '').trim().toLowerCase();
const applyModalFontScale = () => {
const el = document.getElementById('modalContent');
if (!el) return;
const base = 14; // px
const size = base * modalFontScale;
const lineHeight = 1.5 + (modalFontScale - 1) * 0.3;
el.style.fontSize = `${size}px`;
el.style.lineHeight = `${lineHeight}`;
};
// 初始化放大阅读字号(从本地存储恢复)
(() => {
try {
const saved = window.localStorage.getItem('logseq-graph-modal-font-scale');
if (saved) {
const v = parseFloat(saved);
if (!Number.isNaN(v) && v > 0.5 && v < 3) {
modalFontScale = v;
}
}
} catch (_) {
// ignore
}
})();
// 1. 数据转换函数:将 Logseq 导出的 data.json 转换为 G6 所需的 { nodes, edges }
function transformData(rawData) {
const nodes = [];
const edges = [];
const pagesByName = new Map(); // pageName -> pageObject
const blocksByUuid = new Map(); // blockUuid -> blockObject(已过滤 exclude-from-graph-view)
const pageBlocks = new Map(); // pageName -> [blocks](已过滤 exclude-from-graph-view)
const aliasToCanonical = new Map(); // aliasName -> canonicalPageName
const canonicalToAliases = new Map(); // canonicalPageName -> Set(aliases)
const shouldExcludeBlock = (blk) => {
if (!blk || typeof blk.content !== 'string') return false;
return blk.content.includes('exclude-from-graph-view:: true');
};
const excludedPages = new Set(); // 含有 exclude-from-graph-view:: true 的页面,不在图谱中展示
const getCanonicalName = (name) => {
if (!name) return null;
const trimmed = String(name).trim();
return aliasToCanonical.get(trimmed) || trimmed;
};
// --- 第 0 遍:先收集 alias:: 关系,建立别名映射(别名 -> 大名称;大名称 -> 全量别名集合) ---
for (const item of rawData) {
if (!item || typeof item !== 'object') continue;
if (!item.content || !item.page || !item.page.name) continue;
if (shouldExcludeBlock(item)) continue;
const content = String(item.content);
const match = content.match(/alias::([^\n\r]+)/);
if (!match) continue;
const line = match[1];
const parts = line.split(/[,,]/).map(s => s.trim()).filter(Boolean);
if (!parts.length) continue;
const canonical = String(item.page.name).trim();
aliasToCanonical.set(canonical, canonical);
if (!canonicalToAliases.has(canonical)) canonicalToAliases.set(canonical, new Set());
canonicalToAliases.get(canonical).add(canonical);
for (const alias of parts) {
aliasToCanonical.set(alias, canonical);
canonicalToAliases.get(canonical).add(alias);
}
}
// --- 第一遍:分类页面记录与块记录 ---
for (const item of rawData) {
if (item && typeof item === 'object') {
// 页面节点:有 name / uuid
if (item.name && item.uuid) {
const rawName = item.name;
const pageName = getCanonicalName(rawName);
if (!pageName) continue;
const existing = pagesByName.get(pageName);
if (!existing) {
const clone = Object.assign({}, item);
clone.name = pageName;
pagesByName.set(pageName, clone);
} else if (Array.isArray(item._refs)) {
existing._refs = (existing._refs || []).concat(item._refs);
}
}
// 块节点:有 content + uuid
if (item.content && item.uuid) {
const rawPageName = item.page && item.page.name;
const pName = getCanonicalName(rawPageName);
// 如果某个页面的任意块含有 exclude-from-graph-view:: true,则整页从图谱中隐藏
if (shouldExcludeBlock(item) && pName) {
excludedPages.add(pName);
continue;
}
if (!shouldExcludeBlock(item)) {
blocksByUuid.set(item.uuid, item);
if (pName) {
if (!pageBlocks.has(pName)) pageBlocks.set(pName, []);
pageBlocks.get(pName).push(item);
}
}
}
}
}
// --- 第二遍:为每个页面节点构造 G6 Node(排除含 exclude 标记的页面) ---
for (const [pageName, page] of pagesByName.entries()) {
if (excludedPages.has(pageName)) continue;
// 聚合该页面下的所有块内容,作为正文概要
const blocks = pageBlocks.get(pageName) || [];
const combinedContent = blocks
.map(b => (b.content || '').trim())
.filter(Boolean)
.join('\n\n')
.trim();
const displayName = page['original-name'] || page.name || pageName;
// 使用 _refs 统计被引用的次数(Linked References)
const backRefBlocks = Array.isArray(page._refs) ? page._refs : [];
const linkedRefCount = backRefBlocks.length;
// 通过 _refs 找到“引用它的页面名称”集合
const linkedFromPagesSet = new Set();
const linkedRefDetails = [];
for (const ref of backRefBlocks) {
if (!ref || !ref.uuid) continue;
const blk = blocksByUuid.get(ref.uuid);
if (shouldExcludeBlock(blk)) continue;
const rawSrc = blk && blk.page && blk.page.name;
const srcPage = getCanonicalName(rawSrc);
if (srcPage && srcPage !== pageName) {
linkedFromPagesSet.add(srcPage);
linkedRefDetails.push({
fromPage: srcPage,
content: (blk.content || '').trim()
});
}
}
// 根据 Linked References 数量动态调整节点大小(做一下压缩和限制,防止极端过大)
const minSize = 12;
const maxSize = 60;
const baseSize = 18;
const dynamic = linkedRefCount > 0 ? 6 * Math.log2(linkedRefCount + 1) : 0;
const size = Math.max(minSize, Math.min(maxSize, baseSize + dynamic));
const aliasSet = canonicalToAliases.get(pageName) || new Set([pageName]);
const node = {
id: pageName,
label: displayName,
size,
style: {
fill: '#020617',
stroke: '#38bdf8',
lineWidth: 1
},
mainContent: combinedContent || '(暂无内容)',
linkedRefCount,
linkedFromPages: Array.from(linkedFromPagesSet),
linkedRefDetails,
aliases: Array.from(aliasSet)
};
nodes.push(node);
}
// --- 第三遍:构造边(Edges)---
const edgeKeySet = new Set();
// 3.1 通过块的正向引用 refs 构造边: source = 块所在页面; target = 引用的页面
for (const blk of blocksByUuid.values()) {
const rawSrc = blk.page && blk.page.name;
const srcPage = getCanonicalName(rawSrc);
if (!srcPage || !pagesByName.has(srcPage) || excludedPages.has(srcPage)) continue;
if (Array.isArray(blk.refs)) {
for (const r of blk.refs) {
if (!r || typeof r.name !== 'string') continue;
const targetPage = getCanonicalName(r.name);
if (!targetPage || !pagesByName.has(targetPage) || targetPage === srcPage || excludedPages.has(targetPage)) continue;
const key = srcPage + '-->' + targetPage;
if (!edgeKeySet.has(key)) {
edgeKeySet.add(key);
edges.push({
source: srcPage,
target: targetPage
});
}
}
}
}
// 3.2 再通过页面的反向引用 _refs 确保边完整: source = 引用它的页面; target = 当前页面
for (const [pageName, page] of pagesByName.entries()) {
if (excludedPages.has(pageName)) continue;
const backRefBlocks = Array.isArray(page._refs) ? page._refs : [];
for (const ref of backRefBlocks) {
if (!ref || !ref.uuid) continue;
const blk = blocksByUuid.get(ref.uuid);
if (shouldExcludeBlock(blk)) continue;
const rawSrc = blk && blk.page && blk.page.name;
const srcPage = getCanonicalName(rawSrc);
if (!srcPage || !pagesByName.has(srcPage) || srcPage === pageName || excludedPages.has(srcPage)) continue;
const key = srcPage + '-->' + pageName;
if (!edgeKeySet.has(key)) {
edgeKeySet.add(key);
edges.push({
source: srcPage,
target: pageName
});
}
}
}
// 3.3 隐藏页面桥接:若某个被隐藏页面的单个块同时 refs 多个可见页面,则这些页面两两连边
for (const blk of blocksByUuid.values()) {
const rawSrc = blk.page && blk.page.name;
const srcPage = getCanonicalName(rawSrc);
if (!srcPage || !excludedPages.has(srcPage)) continue;
if (!Array.isArray(blk.refs)) continue;
const targetsSet = new Set();
for (const r of blk.refs) {
if (!r || typeof r.name !== 'string') continue;
const t = getCanonicalName(r.name);
if (!t || excludedPages.has(t) || !pagesByName.has(t)) continue;
targetsSet.add(t);
}
const arr = Array.from(targetsSet);
for (let i = 0; i < arr.length; i++) {
for (let j = i + 1; j < arr.length; j++) {
const a = arr[i];
const b = arr[j];
if (a === b) continue;
const key1 = a + '-->' + b;
const key2 = b + '-->' + a;
if (!edgeKeySet.has(key1) && !edgeKeySet.has(key2)) {
edgeKeySet.add(key1);
edges.push({
source: a,
target: b
});
}
}
}
}
return { nodes, edges };
}
// 2. 初始化图谱
async function renderGraph() {
let rawData = [];
try {
const resp = await fetch('data.json');
rawData = await resp.json();
} catch (e) {
console.error('读取 data.json 失败:', e);
alert('无法加载 data.json,请确保 index.html 与 data.json 位于同一目录,且通过本地静态服务器打开。');
return;
}
const data = transformData(rawData || []);
const graph = new G6.Graph({
container: 'mountNode',
width: window.innerWidth,
height: window.innerHeight,
layout: {
type: 'force',
preventOverlap: true,
linkDistance: 170,
nodeStrength: -320,
edgeStrength: 0.12,
collideStrength: 1.3,
alphaDecay: 0.03,
animate: true // 恢复“弹弹弹”的动态布局效果
},
modes: {
// 桌面:画布拖拽 + 缩放 + 节点拖拽;移动端:仅缩放,拖拽/点按由自定义触摸逻辑处理
default: isMobile
? ['zoom-canvas']
: ['drag-canvas', 'zoom-canvas', 'drag-node'],
},
defaultNode: {
labelCfg: {
style: {
fill: '#e5e7eb',
fontSize: 11,
fontFamily: 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
opacity: 1
}
}
},
defaultEdge: {
style: {
stroke: '#475569',
lineWidth: 0.6,
opacity: 0.35
}
},
nodeStateStyles: {
highlight: {
opacity: 1,
stroke: '#38bdf8',
lineWidth: 2,
fill: '#020617'
},
center: {
opacity: 1,
stroke: '#f97316',
lineWidth: 3,
fill: '#020617'
},
inactive: {
opacity: 0.15
}
},
edgeStateStyles: {
highlight: {
stroke: '#38bdf8',
lineWidth: 1.1,
opacity: 0.8
},
inactive: {
opacity: 0.05
}
}
});
// 初始不渲染完整图(避免占用过多内存/GPU),先渲染空画布;
// 真正开始漫游(搜索/随机)或切到全景时再用 changeData 渲染子图/全图。
graph.data({ nodes: [], edges: [] });
graph.render();
globalGraph = graph;
// 全量搜索索引(不随漫游子图变化),确保在引用正文里点 [[双括号]] 时始终能解析到节点
globalSearchIndex = data.nodes.map(n => ({
id: n.id,
label: n.label || n.id,
aliases: Array.isArray(n.aliases) ? n.aliases.join(' ') : ''
}));
if (window.Fuse) {
globalFuse = new Fuse(globalSearchIndex, {
threshold: 0.3,
keys: ['label', 'aliases']
});
}
// 全量名称/别名 -> id 映射
globalNameToId = new Map();
data.nodes.forEach(n => {
const id = n.id;
globalNameToId.set(normalizeName(id), id);
if (n.label) globalNameToId.set(normalizeName(n.label), id);
if (Array.isArray(n.aliases)) {
n.aliases.forEach(a => globalNameToId.set(normalizeName(a), id));
}
});
// 建立邻接表,用于高亮与漫游模式,并统计节点/边数量以及孤立节点数
globalAdjacency = {};
data.edges.forEach(e => {
if (!globalAdjacency[e.source]) globalAdjacency[e.source] = new Set();
if (!globalAdjacency[e.target]) globalAdjacency[e.target] = new Set();
globalAdjacency[e.source].add(e.target);
globalAdjacency[e.target].add(e.source);
});
// 统计信息
const degreeMap = new Map();
data.nodes.forEach(n => degreeMap.set(n.id, 0));
data.edges.forEach(e => {
if (degreeMap.has(e.source)) degreeMap.set(e.source, degreeMap.get(e.source) + 1);
if (degreeMap.has(e.target)) degreeMap.set(e.target, degreeMap.get(e.target) + 1);
});
let isolatedCount = 0;
degreeMap.forEach(v => {
if (v === 0) isolatedCount += 1;
});
const statsEl = document.getElementById('graphStats');
if (statsEl) {
statsEl.textContent = `节点: ${data.nodes.length} | 孤立节点: ${isolatedCount} | 边: ${data.edges.length}`;
}
let roamMode = false;
let roamVisibleIds = null; // Set<string> 当前漫游模式下可见节点
// 自适应窗口大小变化
window.addEventListener('resize', () => {
if (!graph || graph.get('destroyed')) return;
graph.changeSize(window.innerWidth, window.innerHeight);
});
// 画布拖拽完全交由内置 'drag-canvas' 行为处理
// 远景缩小时隐藏标签,避免文字过密;若有高亮中心,则仅显示中心及一层节点标签
const updateLabelVisibility = (zoom) => {
const nodes = graph.getNodes();
const hasHighlight = globalLabelVisibleIds && globalLabelVisibleIds.size > 0;
const showByZoom = zoom >= 0.7;
nodes.forEach(node => {
const model = node.getModel();
const id = node.getID();
let visible = showByZoom;
if (hasHighlight) {
visible = globalLabelVisibleIds.has(id);
}
graph.updateItem(node, {
labelCfg: {
style: Object.assign({}, model.labelCfg && model.labelCfg.style, {
opacity: visible ? 1 : 0
})
}
});
});
};
graph.on('viewportchange', (e) => {
const zoom = e.matrix[0];
updateLabelVisibility(zoom);
});
const resetHighlight = () => {
if (!graph) return;
graph.getNodes().forEach(n => graph.clearItemStates(n));
graph.getEdges().forEach(e => graph.clearItemStates(e));
globalCurrentCenterId = null;
globalLabelVisibleIds = null;
// 恢复为仅按缩放控制标签
updateLabelVisibility(graph.getZoom());
};
// 提供给全局点击“取消聚焦”使用
window.resetGraphHighlight = resetHighlight;
const applyHighlight = (centerId) => {
if (!graph || !globalAdjacency || !centerId) return;
resetHighlight();
// 先拿到中心节点 3 层以内的距离映射
const MAX_DEPTH = 3;
const dist = (() => {
const d = new Map();
if (!globalAdjacency[centerId]) return d;
d.set(centerId, 0);
const queue = [centerId];
while (queue.length) {
const id = queue.shift();
const cur = d.get(id);
if (cur >= MAX_DEPTH) continue;
const neighbors = globalAdjacency[id] || new Set();
neighbors.forEach(n => {
if (!d.has(n)) {
d.set(n, cur + 1);
queue.push(n);
}
});
}
return d;
})();
if (!dist.size) return;
// 初始只取一层(中心 + 1 层)
let visited = new Set([centerId]);
dist.forEach((v, id) => {
if (v === 1) visited.add(id);
});
// 若其中有在当前高亮子图中“度为 0”的点,则尽量补进其二/三层邻居
const computeDegreesInSet = (set) => {
const degree = new Map();
set.forEach(id => degree.set(id, 0));
const allEdges = graph.getEdges().map(e => e.getModel());
allEdges.forEach(m => {
if (set.has(m.source) && set.has(m.target)) {
degree.set(m.source, (degree.get(m.source) || 0) + 1);
degree.set(m.target, (degree.get(m.target) || 0) + 1);
}
});
return degree;
};
for (let iter = 0; iter < 2; iter++) { // 最多补两轮
const degree = computeDegreesInSet(visited);
const toAdd = new Set();
visited.forEach(id => {
if ((degree.get(id) || 0) > 0) return;
const d = dist.get(id);
if (d == null || d >= MAX_DEPTH) return;
const neighbors = globalAdjacency[id] || new Set();
neighbors.forEach(n => {
const dn = dist.get(n);
if (dn != null && dn <= MAX_DEPTH && !visited.has(n)) {
toAdd.add(n);
}
});
});
if (!toAdd.size) break;
toAdd.forEach(id => visited.add(id));
}
const nodes = graph.getNodes();
const edges = graph.getEdges();
nodes.forEach(n => graph.clearItemStates(n));
edges.forEach(e => graph.clearItemStates(e));
nodes.forEach(n => {
const id = n.getID();
if (visited.has(id)) {
graph.setItemState(n, 'highlight', true);
if (id === centerId) {
graph.setItemState(n, 'center', true);
}
} else {
graph.setItemState(n, 'inactive', true);
}
});
edges.forEach(e => {
const m = e.getModel();
const inSet = visited.has(m.source) && visited.has(m.target);
graph.setItemState(e, inSet ? 'highlight' : 'inactive', true);
});
// 高亮时:仅中心及其“尽量消孤立后”的邻域显示标签
globalLabelVisibleIds = visited;
updateLabelVisibility(graph.getZoom());
};
const showNodeDetail = (nodeItem) => {
const model = nodeItem.getModel();
globalCurrentCenterId = model.id;
const panel = document.getElementById('detailPanel');
document.getElementById('title').innerText = model.label;
currentTitleText = model.label;
const meta = [];
if (Array.isArray(model.aliases) && model.aliases.length > 1) {
meta.push(`别名:${model.aliases.join(' / ')}`);
}
if (typeof model.linkedRefCount === 'number') {
meta.push(`被引用块数:${model.linkedRefCount}`);
}
document.getElementById('meta').innerText = meta.join(' · ');
currentContentHtml = renderWikiLinks(model.mainContent || '');
document.getElementById('content').innerHTML = currentContentHtml;
const fromPages = Array.isArray(model.linkedFromPages) ? model.linkedFromPages : [];
const refsHtml = fromPages
.map(name => `<li class="cursor-pointer hover:text-sky-200" data-ref-page="${name}">${name}</li>`)
.join('');
document.getElementById('refs').innerHTML = refsHtml || '<li class="text-slate-500">无引用页面</li>';
const detailContainer = document.getElementById('refDetails');
const details = Array.isArray(model.linkedRefDetails) ? model.linkedRefDetails : [];
currentNodeLinkedDetails = details;
if (details.length) {
detailContainer.innerHTML = '<div class="text-slate-400">点击上方某个页面名,在该行下方展开对应引用块内容。</div>';
} else {
detailContainer.innerHTML = '<div class="text-slate-500">无引用详情</div>';
}
panel.classList.add('open');
applyHighlight(model.id);
};
const applyRoamFromNode = (centerId, append) => {
if (!centerId) return;
let visible = append && roamVisibleIds ? new Set(roamVisibleIds) : new Set();
visible.add(centerId);
roamVisibleIds = visible;
const subNodes = data.nodes.filter(n => visible.has(n.id));
const subEdges = data.edges.filter(e => visible.has(e.source) && visible.has(e.target));
graph.changeData({ nodes: subNodes, edges: subEdges });
// 复位视图,让当前漫游子图居中显示
fitViewToNodeIds(Array.from(visible));
const item = graph.findById(centerId);
if (item) {
showNodeDetail(item);
}
// 首次漫游时隐藏首屏覆盖层
if (!roamStarted && startOverlay) {
roamStarted = true;
startOverlay.classList.add('hidden');
}
};
const roamButton = document.getElementById('toggleRoam');
const randomButton = document.getElementById('randomRoam');
const resetButton = document.getElementById('resetView');
const startOverlay = document.getElementById('startOverlay');
const startRandomBtn = document.getElementById('startRandomBtn');
const startFullBtn = document.getElementById('startFullBtn');
const fitViewToNodeIds = (ids) => {
const items = ids
.map(id => graph.findById(id))
.filter(Boolean);
if (!items.length) {
graph.fitView(20);
return;
}
let minX = Infinity, minY = Infinity;
let maxX = -Infinity, maxY = -Infinity;
items.forEach(node => {
const m = node.getModel();
const x = m.x || 0;
const y = m.y || 0;
if (x < minX) minX = x;
if (y < minY) minY = y;
if (x > maxX) maxX = x;
if (y > maxY) maxY = y;
});
const width = graph.get('width');
const height = graph.get('height');
const cx = (minX + maxX) / 2;
const cy = (minY + maxY) / 2;
const size = Math.max(maxX - minX, maxY - minY, 10);
const scale = Math.min(width, height) / (size * 2.2);
const canvasCenter = { x: width / 2, y: height / 2 };
const centerCanvasPoint = graph.getCanvasByPoint(cx, cy);
graph.translate(canvasCenter.x - centerCanvasPoint.x, canvasCenter.y - centerCanvasPoint.y);
graph.zoomTo(scale, canvasCenter);
};
const resetView = () => {
if (!graph) return;
// 若当前有聚焦节点,则以该节点及其一层节点为中心复位;否则对当前子图做整体 fitView
if (globalCurrentCenterId && globalAdjacency[globalCurrentCenterId]) {
const ids = new Set([globalCurrentCenterId]);
(globalAdjacency[globalCurrentCenterId] || new Set()).forEach(n => ids.add(n));
fitViewToNodeIds(Array.from(ids));
} else {
graph.fitView(20);
}
};
const beginRoamAtNode = (id) => {
if (!id) return;
setRoamMode(true);
applyRoamFromNode(id, false);
// 等当前子图布局完成后,再做一次视图复位,体验等同于手动点击“复位视图”
graph.once('afterlayout', () => {
resetView();
});
};
// 暴露给全局事件处理使用(首屏搜索、其它全局逻辑)
window.beginRoamAtNode = beginRoamAtNode;
const setRoamMode = (on) => {
roamMode = on;
globalRoamMode = on;
if (roamButton) {
// 默认漫游;点击按钮切全景
roamButton.textContent = on ? '切换到全景' : '切换到漫游';
}
if (!on) {
roamVisibleIds = null;
graph.changeData(data);
globalSearchIndex = data.nodes.map(n => ({
id: n.id,
label: n.label || n.id,
aliases: Array.isArray(n.aliases) ? n.aliases.join(' ') : ''
}));
if (window.Fuse) {
globalFuse = new Fuse(globalSearchIndex, {
threshold: 0.3,
keys: ['label', 'aliases']
});
}
resetHighlight();
}
};
if (roamButton) {
roamButton.addEventListener('click', () => {
// 在首屏没开始前,先隐藏覆盖层
if (!roamStarted && startOverlay) {
roamStarted = true;
startOverlay.classList.add('hidden');
}
setRoamMode(!roamMode);
});
}
if (randomButton) {
randomButton.addEventListener('click', () => {
if (!data.nodes.length) return;
const candidates = data.nodes.filter(n => globalAdjacency[n.id] && globalAdjacency[n.id].size > 0);
if (!candidates.length) return;
const picked = candidates[Math.floor(Math.random() * candidates.length)];
beginRoamAtNode(picked.id);
});
}
if (startRandomBtn) {
startRandomBtn.addEventListener('click', () => {
if (!data.nodes.length) return;
const candidates = data.nodes.filter(n => globalAdjacency[n.id] && globalAdjacency[n.id].size > 0);
if (!candidates.length) return;
const picked = candidates[Math.floor(Math.random() * candidates.length)];
beginRoamAtNode(picked.id);
});
}
if (startFullBtn) {
startFullBtn.addEventListener('click', () => {
roamStarted = true;
if (startOverlay) startOverlay.classList.add('hidden');
setRoamMode(false);
resetHighlight();
resetView();
});
}
if (resetButton) {
resetButton.addEventListener('click', resetView);
}
// 3. 点击交互:展开详细信息 + 高亮关系 / 漫游扩展
const handleNodeTapOrClick = (e) => {
const item = e.item;
if (!item) return;
const id = item.getID();
// 再次点击同一聚焦节点:取消聚焦 / 高亮
if (globalCurrentCenterId === id) {
resetHighlight();
return;
}
if (roamMode) {
// 漫游模式下,点击图中的节点只切换详情,不扩展子图
graph.focusItem(item, true);
showNodeDetail(item);
} else {
graph.focusItem(item, true);
showNodeDetail(item);
}
};
graph.on('node:click', handleNodeTapOrClick);
// 移动端:自定义 touch 逻辑,区分“点按聚焦”和“拖动画布”
if (isMobile) {
let touchState = null; // { type: 'node' | 'canvas', id?, x, y, time }
const TAP_DISTANCE = 8; // px
const TAP_DURATION = 300; // ms
const startTouch = (type) => (e) => {
const id = type === 'node' && e.item ? e.item.getID() : null;
touchState = {
type,
id,
x: e.x,
y: e.y,
time: Date.now(),
lastX: e.x,
lastY: e.y
};
};
const moveTouch = (e) => {
if (!touchState) return;
const dx = e.x - touchState.lastX;
const dy = e.y - touchState.lastY;
// 只要有明显移动,就当作拖动画布
graph.translate(dx, dy);
touchState.lastX = e.x;
touchState.lastY = e.y;
};
const endTouch = (type) => (e) => {
if (!touchState || touchState.type !== type) {
touchState = null;
return;
}
const dx = e.x - touchState.x;
const dy = e.y - touchState.y;
const dist = Math.hypot(dx, dy);
const dt = Date.now() - touchState.time;
// 轻触在节点上:视为点击聚焦
if (type === 'node' && dist < TAP_DISTANCE && dt < TAP_DURATION) {
handleNodeTapOrClick(e);
}
touchState = null;
};
// 在节点上按下/移动/抬起
graph.on('node:touchstart', startTouch('node'));
graph.on('node:touchmove', moveTouch);
graph.on('node:touchend', endTouch('node'));
// 在画布空白处按下/移动/抬起(仅用于拖动画布)
graph.on('canvas:touchstart', startTouch('canvas'));
graph.on('canvas:touchmove', moveTouch);
graph.on('canvas:touchend', () => { touchState = null; });
}
// 点击画布空白处,清除高亮
// 画布点击仅用于拖拽,不再取消聚焦 / 高亮,防止误触
graph.on('canvas:click', () => {});
// 暴露一个给搜索 / wikilink 使用的打开节点函数
window.__openNodeById = (id) => {
if (!graph) return;
if (roamMode) {
applyRoamFromNode(id, true);
} else {
const item = graph.findById(id);
if (!item) return;
graph.focusItem(item, true);
showNodeDetail(item);
}
};
// 初始进入页面默认处于漫游模式,由首屏覆盖层引导用户选择“搜索节点开始 / 随机节点 / 全景图”
setRoamMode(true);
}
function closePanel() {
document.getElementById('detailPanel').classList.remove('open');
}
// 点击具体引用页面,展开该页面下的引用块详情(在对应行下面)
document.addEventListener('click', (e) => {
const li = e.target.closest('#refs li[data-ref-page]');
if (!li) return;
const pageName = li.getAttribute('data-ref-page');
const related = (currentNodeLinkedDetails || []).filter(d => d.fromPage === pageName);
currentRefPageBlocks = related;
let detailBox = li.querySelector('.ref-detail-box');
if (!detailBox) {
detailBox = document.createElement('div');
detailBox.className = 'ref-detail-box mt-1 space-y-2';
li.appendChild(detailBox);
}
if (!related.length) {
detailBox.innerHTML = `<div class="text-slate-500">该页面的引用块暂无可展示内容(可能被过滤)。</div>`;
return;
}
const open = detailBox.dataset.open === 'true';
if (open) {
detailBox.dataset.open = 'false';
detailBox.innerHTML = '';
return;
}
detailBox.dataset.open = 'true';
detailBox.innerHTML = related.map((d, idx) => `
<div class="border border-slate-700/60 rounded-md px-2 py-1.5">
<div class="flex items-center justify-between mb-1">
<div class="text-[11px] text-sky-300">${escapeHtml(d.fromPage || '未知页面')}</div>
<button class="ref-enlarge text-[11px] text-sky-300 hover:text-sky-100 px-1.5 py-0.5 border border-slate-600 rounded" data-ref-index="${idx}">
放大阅读
</button>
</div>
<pre class="text-[11px] text-slate-200 bg-slate-900/40 rounded-sm px-1 py-1 max-h-40 overflow-y-auto">${renderWikiLinks(d.content || '')}</pre>
</div>
`).join('');
});
// 简单搜索交互(左上角)
document.addEventListener('input', (e) => {
if (e.target && e.target.id === 'searchInput') {
const value = e.target.value.trim();
const list = document.getElementById('searchResults');
if (!value || !globalFuse) {
list.innerHTML = '';
list.classList.add('hidden');
return;
}
const results = globalFuse.search(value, { limit: 12 });
if (!results.length) {
list.innerHTML = '<li class="px-3 py-1.5 text-slate-500">无匹配结果</li>';
list.classList.remove('hidden');
return;
}
list.innerHTML = results.map(r => {
const d = r.item;
const aliasText = d.aliases ? `(${d.aliases})` : '';
return `<li data-node-id="${d.id}" class="px-3 py-1.5 hover:bg-slate-800 cursor-pointer">${d.label}${aliasText}</li>`;
}).join('');
list.classList.remove('hidden');
}
});
document.addEventListener('click', (e) => {
const li = e.target.closest('#searchResults li');
if (li && globalGraph) {
const id = li.getAttribute('data-node-id');
if (window.__openNodeById) {
window.__openNodeById(id);
}
document.getElementById('searchResults').classList.add('hidden');
}
});
// 首屏大搜索框交互
document.addEventListener('input', (e) => {
if (e.target && e.target.id === 'startSearchInput') {
const value = e.target.value.trim();
const list = document.getElementById('startSearchResults');
if (!value || !globalFuse) {
list.innerHTML = '';
list.classList.add('hidden');
return;
}
const results = globalFuse.search(value, { limit: 12 });
if (!results.length) {
list.innerHTML = '<li class="px-4 py-1.5 text-slate-500">无匹配结果</li>';
list.classList.remove('hidden');
return;
}
list.innerHTML = results.map(r => {
const d = r.item;
const aliasText = d.aliases ? `(${d.aliases})` : '';
return `<li data-node-id="${d.id}" class="px-4 py-1.5 hover:bg-slate-800 cursor-pointer">${d.label}${aliasText}</li>`;
}).join('');
list.classList.remove('hidden');
}
});
document.addEventListener('click', (e) => {
const li = e.target.closest('#startSearchResults li');
if (li && globalGraph) {
const id = li.getAttribute('data-node-id');
roamStarted = true;
const overlay = document.getElementById('startOverlay');
if (overlay) overlay.classList.add('hidden');
if (window.beginRoamAtNode) {
window.beginRoamAtNode(id);
}
document.getElementById('startSearchResults').classList.add('hidden');
}
});
// 点击 [[wiki]] 链接,跳转到对应节点
document.addEventListener('click', (e) => {
const span = e.target.closest('.wikilink[data-wikilink]');
if (!span || !globalGraph) return;
const name = span.getAttribute('data-wikilink');
const candidateId = globalNameToId.get(normalizeName(name)) || name;
if (window.__openNodeById) {
window.__openNodeById(candidateId);
}
});
// 放大 / 关闭正文阅读弹层
document.addEventListener('click', (e) => {
const modal = document.getElementById('contentModal');
if (e.target && e.target.id === 'enlargeContent') {
if (!currentContentHtml) return;
document.getElementById('modalTitle').innerText = currentTitleText || '';
document.getElementById('modalContent').innerHTML = currentContentHtml;
applyModalFontScale();
modal.classList.remove('hidden');
modal.classList.add('flex');
return;
}
const refBtn = e.target.closest('.ref-enlarge[data-ref-index]');
if (refBtn) {
const idx = parseInt(refBtn.getAttribute('data-ref-index'), 10);
const block = (currentRefPageBlocks || [])[idx];
if (!block) return;
document.getElementById('modalTitle').innerText = `${block.fromPage || '引用页面'} · 引用块`;
document.getElementById('modalContent').innerHTML = renderWikiLinks(block.content || '');
applyModalFontScale();
modal.classList.remove('hidden');
modal.classList.add('flex');
return;
}
if (e.target && (e.target.id === 'closeModal' || e.target.id === 'contentModal')) {
modal.classList.add('hidden');
modal.classList.remove('flex');
return;
}
if (e.target && e.target.id === 'fontSmaller') {
modalFontScale = Math.max(0.7, modalFontScale - 0.1);
try {
window.localStorage.setItem('logseq-graph-modal-font-scale', String(modalFontScale));
} catch (_) {}
applyModalFontScale();
return;
}
if (e.target && e.target.id === 'fontLarger') {
modalFontScale = Math.min(2.0, modalFontScale + 0.1);
try {
window.localStorage.setItem('logseq-graph-modal-font-scale', String(modalFontScale));
} catch (_) {}
applyModalFontScale();
return;
}
if (e.target && e.target.id === 'fontReset') {
modalFontScale = 1;
try {
window.localStorage.setItem('logseq-graph-modal-font-scale', String(modalFontScale));
} catch (_) {}
applyModalFontScale();
return;
}
if (e.target && e.target.id === 'clearFocus') {
// 重置高亮/聚焦状态,并收起详情面板
if (window.resetGraphHighlight) {
window.resetGraphHighlight();
}
const panel = document.getElementById('detailPanel');
if (panel) panel.classList.remove('open');
}
});
renderGraph();
</script>
</body>
</html>
