update
This commit is contained in:
@@ -169,11 +169,17 @@ export function createGenerateRouter(apiKey: string | undefined): Hono {
|
|||||||
|
|
||||||
const entries = rawEntries
|
const entries = rawEntries
|
||||||
.map((entry, index) => {
|
.map((entry, index) => {
|
||||||
const e = entry as { index?: unknown; title?: unknown; charCount?: unknown }
|
const e = entry as {
|
||||||
|
index?: unknown
|
||||||
|
title?: unknown
|
||||||
|
charCount?: unknown
|
||||||
|
depth?: unknown
|
||||||
|
}
|
||||||
const title = typeof e?.title === 'string' ? e.title.trim() : ''
|
const title = typeof e?.title === 'string' ? e.title.trim() : ''
|
||||||
const entryIndex = typeof e?.index === 'number' ? e.index : index
|
const entryIndex = typeof e?.index === 'number' ? e.index : index
|
||||||
const charCount = typeof e?.charCount === 'number' ? e.charCount : 0
|
const charCount = typeof e?.charCount === 'number' ? e.charCount : 0
|
||||||
return { index: entryIndex, title, charCount }
|
const depth = typeof e?.depth === 'number' && e.depth > 0 ? e.depth : 0
|
||||||
|
return { index: entryIndex, title, charCount, depth }
|
||||||
})
|
})
|
||||||
.filter((entry) => entry.title !== '')
|
.filter((entry) => entry.title !== '')
|
||||||
|
|
||||||
@@ -186,7 +192,10 @@ export function createGenerateRouter(apiKey: string | undefined): Hono {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tocText = entries
|
const tocText = entries
|
||||||
.map((entry) => `${entry.index}\t${entry.title}\t约${entry.charCount}字`)
|
.map(
|
||||||
|
(entry) =>
|
||||||
|
`${entry.index}\t${' '.repeat(entry.depth)}${entry.title}\t约${entry.charCount}字`,
|
||||||
|
)
|
||||||
.join('\n')
|
.join('\n')
|
||||||
|
|
||||||
let response: Response
|
let response: Response
|
||||||
@@ -203,7 +212,7 @@ export function createGenerateRouter(apiKey: string | undefined): Hono {
|
|||||||
{
|
{
|
||||||
role: 'system',
|
role: 'system',
|
||||||
content:
|
content:
|
||||||
'你是教学设计专家。下面是一本教材的目录,每行为「序号<TAB>标题<TAB>正文体量」。' +
|
'你是教学设计专家。下面是一本教材的目录,每行为「序号<TAB>标题<TAB>正文体量」,标题前的缩进表示层级(章 → 节 → 小节),同一上级标题下的条目应优先归到相邻课时。' +
|
||||||
'请据此把全书划分为一系列单课时课题:难度由浅入深、覆盖全部目录条目,体量小的相邻条目可合并为一课时,体量大的条目可拆成多课时。' +
|
'请据此把全书划分为一系列单课时课题:难度由浅入深、覆盖全部目录条目,体量小的相邻条目可合并为一课时,体量大的条目可拆成多课时。' +
|
||||||
'每个课题对应一个或多个目录条目(用其序号表示)。' +
|
'每个课题对应一个或多个目录条目(用其序号表示)。' +
|
||||||
'仅输出 JSON,不要解释、不要代码块围栏,形如:' +
|
'仅输出 JSON,不要解释、不要代码块围栏,形如:' +
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { flushPromises, mount } from '@vue/test-utils'
|
import { flushPromises, mount } from '@vue/test-utils'
|
||||||
import JSZip from 'jszip'
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import * as booksApi from '../services/booksApi'
|
import * as booksApi from '../services/booksApi'
|
||||||
import BookImportDialog from './BookImportDialog.vue'
|
import BookImportDialog from './BookImportDialog.vue'
|
||||||
@@ -14,18 +13,15 @@ const baseProps = {
|
|||||||
error: null,
|
error: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
async function makeZipFile(files: Record<string, string>): Promise<File> {
|
function makeMdFiles(files: Record<string, string>): File[] {
|
||||||
const zip = new JSZip()
|
return Object.entries(files).map(
|
||||||
for (const [name, content] of Object.entries(files)) {
|
([name, content]) => new File([content], name, { type: 'text/markdown' }),
|
||||||
zip.file(name, content)
|
)
|
||||||
}
|
|
||||||
const blob = await zip.generateAsync({ type: 'blob' })
|
|
||||||
return new File([blob], 'book.zip', { type: 'application/zip' })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function uploadFile(wrapper: ReturnType<typeof mount>, file: File): Promise<void> {
|
async function uploadFiles(wrapper: ReturnType<typeof mount>, files: File[]): Promise<void> {
|
||||||
const input = wrapper.get('input[type="file"]')
|
const input = wrapper.get('input[type="file"]')
|
||||||
Object.defineProperty(input.element, 'files', { value: [file], configurable: true })
|
Object.defineProperty(input.element, 'files', { value: files, configurable: true })
|
||||||
await input.trigger('change')
|
await input.trigger('change')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,7 +34,7 @@ describe('BookImportDialog', () => {
|
|||||||
vi.restoreAllMocks()
|
vi.restoreAllMocks()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('parses toc + zip, divides lessons, and emits start with assembled content', async () => {
|
it('parses toc + md files, divides lessons, and emits start with assembled content', async () => {
|
||||||
vi.mocked(booksApi.divideLessonsFromBook).mockResolvedValue({
|
vi.mocked(booksApi.divideLessonsFromBook).mockResolvedValue({
|
||||||
lessons: [
|
lessons: [
|
||||||
{ title: 'C# 入门——搭建环境', sourceIndexes: [0] },
|
{ title: 'C# 入门——搭建环境', sourceIndexes: [0] },
|
||||||
@@ -49,19 +45,19 @@ describe('BookImportDialog', () => {
|
|||||||
const wrapper = mount(BookImportDialog, { props: baseProps })
|
const wrapper = mount(BookImportDialog, { props: baseProps })
|
||||||
|
|
||||||
await wrapper.get('textarea').setValue('第1章 入门\n第2章 变量')
|
await wrapper.get('textarea').setValue('第1章 入门\n第2章 变量')
|
||||||
const zip = await makeZipFile({ '第1章 入门.md': '入门正文', '第2章 变量.md': '变量正文' })
|
const files = makeMdFiles({ '第1章 入门.md': '入门正文', '第2章 变量.md': '变量正文' })
|
||||||
await uploadFile(wrapper, zip)
|
await uploadFiles(wrapper, files)
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
// 解析并划分(parseZip 经由真实定时器解析,故用 waitFor 等待预览出现)
|
// 解析并划分(File.text() 为异步,故用 waitFor 等待预览出现)
|
||||||
await wrapper.get('.dialog-actions button').trigger('click')
|
await wrapper.get('.dialog-actions button').trigger('click')
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
expect(wrapper.findAll('.book-import-lesson-title')).toHaveLength(2)
|
expect(wrapper.findAll('.book-import-lesson-title')).toHaveLength(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(booksApi.divideLessonsFromBook).toHaveBeenCalledWith([
|
expect(booksApi.divideLessonsFromBook).toHaveBeenCalledWith([
|
||||||
{ index: 0, title: '第1章 入门', charCount: 4 },
|
{ index: 0, title: '第1章 入门', charCount: 4, depth: 0 },
|
||||||
{ index: 1, title: '第2章 变量', charCount: 4 },
|
{ index: 1, title: '第2章 变量', charCount: 4, depth: 0 },
|
||||||
])
|
])
|
||||||
|
|
||||||
// 预览阶段:编辑第一个课时标题
|
// 预览阶段:编辑第一个课时标题
|
||||||
@@ -79,12 +75,12 @@ describe('BookImportDialog', () => {
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('rejects a non-zip upload', async () => {
|
it('rejects an upload with no md files', async () => {
|
||||||
const wrapper = mount(BookImportDialog, { props: baseProps })
|
const wrapper = mount(BookImportDialog, { props: baseProps })
|
||||||
const txt = new File(['x'], 'notes.txt', { type: 'text/plain' })
|
const txt = new File(['x'], 'notes.txt', { type: 'text/plain' })
|
||||||
await uploadFile(wrapper, txt)
|
await uploadFiles(wrapper, [txt])
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(wrapper.get('.app-notice--error').text()).toContain('.zip')
|
expect(wrapper.get('.app-notice--error').text()).toContain('.md')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import * as booksApi from '../services/booksApi'
|
|||||||
import {
|
import {
|
||||||
assembleContent,
|
assembleContent,
|
||||||
matchToc,
|
matchToc,
|
||||||
parseZip,
|
parseMdFiles,
|
||||||
type BookEntry,
|
type BookEntry,
|
||||||
type ProposedLesson,
|
type ProposedLesson,
|
||||||
} from '../services/bookImport'
|
} from '../services/bookImport'
|
||||||
@@ -29,7 +29,7 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const phase = ref<Phase>('input')
|
const phase = ref<Phase>('input')
|
||||||
const tocText = ref('')
|
const tocText = ref('')
|
||||||
const zipFile = ref<File | null>(null)
|
const mdFiles = ref<File[]>([])
|
||||||
const inputError = ref<string | null>(null)
|
const inputError = ref<string | null>(null)
|
||||||
|
|
||||||
const entries = ref<BookEntry[]>([])
|
const entries = ref<BookEntry[]>([])
|
||||||
@@ -47,14 +47,16 @@ watch(
|
|||||||
)
|
)
|
||||||
|
|
||||||
function onFiles(files: File[]): void {
|
function onFiles(files: File[]): void {
|
||||||
const file = files[0]
|
const mds = files.filter((file) => /\.md$/i.test(file.name))
|
||||||
if (!file) return
|
if (mds.length === 0) {
|
||||||
if (!/\.zip$/i.test(file.name)) {
|
inputError.value = '请上传 .md 文件。'
|
||||||
inputError.value = '请上传 .zip 压缩包。'
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
inputError.value = null
|
inputError.value = null
|
||||||
zipFile.value = file
|
// 去重(同名覆盖)并追加到已选列表。
|
||||||
|
const byName = new Map(mdFiles.value.map((f) => [f.name, f]))
|
||||||
|
for (const file of mds) byName.set(file.name, file)
|
||||||
|
mdFiles.value = [...byName.values()]
|
||||||
}
|
}
|
||||||
|
|
||||||
function chapterTitlesOf(lesson: ProposedLesson): string {
|
function chapterTitlesOf(lesson: ProposedLesson): string {
|
||||||
@@ -65,15 +67,15 @@ function chapterTitlesOf(lesson: ProposedLesson): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleAnalyze(): Promise<void> {
|
async function handleAnalyze(): Promise<void> {
|
||||||
if (!tocText.value.trim() || !zipFile.value) return
|
if (!tocText.value.trim() || mdFiles.value.length === 0) return
|
||||||
inputError.value = null
|
inputError.value = null
|
||||||
phase.value = 'division-loading'
|
phase.value = 'division-loading'
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const map = await parseZip(zipFile.value)
|
const map = await parseMdFiles(mdFiles.value)
|
||||||
entries.value = matchToc(tocText.value, map)
|
entries.value = matchToc(tocText.value, map)
|
||||||
} catch {
|
} catch {
|
||||||
inputError.value = '压缩包解析失败,请确认是有效的 .zip 文件。'
|
inputError.value = 'Markdown 文件读取失败,请重试。'
|
||||||
phase.value = 'input'
|
phase.value = 'input'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -86,7 +88,12 @@ async function handleAnalyze(): Promise<void> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await booksApi.divideLessonsFromBook(
|
const result = await booksApi.divideLessonsFromBook(
|
||||||
entries.value.map((e) => ({ index: e.index, title: e.title, charCount: e.charCount })),
|
entries.value.map((e) => ({
|
||||||
|
index: e.index,
|
||||||
|
title: e.title,
|
||||||
|
charCount: e.charCount,
|
||||||
|
depth: e.depth,
|
||||||
|
})),
|
||||||
)
|
)
|
||||||
lessons.value = result.lessons
|
lessons.value = result.lessons
|
||||||
phase.value = 'preview'
|
phase.value = 'preview'
|
||||||
@@ -115,7 +122,7 @@ function handleStart(): void {
|
|||||||
function handleClose(): void {
|
function handleClose(): void {
|
||||||
phase.value = 'input'
|
phase.value = 'input'
|
||||||
tocText.value = ''
|
tocText.value = ''
|
||||||
zipFile.value = null
|
mdFiles.value = []
|
||||||
inputError.value = null
|
inputError.value = null
|
||||||
entries.value = []
|
entries.value = []
|
||||||
lessons.value = []
|
lessons.value = []
|
||||||
@@ -130,7 +137,7 @@ function handleClose(): void {
|
|||||||
|
|
||||||
<!-- 第一步:粘贴目录 + 上传 ZIP -->
|
<!-- 第一步:粘贴目录 + 上传 ZIP -->
|
||||||
<template v-if="phase === 'input'">
|
<template v-if="phase === 'input'">
|
||||||
<p>粘贴教材目录(每行一个标题),并上传内含各章 .md 的 ZIP(文件名为目录标题)。AI 将据此划分课时。</p>
|
<p>粘贴教材目录(每行一个标题,可用缩进表示章/节层级),并上传各章 .md 文件(文件名为目录标题,可多选)。AI 将据此划分课时。</p>
|
||||||
<p v-if="inputError" class="app-notice app-notice--error" role="alert">{{ inputError }}</p>
|
<p v-if="inputError" class="app-notice app-notice--error" role="alert">{{ inputError }}</p>
|
||||||
<textarea
|
<textarea
|
||||||
v-model="tocText"
|
v-model="tocText"
|
||||||
@@ -138,10 +145,14 @@ function handleClose(): void {
|
|||||||
rows="12"
|
rows="12"
|
||||||
placeholder="第1章 C# 入门 第2章 变量与数据类型 第3章 流程控制"
|
placeholder="第1章 C# 入门 第2章 变量与数据类型 第3章 流程控制"
|
||||||
/>
|
/>
|
||||||
<UploadDropzone accept=".zip,application/zip" :multiple="false" @files="onFiles" />
|
<UploadDropzone accept=".md,text/markdown" :multiple="true" @files="onFiles" />
|
||||||
<p v-if="zipFile" class="book-import-file">已选:{{ zipFile.name }}</p>
|
<p v-if="mdFiles.length" class="book-import-file">已选 {{ mdFiles.length }} 个文件</p>
|
||||||
<div class="dialog-actions">
|
<div class="dialog-actions">
|
||||||
<button type="button" :disabled="!tocText.trim() || !zipFile" @click="handleAnalyze">
|
<button
|
||||||
|
type="button"
|
||||||
|
:disabled="!tocText.trim() || mdFiles.length === 0"
|
||||||
|
@click="handleAnalyze"
|
||||||
|
>
|
||||||
解析并划分课时
|
解析并划分课时
|
||||||
</button>
|
</button>
|
||||||
<button type="button" @click="handleClose">取消</button>
|
<button type="button" @click="handleClose">取消</button>
|
||||||
|
|||||||
@@ -1,19 +1,14 @@
|
|||||||
import JSZip from 'jszip'
|
|
||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import {
|
import {
|
||||||
assembleContent,
|
assembleContent,
|
||||||
matchToc,
|
matchToc,
|
||||||
normalizeTitle,
|
normalizeTitle,
|
||||||
parseZip,
|
parseMdFiles,
|
||||||
type BookEntry,
|
type BookEntry,
|
||||||
} from './bookImport'
|
} from './bookImport'
|
||||||
|
|
||||||
async function makeZip(files: Record<string, string>): Promise<Blob> {
|
function makeMdFiles(files: Record<string, string>): File[] {
|
||||||
const zip = new JSZip()
|
return Object.entries(files).map(([name, content]) => new File([content], name))
|
||||||
for (const [name, content] of Object.entries(files)) {
|
|
||||||
zip.file(name, content)
|
|
||||||
}
|
|
||||||
return zip.generateAsync({ type: 'blob' })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('normalizeTitle', () => {
|
describe('normalizeTitle', () => {
|
||||||
@@ -23,15 +18,15 @@ describe('normalizeTitle', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('parseZip + matchToc', () => {
|
describe('parseMdFiles + matchToc', () => {
|
||||||
it('matches toc lines to md files by normalized filename', async () => {
|
it('matches toc lines to md files by normalized filename', async () => {
|
||||||
const blob = await makeZip({
|
const map = await parseMdFiles(
|
||||||
|
makeMdFiles({
|
||||||
'第1章 入门.md': '入门正文',
|
'第1章 入门.md': '入门正文',
|
||||||
'第2章 变量.md': '变量正文',
|
'第2章 变量.md': '变量正文',
|
||||||
'readme.txt': '忽略非 md',
|
'readme.txt': '忽略非 md',
|
||||||
})
|
}),
|
||||||
|
)
|
||||||
const map = await parseZip(blob)
|
|
||||||
const entries = matchToc('第1章 入门\n第2章 变量\n第3章 缺失', map)
|
const entries = matchToc('第1章 入门\n第2章 变量\n第3章 缺失', map)
|
||||||
|
|
||||||
expect(entries).toHaveLength(3)
|
expect(entries).toHaveLength(3)
|
||||||
@@ -40,9 +35,27 @@ describe('parseZip + matchToc', () => {
|
|||||||
expect(entries[2]).toMatchObject({ index: 2, content: '', matched: false, charCount: 0 })
|
expect(entries[2]).toMatchObject({ index: 2, content: '', matched: false, charCount: 0 })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('ignores directory prefixes inside the zip', async () => {
|
it('infers depth from leading indentation, ranking distinct widths', async () => {
|
||||||
const blob = await makeZip({ 'book/第1章 入门.md': '正文' })
|
const map = await parseMdFiles([])
|
||||||
const map = await parseZip(blob)
|
const entries = matchToc(
|
||||||
|
['第1章 入门', ' 1.1 安装', ' 1.2 第一个程序', '第2章 变量'].join('\n'),
|
||||||
|
map,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(entries.map((e) => e.depth)).toEqual([0, 1, 1, 0])
|
||||||
|
expect(entries[1]?.title).toBe('1.1 安装')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('treats tab and space indentation as the same depth unit per document', async () => {
|
||||||
|
const map = await parseMdFiles([])
|
||||||
|
const spaces = matchToc(['章', ' 节'].join('\n'), map)
|
||||||
|
const tabs = matchToc(['章', '\t节'].join('\n'), map)
|
||||||
|
expect(spaces.map((e) => e.depth)).toEqual([0, 1])
|
||||||
|
expect(tabs.map((e) => e.depth)).toEqual([0, 1])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores directory prefixes in file names', async () => {
|
||||||
|
const map = await parseMdFiles(makeMdFiles({ 'book/第1章 入门.md': '正文' }))
|
||||||
const entries = matchToc('第1章 入门', map)
|
const entries = matchToc('第1章 入门', map)
|
||||||
expect(entries[0]?.matched).toBe(true)
|
expect(entries[0]?.matched).toBe(true)
|
||||||
})
|
})
|
||||||
@@ -51,9 +64,9 @@ describe('parseZip + matchToc', () => {
|
|||||||
describe('assembleContent', () => {
|
describe('assembleContent', () => {
|
||||||
it('concatenates content of all source entries in order', () => {
|
it('concatenates content of all source entries in order', () => {
|
||||||
const entries: BookEntry[] = [
|
const entries: BookEntry[] = [
|
||||||
{ index: 0, title: 'a', content: 'AAA', charCount: 3, matched: true },
|
{ index: 0, title: 'a', depth: 0, content: 'AAA', charCount: 3, matched: true },
|
||||||
{ index: 1, title: 'b', content: 'BBB', charCount: 3, matched: true },
|
{ index: 1, title: 'b', depth: 0, content: 'BBB', charCount: 3, matched: true },
|
||||||
{ index: 2, title: 'c', content: '', charCount: 0, matched: false },
|
{ index: 2, title: 'c', depth: 0, content: '', charCount: 0, matched: false },
|
||||||
]
|
]
|
||||||
expect(assembleContent({ title: 'x', sourceIndexes: [0, 1, 2] }, entries)).toBe('AAA\n\nBBB')
|
expect(assembleContent({ title: 'x', sourceIndexes: [0, 1, 2] }, entries)).toBe('AAA\n\nBBB')
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import JSZip from 'jszip'
|
|
||||||
|
|
||||||
/** 目录中的一行,匹配到正文后携带内容与体量。 */
|
/** 目录中的一行,匹配到正文后携带内容与体量。 */
|
||||||
export interface BookEntry {
|
export interface BookEntry {
|
||||||
index: number
|
index: number
|
||||||
title: string
|
title: string
|
||||||
|
/** 由前导缩进推断的层级,0 为顶层(章),数字越大越深(节)。 */
|
||||||
|
depth: number
|
||||||
content: string
|
content: string
|
||||||
charCount: number
|
charCount: number
|
||||||
matched: boolean
|
matched: boolean
|
||||||
@@ -27,38 +27,44 @@ export function normalizeTitle(value: string): string {
|
|||||||
.trim()
|
.trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 解压 ZIP,按归一化文件名建立 { 标题 → 正文 } 映射(仅取 .md 文件)。 */
|
/** 读取多个 .md 文件,按归一化文件名建立 { 标题 → 正文 } 映射(忽略非 .md)。 */
|
||||||
export async function parseZip(file: File | Blob): Promise<Map<string, string>> {
|
export async function parseMdFiles(files: readonly File[]): Promise<Map<string, string>> {
|
||||||
const zip = await JSZip.loadAsync(file)
|
|
||||||
const map = new Map<string, string>()
|
const map = new Map<string, string>()
|
||||||
|
|
||||||
const entries = Object.values(zip.files).filter(
|
for (const file of files) {
|
||||||
(entry) => !entry.dir && /\.md$/i.test(entry.name),
|
if (!/\.md$/i.test(file.name)) continue
|
||||||
)
|
// 文件名可能带目录前缀(拖拽文件夹时),只取最后一段。
|
||||||
|
const base = file.name.split('/').pop() ?? file.name
|
||||||
for (const entry of entries) {
|
|
||||||
// 文件名可能带目录前缀,只取最后一段。
|
|
||||||
const base = entry.name.split('/').pop() ?? entry.name
|
|
||||||
const key = normalizeTitle(base)
|
const key = normalizeTitle(base)
|
||||||
if (key) {
|
if (key) {
|
||||||
map.set(key, await entry.async('text'))
|
map.set(key, await file.text())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return map
|
return map
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 把目录文本逐行与正文映射匹配,得到带内容与体量的条目列表。 */
|
/** 一行前导空白的宽度(tab 记 4 格),用作缩进度量。 */
|
||||||
|
function indentWidth(line: string): number {
|
||||||
|
const lead = line.match(/^[ \t]*/)?.[0] ?? ''
|
||||||
|
return lead.replace(/\t/g, ' ').length
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把目录文本逐行与正文映射匹配,得到带内容与体量的条目列表。
|
||||||
|
* 缩进层级按全文出现的不同缩进宽度排名得出,兼容空格 / tab、2 格 / 4 格等不同风格。
|
||||||
|
*/
|
||||||
export function matchToc(tocText: string, contentMap: Map<string, string>): BookEntry[] {
|
export function matchToc(tocText: string, contentMap: Map<string, string>): BookEntry[] {
|
||||||
return tocText
|
const lines = tocText.split('\n').filter((line) => line.trim() !== '')
|
||||||
.split('\n')
|
const widths = [...new Set(lines.map(indentWidth))].sort((a, b) => a - b)
|
||||||
.map((line) => line.trim())
|
|
||||||
.filter(Boolean)
|
return lines.map((line, index) => {
|
||||||
.map((title, index) => {
|
const title = line.trim()
|
||||||
const content = contentMap.get(normalizeTitle(title)) ?? ''
|
const content = contentMap.get(normalizeTitle(title)) ?? ''
|
||||||
return {
|
return {
|
||||||
index,
|
index,
|
||||||
title,
|
title,
|
||||||
|
depth: widths.indexOf(indentWidth(line)),
|
||||||
content,
|
content,
|
||||||
charCount: content.length,
|
charCount: content.length,
|
||||||
matched: content !== '',
|
matched: content !== '',
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ export function generateOutline(theme: string, count = 18): Promise<{ titles: st
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function divideLessonsFromBook(
|
export function divideLessonsFromBook(
|
||||||
entries: readonly { index: number; title: string; charCount: number }[],
|
entries: readonly { index: number; title: string; charCount: number; depth: number }[],
|
||||||
): Promise<{ lessons: ProposedLesson[] }> {
|
): Promise<{ lessons: ProposedLesson[] }> {
|
||||||
return authedFetch('/api/generate/lessons-from-book', {
|
return authedFetch('/api/generate/lessons-from-book', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
Reference in New Issue
Block a user