import { app, nativeImage } from 'electron' import { promises as fs } from 'fs' import path from 'path' import type { GeneratedReportRecord, DeleteGeneratedReportResult, ReportAssetStatus, ReportHistoryResult, SaveGeneratedReportRequest, SaveGeneratedReportResult } from '../shared/report-history' const REPORTS_DIR = 'reports' const getReportsRoot = (): string => path.join(app.getPath('userData'), REPORTS_DIR) const pad2 = (value: number): string => String(value).padStart(2, '0') const safeSegment = (value: string): string => value .trim() // Strip Windows-reserved filename characters and ASCII control chars. // eslint-disable-next-line no-control-regex .replace(/[<>:"/\\|?*\x00-\x1f]/g, '_') .replace(/\s+/g, '_') .slice(0, 48) || 'report' const parseDataUrl = (dataUrl: string): Buffer | null => { const match = /^data:image\/png;base64,(.+)$/i.exec(dataUrl) if (!match) return null return Buffer.from(match[1], 'base64') } const exists = async (filePath?: string): Promise => { if (!filePath) return false try { const stat = await fs.stat(filePath) return stat.isFile() } catch { return false } } const fileStatus = async (filePath?: string): Promise => (await exists(filePath)) ? 'ready' : 'missing' const readPngAsDataUrl = async (filePath?: string): Promise => { if (!filePath || !(await exists(filePath))) return undefined const content = await fs.readFile(filePath) return `data:image/png;base64,${content.toString('base64')}` } const readPngSize = async ( filePath?: string ): Promise<{ width: number; height: number } | undefined> => { if (!filePath || !(await exists(filePath))) return undefined const image = nativeImage.createFromPath(filePath) if (image.isEmpty()) return undefined const size = image.getSize() return size.width && size.height ? size : undefined } const readFileSize = async (filePath?: string): Promise => { if (!filePath) return undefined try { const stat = await fs.stat(filePath) return stat.isFile() ? stat.size : undefined } catch { return undefined } } const normalizeRecord = async ( record: GeneratedReportRecord, jsonPath: string ): Promise => { const htmlStatus = await fileStatus(record.htmlPath) const pngStatus = await fileStatus(record.pngPath) return { ...record, jsonPath, htmlStatus, pngStatus, generatedImage: await readPngAsDataUrl(record.pngPath), imageSize: await readPngSize(record.pngPath), fileSize: { html: await readFileSize(record.htmlPath), png: await readFileSize(record.pngPath) } } } const walkJsonFiles = async (directory: string): Promise => { try { const entries = await fs.readdir(directory, { withFileTypes: true }) const children = await Promise.all( entries.map(async (entry) => { const entryPath = path.join(directory, entry.name) if (entry.isDirectory()) return walkJsonFiles(entryPath) return entry.isFile() && entry.name.endsWith('.json') ? [entryPath] : [] }) ) return children.flat() } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [] throw error } } export async function listGeneratedReports(): Promise { try { const jsonFiles = await walkJsonFiles(getReportsRoot()) const records = await Promise.all( jsonFiles.map(async (jsonPath) => { try { const content = await fs.readFile(jsonPath, 'utf8') return normalizeRecord(JSON.parse(content) as GeneratedReportRecord, jsonPath) } catch (error) { console.warn(`[ReportHistory] skip invalid report record: ${jsonPath}`, error) return null } }) ) const reports = records.filter((record): record is GeneratedReportRecord => Boolean(record)) reports.sort((left, right) => Date.parse(right.generatedAt) - Date.parse(left.generatedAt)) return { success: true, reports } } catch (error) { return { success: false, error: error instanceof Error ? error.message : String(error) } } } export async function saveGeneratedReport( request: SaveGeneratedReportRequest ): Promise { try { const generatedAtDate = new Date(request.generatedAt) const timestamp = Number.isFinite(generatedAtDate.getTime()) ? generatedAtDate : new Date() const year = String(timestamp.getFullYear()) const month = pad2(timestamp.getMonth() + 1) const directory = path.join(getReportsRoot(), year, month) await fs.mkdir(directory, { recursive: true }) const id = `report_${timestamp.getTime()}_${Math.random().toString(36).slice(2, 8)}` const baseName = `${id}_${safeSegment(request.contactName)}` const htmlPath = path.join(directory, `${baseName}.html`) const pngPath = path.join(directory, `${baseName}.png`) const jsonPath = path.join(directory, `${baseName}.json`) let savedHtmlPath: string | undefined if (request.htmlPath && (await exists(request.htmlPath))) { await fs.copyFile(request.htmlPath, htmlPath) savedHtmlPath = htmlPath } let savedPngPath: string | undefined const imageBuffer = request.generatedImage ? parseDataUrl(request.generatedImage) : null if (imageBuffer) { await fs.writeFile(pngPath, imageBuffer) savedPngPath = pngPath } else if (request.pngPath && (await exists(request.pngPath))) { await fs.copyFile(request.pngPath, pngPath) savedPngPath = pngPath } const record: GeneratedReportRecord = { id, contactId: request.contactId, contactName: request.contactName, contactAvatar: request.contactAvatar, dateRange: request.dateRange, messageCount: request.messageCount, generatedAt: timestamp.toISOString(), reportDate: `${year}-${month}-${pad2(timestamp.getDate())}`, htmlPath: savedHtmlPath, pngPath: savedPngPath, jsonPath, htmlStatus: savedHtmlPath ? 'ready' : 'missing', pngStatus: savedPngPath ? 'ready' : 'missing', imageSize: await readPngSize(savedPngPath), duration: request.duration, modelName: request.modelName, tokenUsage: request.tokenUsage, fileSize: { html: await readFileSize(savedHtmlPath), png: await readFileSize(savedPngPath) }, generationLogs: request.generationLogs } await fs.writeFile(jsonPath, JSON.stringify(record, null, 2), 'utf8') return { success: true, record: { ...record, generatedImage: savedPngPath ? await readPngAsDataUrl(savedPngPath) : undefined } } } catch (error) { return { success: false, error: error instanceof Error ? error.message : String(error) } } } export async function deleteGeneratedReport( reportId: string ): Promise { try { const jsonFiles = await walkJsonFiles(getReportsRoot()) for (const jsonPath of jsonFiles) { try { const content = await fs.readFile(jsonPath, 'utf8') const record = JSON.parse(content) as GeneratedReportRecord if (record.id !== reportId) continue const paths = [record.htmlPath, record.pngPath, jsonPath].filter( (filePath): filePath is string => Boolean(filePath) ) await Promise.all( paths.map(async (filePath) => { try { await fs.unlink(filePath) } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error } }) ) return { success: true, deletedId: reportId } } catch (error) { console.warn( `[ReportHistory] skip invalid report record while deleting: ${jsonPath}`, error ) } } return { success: false, error: '未找到要删除的日报记录' } } catch (error) { return { success: false, error: error instanceof Error ? error.message : String(error) } } }