| 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 |