返回 oh-my-ppt
page-outline-utils.ts
根目录 / src / main / session / page-outline-utils.ts
1 import type { PPTDatabase, SessionPageRecord, SourcePageSkeletonRecord } from '../db/database'
2
3 const normalizeOutlineSource = (value: string | null | undefined): string =>
4 String(value || '')
5 .replace(/\r\n/g, '\n')
6 .replace(/\r/g, '\n')
7 .trim()
8
9 const compactOutlineText = (value: string): string | null => {
10 const text = value
11 .replace(/```[a-z]*\n?/gi, '')
12 .replace(/```/g, '')
13 .replace(/\s+/g, ' ')
14 .trim()
15 return text || null
16 }
17
18 const buildSourceSkeletonOutline = (
19 skeleton: SourcePageSkeletonRecord | undefined
20 ): string | null => {
21 if (!skeleton) return null
22 return compactOutlineText([skeleton.source_heading, skeleton.reason].filter(Boolean).join(' '))
23 }
24
25 const normalizeContentOutline = (value: string | null | undefined): string | null => {
26 const text = String(value || '')
27 .replace(/\s+/g, ' ')
28 .trim()
29 return text || null
30 }
31
32 const outlineSectionPattern =
33 /(建议大纲|推荐大纲|每页要点|页面要点|页(?:面)?大纲|幻灯片大纲|Recommended outline|Per-page points|Page outline|Slide outline|Slides? outline)/i
34
35 const sectionStopPattern =
36 /^(必须保留|风格|表达|注意事项|受众|核心观点|演示目标|Facts\/|Style|Audience|Core argument)\s*[::]/i
37
38 const cnDigitMap: Record<string, number> = {
39 零: 0,
40 〇: 0,
41 一: 1,
42 二: 2,
43 两: 2,
44 三: 3,
45 四: 4,
46 五: 5,
47 六: 6,
48 七: 7,
49 八: 8,
50 九: 9
51 }
52
53 const normalizeDigits = (value: string): string =>
54 value.replace(/[0-9]/g, (char) => String(char.charCodeAt(0) - 0xff10))
55
56 const parsePageNumberToken = (value: string | undefined): number | null => {
57 const token = normalizeDigits(String(value || '').trim())
58 if (!token) return null
59 if (/^\d+$/.test(token)) return Number.parseInt(token, 10)
60 if (!/^[零〇一二两三四五六七八九十]+$/.test(token)) return null
61 if (token === '十') return 10
62 const tenIndex = token.indexOf('十')
63 if (tenIndex >= 0) {
64 const before = token.slice(0, tenIndex)
65 const after = token.slice(tenIndex + 1)
66 const tens = before ? cnDigitMap[before] : 1
67 const ones = after ? cnDigitMap[after] : 0
68 if (typeof tens !== 'number' || typeof ones !== 'number') return null
69 return tens * 10 + ones
70 }
71 return cnDigitMap[token] ?? null
72 }
73
74 const stripLinePrefix = (line: string): string =>
75 line
76 .trim()
77 .replace(/^#{1,6}\s*/, '')
78 .replace(/^[-*+•]\s*/, '')
79 .trim()
80
81 const stripCollectedLine = (line: string): string =>
82 stripLinePrefix(line)
83 .replace(/^(?:内容要点|要点|关键要点|Key points?|Content points?)\s*[::]\s*/i, '')
84 .replace(/^(?:页面目的|目的|Objective|Page purpose)\s*[::]\s*/i, '')
85
86 const isDroppedOutlineMetadataLine = (line: string): boolean =>
87 /^(?:页面角色|角色|来源标题|来源范围|来源页码|来源|版式意图|布局意图|Role|Page role|Source heading|Source range|Source page|Source|Layout intent)\s*[::]/i.test(
88 stripLinePrefix(line)
89 )
90
91 const matchExplicitPageHeading = (line: string): { pageNumber: number; rest: string } | null => {
92 const text = stripLinePrefix(line)
93 const patterns: Array<{ pattern: RegExp; restIndex: number }> = [
94 {
95 pattern:
96 /^(?:第\s*([0-90-9零〇一二两三四五六七八九十]+)\s*[页頁]|(?:P|Page|Slide)\s*([0-90-9]+)|(?:页面|页|幻灯片)\s*([0-90-9]+))\s*(?:[::.、)\]\-–—])?\s*(.*)$/i,
97 restIndex: 4
98 },
99 {
100 pattern:
101 /^([0-90-9]+)\s*[.、.)]\s*(?:第\s*)?(?:页|页面|幻灯片|Slide)\s*(?:[::.、)\]\-–—])?\s*(.*)$/i,
102 restIndex: 2
103 },
104 {
105 pattern:
106 /^([0-90-9零〇一二两三四五六七八九十]{1,4})\s*(?:页|頁|页面|幻灯片)\s*(?:[::.、)\]\-–—])?\s*(.*)$/i,
107 restIndex: 2
108 }
109 ]
110
111 for (const { pattern, restIndex } of patterns) {
112 const match = text.match(pattern)
113 if (!match) continue
114 const pageNumber = parsePageNumberToken(match[1] || match[2] || match[3])
115 if (!pageNumber) continue
116 return { pageNumber, rest: (match[restIndex] || '').trim() }
117 }
118
119 return null
120 }
121
122 const matchNumberedOutlineItem = (line: string): { pageNumber: number; rest: string } | null => {
123 const match = stripLinePrefix(line).match(
124 /^([0-90-9]{1,3}|[零〇一二两三四五六七八九十]{1,4})\s*[.、.)]\s*(.*)$/
125 )
126 if (!match) return null
127 const pageNumber = parsePageNumberToken(match[1])
128 if (!pageNumber) return null
129 return { pageNumber, rest: match[2].trim() }
130 }
131
132 const collectNumberedEntry = (
133 lines: string[],
134 pageNumber: number,
135 options: { requireOutlineSection: boolean }
136 ): string | null => {
137 let inOutlineSection = !options.requireOutlineSection
138 let collecting = false
139 let sawNumberedItem = false
140 const collected: string[] = []
141
142 for (const line of lines) {
143 const trimmed = line.trim()
144 if (!trimmed) continue
145 if (outlineSectionPattern.test(trimmed)) {
146 inOutlineSection = true
147 continue
148 }
149 if (!inOutlineSection) continue
150 if (sectionStopPattern.test(stripLinePrefix(trimmed))) break
151
152 const numbered = matchNumberedOutlineItem(trimmed)
153 if (numbered) {
154 sawNumberedItem = true
155 if (numbered.pageNumber === pageNumber) {
156 collecting = true
157 if (numbered.rest) collected.push(numbered.rest)
158 continue
159 }
160 if (collecting) break
161 continue
162 }
163
164 if (collecting) {
165 if (isDroppedOutlineMetadataLine(trimmed)) continue
166 const line = stripCollectedLine(trimmed)
167 if (line) collected.push(line)
168 }
169 }
170
171 if (!sawNumberedItem) return null
172 return compactOutlineText(collected.join(' '))
173 }
174
175 const extractExplicitPageEntry = (source: string, pageNumber: number): string | null => {
176 const lines = source.split('\n')
177 let collecting = false
178 const collected: string[] = []
179
180 for (const line of lines) {
181 const trimmed = line.trim()
182 if (!trimmed) continue
183 if (collecting && sectionStopPattern.test(stripLinePrefix(trimmed))) break
184 if (collecting && outlineSectionPattern.test(trimmed)) break
185 const heading = matchExplicitPageHeading(trimmed)
186 if (heading) {
187 if (collecting) break
188 if (heading.pageNumber === pageNumber) {
189 collecting = true
190 if (heading.rest) collected.push(heading.rest)
191 }
192 continue
193 }
194 if (collecting) {
195 if (isDroppedOutlineMetadataLine(trimmed)) continue
196 const line = stripCollectedLine(trimmed)
197 if (line) collected.push(line)
198 }
199 }
200
201 return compactOutlineText(collected.join(' '))
202 }
203
204 export const resolvePageContentOutline = (
205 outline: string | null | undefined,
206 pageNumber: number,
207 options?: { allowUnheadedNumberedOutline?: boolean }
208 ): string | null => {
209 const source = normalizeOutlineSource(outline)
210 if (!source) return null
211
212 const explicitEntry = extractExplicitPageEntry(source, pageNumber)
213 if (explicitEntry) return explicitEntry
214
215 const lines = source.split('\n')
216 const sectionEntry = collectNumberedEntry(lines, pageNumber, { requireOutlineSection: true })
217 if (sectionEntry) return sectionEntry
218
219 if (options?.allowUnheadedNumberedOutline) {
220 const unheadedEntry = collectNumberedEntry(lines, pageNumber, { requireOutlineSection: false })
221 if (unheadedEntry) return unheadedEntry
222 }
223
224 return compactOutlineText(source)
225 }
226
227 export async function resolveOutlinesForPages(
228 db: PPTDatabase,
229 sessionId: string,
230 pages: Array<Pick<SessionPageRecord, 'id' | 'file_slug' | 'legacy_page_id' | 'page_number'>>
231 ): Promise<Map<string, string | null>> {
232 const skeletons = await db.listSourcePageSkeletons(sessionId)
233 const skeletonByPageNumber = new Map<number, SourcePageSkeletonRecord>()
234
235 for (const skeleton of skeletons) {
236 skeletonByPageNumber.set(skeleton.page_number, skeleton)
237 }
238
239 return new Map(
240 pages.map((page) => [
241 page.id,
242 buildSourceSkeletonOutline(skeletonByPageNumber.get(page.page_number))
243 ])
244 )
245 }
246
247 const readLegacyMetadataValue = (source: string, labels: string[]): string => {
248 const labelPattern = labels.map((label) => label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')
249 const pattern = new RegExp(`^(?:${labelPattern})\\s*[::]\\s*(.+)$`, 'i')
250 for (const line of source.split('\n')) {
251 const match = stripLinePrefix(line).match(pattern)
252 if (match?.[1]?.trim()) return match[1].trim()
253 }
254 return ''
255 }
256
257 const parseLegacySourcePlanOutline = (
258 source: string | null | undefined
259 ): { sourceHeading: string; reason: string | null; role: 'chapter-divider' | 'content' } | null => {
260 const normalized = normalizeOutlineSource(source)
261 if (!normalized) return null
262 const sourceHeading = readLegacyMetadataValue(normalized, ['Source heading', '来源标题'])
263 if (!sourceHeading) return null
264 const reason = readLegacyMetadataValue(normalized, ['Structure basis', '结构依据']) || null
265 const roleText = readLegacyMetadataValue(normalized, ['Page role', '页面角色', '角色'])
266 const role = /chapter-divider|chapter divider|章节|分隔|封面/i.test(roleText)
267 ? 'chapter-divider'
268 : 'content'
269 return { sourceHeading, reason, role }
270 }
271
272 const getLegacySourceDocumentPath = (
273 sessionId: string,
274 session: Awaited<ReturnType<PPTDatabase['getSession']>> | null | undefined
275 ): string => {
276 const referenceDocumentPath =
277 typeof session?.referenceDocumentPath === 'string'
278 ? session.referenceDocumentPath.trim()
279 : typeof session?.reference_document_path === 'string'
280 ? session.reference_document_path.trim()
281 : ''
282 return referenceDocumentPath || `legacy-outline:${sessionId}`
283 }
284
285 export async function migrateLegacyPageOutlinesToSourceSkeletons(
286 db: PPTDatabase,
287 sessionId: string
288 ): Promise<{ migrated: boolean; migratedCount: number; existingCount: number }> {
289 const existingSkeletons = await db.listSourcePageSkeletons(sessionId)
290 if (existingSkeletons.length > 0) {
291 return { migrated: false, migratedCount: 0, existingCount: existingSkeletons.length }
292 }
293
294 const [session, pages, snapshots] = await Promise.all([
295 db.getSession(sessionId),
296 db.listSessionPages(sessionId),
297 db.listLatestGenerationPageSnapshot(sessionId)
298 ])
299 const snapshotByPageId = new Map(snapshots.map((snapshot) => [snapshot.page_id, snapshot]))
300
301 const outlineUseCount = new Map<string, number>()
302 for (const page of pages) {
303 const snapshot =
304 snapshotByPageId.get(page.file_slug) ||
305 (page.legacy_page_id ? snapshotByPageId.get(page.legacy_page_id) : undefined)
306 const outline = normalizeContentOutline(snapshot?.content_outline)
307 if (!outline) continue
308 outlineUseCount.set(outline, (outlineUseCount.get(outline) || 0) + 1)
309 }
310
311 const items = pages
312 .map((page) => {
313 const snapshot =
314 snapshotByPageId.get(page.file_slug) ||
315 (page.legacy_page_id ? snapshotByPageId.get(page.legacy_page_id) : undefined)
316 const metadataOutline = parseLegacySourcePlanOutline(snapshot?.content_outline)
317 const outline = normalizeContentOutline(snapshot?.content_outline)
318 const resolvedOutline =
319 metadataOutline?.sourceHeading ||
320 resolvePageContentOutline(
321 snapshot?.content_outline,
322 snapshot?.page_number || page.page_number,
323 {
324 allowUnheadedNumberedOutline: outline ? (outlineUseCount.get(outline) || 0) > 1 : false
325 }
326 ) ||
327 normalizeContentOutline(snapshot?.title) ||
328 normalizeContentOutline(page.title)
329 if (!resolvedOutline) return null
330 return {
331 pageNumber: page.page_number,
332 title: snapshot?.title || page.title,
333 role: metadataOutline?.role || ('content' as const),
334 sourceHeading: resolvedOutline,
335 headingLevel: 1,
336 lineStart: page.page_number,
337 lineEnd: page.page_number,
338 reason: metadataOutline?.reason || null
339 }
340 })
341 .filter((item): item is NonNullable<typeof item> => Boolean(item))
342
343 if (items.length === 0) {
344 return { migrated: false, migratedCount: 0, existingCount: 0 }
345 }
346
347 await db.replaceSourcePageSkeletons({
348 sessionId,
349 sourceDocumentPath: getLegacySourceDocumentPath(sessionId, session),
350 sourceDocumentName: session?.title || 'Legacy outline',
351 confidence: 'medium',
352 items
353 })
354
355 return { migrated: true, migratedCount: items.length, existingCount: 0 }
356 }
357
357 lines TYPESCRIPT