yan-istart_IStart-Note-AI-P.../src/features/reading/ReadingProjectManager.ts
Yan e262d6c0ad fix(reading): chapter files prefixed with zero-padded number for sort order
Before: 婴儿喂养.md, 新生儿护理.md (random alphabetical order)
After:  01-婴儿喂养.md, 02-新生儿护理.md (natural reading order)

- createChapterNote: filename = '{NN}-{title}.md'
- writeChapterQuestions: uses same pattern to locate files
- createIndexPage: links use [[NN-title|title]] format

This ensures Obsidian's file tree displays chapters in reading order
regardless of the user's sort setting.
2026-05-20 22:07:13 +08:00

374 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { App, TFile, normalizePath, Notice } from "obsidian";
import { ReadingPlan, ChapterSkeleton, ChapterDetail, ChapterSummaryResult, ReadingPlanner } from "../../ai/ReadingPlanner";
import { DeepSeekSettings } from "../../types";
const READING_ROOT = "Knowledge/Reading";
export class ReadingProjectManager {
constructor(private app: App, private settings: DeepSeekSettings) {}
/**
* 创建阅读项目:先建骨架,再逐章生成问题。
* 支持中断后恢复。
*/
async createProject(
plan: ReadingPlan,
onProgress?: (current: number, total: number, chapter: string) => void
): Promise<TFile> {
const projectFolder = normalizePath(`${READING_ROOT}/${this.sanitize(plan.bookTitle)}`);
await this.ensureFolder(projectFolder);
// 1. 创建索引页(骨架)
const indexFile = await this.createIndexPage(projectFolder, plan);
// 2. 创建所有章节笔记(空模板,不含问题)
for (const chapter of plan.chapters) {
await this.createChapterNote(projectFolder, plan.bookTitle, chapter);
}
// 3. 逐章生成预设问题
const planner = new ReadingPlanner(this.settings);
let generated = 0;
for (const chapter of plan.chapters) {
generated++;
onProgress?.(generated, plan.chapters.length, chapter.title);
try {
const detail = await planner.generateChapterQuestions(plan.bookTitle, chapter);
await this.writeChapterQuestions(projectFolder, chapter, detail);
} catch {
// 单章失败不中断整个流程,后续可以补全
console.error(`[Reading] Failed to generate questions for chapter ${chapter.number}`);
}
}
// 4. 更新索引页状态
await this.updateGenerationStatus(indexFile, generated, plan.chapters.length);
// 5. 预创建核心概念页
await this.ensureConceptNotes(plan.keyConcepts, plan.bookTitle);
return indexFile;
}
/**
* 补全阅读项目:扫描缺少预设问题的章节,逐个生成。
*/
async resumeProject(
indexFile: TFile,
onProgress?: (current: number, total: number, chapter: string) => void
): Promise<number> {
const content = await this.app.vault.read(indexFile);
const meta = this.app.metadataCache.getFileCache(indexFile);
const fm = meta?.frontmatter;
if (fm?.type !== "reading-project") {
throw new Error("当前文件不是阅读项目索引页");
}
const bookTitle = (fm.book as string) || "";
const projectFolder = indexFile.parent?.path ?? "";
// 扫描所有章节笔记,找出缺少问题的
const chapterFiles = this.app.vault.getMarkdownFiles().filter(
(f) => f.path.startsWith(projectFolder) && f.path !== indexFile.path && f.name !== "_总结.md"
);
const incomplete: { file: TFile; number: number; title: string; summary: string; concepts: string[] }[] = [];
for (const file of chapterFiles) {
const fileMeta = this.app.metadataCache.getFileCache(file);
const fileFm = fileMeta?.frontmatter;
if (fileFm?.type !== "reading-note") continue;
if (fileFm?.questions_generated === true) continue;
// 读取文件检查是否有预设问题内容
const fileContent = await this.app.vault.read(file);
const questionsMatch = fileContent.match(/## 读前问题\n([\s\S]*?)(?=\n## )/);
const hasQuestions = questionsMatch
? questionsMatch[1].split("\n").filter((l) => l.trim().startsWith("- [")).length > 0
: false;
if (!hasQuestions) {
incomplete.push({
file,
number: (fileFm?.chapter as number) || 0,
title: (fileFm?.title as string) || file.basename,
summary: "",
concepts: [],
});
}
}
if (incomplete.length === 0) return 0;
const planner = new ReadingPlanner(this.settings);
let done = 0;
for (const ch of incomplete) {
done++;
onProgress?.(done, incomplete.length, ch.title);
try {
const skeleton: ChapterSkeleton = {
number: ch.number,
title: ch.title,
summary: ch.summary,
importance: "recommended",
keyConcepts: ch.concepts,
};
const detail = await planner.generateChapterQuestions(bookTitle, skeleton);
// 写入问题到章节文件
let fileContent = await this.app.vault.read(ch.file);
const questionsLines = detail.questions.map((q) => `- [ ] ${q}`).join("\n");
if (fileContent.includes("## 读前问题")) {
fileContent = fileContent.replace(
/## 读前问题\n[\s\S]*?(?=\n## )/,
`## 读前问题\n${questionsLines}\n`
);
}
// 更新 frontmatter
fileContent = fileContent.replace(
/questions_generated: false/,
"questions_generated: true"
);
if (!fileContent.includes("questions_generated")) {
fileContent = fileContent.replace("---\n\n", "questions_generated: true\n---\n\n");
}
await this.app.vault.modify(ch.file, fileContent);
} catch {
console.error(`[Reading] Resume failed for chapter ${ch.number}`);
}
}
// 更新索引页状态
const totalChapters = chapterFiles.filter((f) => {
const m = this.app.metadataCache.getFileCache(f);
return m?.frontmatter?.type === "reading-note";
}).length;
await this.updateGenerationStatus(indexFile, totalChapters - (incomplete.length - done), totalChapters);
return done;
}
/** 将章节总结写入章节笔记 */
async writeChapterSummary(file: TFile, result: ChapterSummaryResult): Promise<void> {
let content = await this.app.vault.read(file);
content = content.replace(/status: (unread|reading)/, "status: done");
const summarySection = `## 章节总结\n\n${result.summary}`;
if (content.includes("## 章节总结")) {
content = content.replace(/## 章节总结[\s\S]*?(?=\n## |$)/, summarySection + "\n");
} else {
content = content.trimEnd() + "\n\n" + summarySection + "\n";
}
if (result.answeredQuestions.length > 0) {
const qaLines = result.answeredQuestions
.map((qa) => `**Q: ${qa.question}**\nA: ${qa.answer}`)
.join("\n\n");
content = content.trimEnd() + `\n\n## 问题回答\n\n${qaLines}\n`;
}
if (result.mermaid) {
content = content.trimEnd() + `\n\n## 关系图\n\n\`\`\`mermaid\n${result.mermaid}\n\`\`\`\n`;
}
if (result.connections.length > 0) {
content = content.trimEnd() + `\n\n## 跨章关联\n\n${result.connections.map((c) => `- ${c}`).join("\n")}\n`;
}
await this.app.vault.modify(file, content);
}
// ── 私有方法 ───────────────────────────────────────────────
private async createIndexPage(folder: string, plan: ReadingPlan): Promise<TFile> {
const indexPath = normalizePath(`${folder}/_索引.md`);
const progressLines = plan.chapters.map((ch) => {
const icon = ch.importance === "core" ? "⭐" : ch.importance === "recommended" ? "📖" : "📄";
const numPrefix = String(ch.number).padStart(2, "0");
const fileName = `${numPrefix}-${this.sanitize(ch.title)}`;
return `- [ ] ${icon}${ch.number}章:[[${fileName}|${ch.title}]]`;
});
const mermaidLines = plan.chapterRelations.length > 0
? `\`\`\`mermaid\ngraph LR\n ${plan.chapterRelations.join("\n ")}\n\`\`\``
: "";
const conceptLinks = plan.keyConcepts.map((c) => `[[${c}]]`).join(" · ");
const content = `---
type: reading-project
book: "${plan.bookTitle}"
author: "${plan.author}"
started: ${new Date().toISOString().slice(0, 10)}
status: reading
generation_status: generating
chapters_generated: 0
chapters_total: ${plan.chapters.length}
---
# ${plan.bookTitle}
> ${plan.oneLiner}
**作者:** ${plan.author}
## 读完应能回答
${plan.coreQuestions.map((q) => `- ${q}`).join("\n")}
## 前置知识
${plan.prerequisites.length > 0 ? plan.prerequisites.map((p) => `- ${p}`).join("\n") : "- 无特殊前置要求"}
## 阅读进度
${progressLines.join("\n")}
## 章节关系
${mermaidLines}
## 核心概念
${conceptLinks}
`;
const existing = this.app.vault.getAbstractFileByPath(indexPath);
if (existing instanceof TFile) {
await this.app.vault.modify(existing, content);
return existing;
}
return await this.app.vault.create(indexPath, content);
}
private async createChapterNote(folder: string, bookTitle: string, chapter: ChapterSkeleton): Promise<void> {
// Zero-padded chapter number prefix ensures correct sort order in file tree
const numPrefix = String(chapter.number).padStart(2, "0");
const fileName = `${numPrefix}-${this.sanitize(chapter.title)}`;
const filePath = normalizePath(`${folder}/${fileName}.md`);
if (this.app.vault.getAbstractFileByPath(filePath)) return;
const conceptLinks = chapter.keyConcepts.map((c) => `[[${c}]]`).join(" · ");
const content = `---
type: reading-note
book: "${bookTitle}"
chapter: ${chapter.number}
title: "${chapter.title}"
status: unread
importance: ${chapter.importance}
questions_generated: false
---
# 第${chapter.number}章:${chapter.title}
> ${chapter.summary}
## 读前问题
(生成中...
## 要点
-
## 摘抄
>
## 疑问
-
## 我的理解
## 关联概念
${conceptLinks}
## 章节总结
`;
await this.app.vault.create(filePath, content);
}
private async writeChapterQuestions(folder: string, chapter: ChapterSkeleton, detail: ChapterDetail): Promise<void> {
const numPrefix = String(chapter.number).padStart(2, "0");
const fileName = `${numPrefix}-${this.sanitize(chapter.title)}`;
const filePath = normalizePath(`${folder}/${fileName}.md`);
const file = this.app.vault.getAbstractFileByPath(filePath);
if (!file || !(file instanceof TFile)) return;
let content = await this.app.vault.read(file);
const questionsLines = detail.questions.map((q) => `- [ ] ${q}`).join("\n");
// 替换占位文本
content = content.replace(
/## 读前问题\n\n生成中\.\.\./,
`## 读前问题\n\n${questionsLines}`
);
// 更新 frontmatter
content = content.replace("questions_generated: false", "questions_generated: true");
await this.app.vault.modify(file, content);
}
private async updateGenerationStatus(indexFile: TFile, generated: number, total: number): Promise<void> {
let content = await this.app.vault.read(indexFile);
const status = generated >= total ? "complete" : "partial";
content = content.replace(/generation_status: \w+/, `generation_status: ${status}`);
content = content.replace(/chapters_generated: \d+/, `chapters_generated: ${generated}`);
await this.app.vault.modify(indexFile, content);
}
private async ensureConceptNotes(concepts: string[], bookTitle: string): Promise<void> {
const conceptsPath = normalizePath(this.settings.conceptsPath || "Knowledge/Concepts");
const uncategorizedPath = normalizePath(`${conceptsPath}/_未分类`);
await this.ensureFolder(uncategorizedPath);
const today = new Date().toISOString().slice(0, 10);
for (const concept of concepts) {
const existing = this.app.vault.getMarkdownFiles().find(
(f) => f.path.startsWith(conceptsPath) && f.basename === concept
);
if (existing) continue;
const filePath = normalizePath(`${uncategorizedPath}/${concept}.md`);
if (!this.app.vault.getAbstractFileByPath(filePath)) {
await this.app.vault.create(filePath,
`---\ntype: concept\nname: ${concept}\nstatus: empty\ncompletion_status: pending\ncreated_from: reading\nsource_book: "${bookTitle}"\ncreated_at: ${today}\n---\n\n# ${concept}\n\n## 定义\n\n## 核心解释\n\n## 示例\n\n## 关联概念\n\n## 相关问题\n\n## 来源\n`
);
}
}
}
private async ensureFolder(path: string): Promise<void> {
if (!this.app.vault.getAbstractFileByPath(path)) {
await this.app.vault.createFolder(path).catch(() => {});
}
}
private sanitize(name: string): string {
return name.replace(/[\\/:*?"<>|#[\]]/g, "-").replace(/\s+/g, " ").trim();
}
}