全新出发

This commit is contained in:
cc committed 2026-06-30 00:18:31 +08:00
1 parent b06c117b1e
commit fa4edebed6
463 files changed
+181610 -125

No files matched your search

+293
View File
@@ -0,0 +1,293 @@
/**
* ExportV2 — Automation utility functions
* Pure functions for automation task scheduling, date range resolution, and formatting.
*/
import type {
ExportAutomationTask,
ExportAutomationSchedule,
ExportAutomationDateRangeConfig
} from '../../../types/exportAutomation'
import type { ExportDateRangeSelection, ExportDateRangePreset } from '../../../utils/exportDateRange'
import {
resolveExportDateRangeConfig,
getExportDateRangeLabel,
createDateRangeByLastNDays
} from '../../../utils/exportDateRange'
import { formatDurationMs } from './format'
// ─── Interval normalization ──────────────────────────────────
export const normalizeAutomationIntervalDays = (value: unknown): number =>
Math.max(0, Math.floor(Number(value) || 0))
export const normalizeAutomationIntervalHours = (value: unknown): number =>
Math.max(0, Math.min(23, Math.floor(Number(value) || 0)))
export const normalizeAutomationFirstTriggerAt = (value: unknown): number => {
const numeric = Math.floor(Number(value) || 0)
if (!Number.isFinite(numeric) || numeric <= 0) return 0
return numeric
}
// ─── Interval calculation ────────────────────────────────────
export const resolveAutomationIntervalMs = (schedule: ExportAutomationSchedule): number => {
const days = normalizeAutomationIntervalDays(schedule.intervalDays)
const hours = normalizeAutomationIntervalHours(schedule.intervalHours)
const totalHours = (days * 24) + hours
if (totalHours <= 0) return 0
return totalHours * 60 * 60 * 1000
}
// ─── Trigger timing ──────────────────────────────────────────
export const resolveAutomationInitialTriggerAt = (task: ExportAutomationTask): number | null => {
const intervalMs = resolveAutomationIntervalMs(task.schedule)
if (intervalMs <= 0) return null
const firstTriggerAt = normalizeAutomationFirstTriggerAt(task.schedule.firstTriggerAt)
if (firstTriggerAt > 0) return firstTriggerAt
const createdAt = Math.max(0, Math.floor(Number(task.createdAt || 0)))
if (!createdAt) return null
return createdAt + intervalMs
}
export const resolveAutomationNextTriggerAt = (task: ExportAutomationTask): number | null => {
const intervalMs = resolveAutomationIntervalMs(task.schedule)
if (intervalMs <= 0) return null
const lastTriggeredAt = Math.max(0, Math.floor(Number(task.runState?.lastTriggeredAt || 0)))
if (lastTriggeredAt > 0) return lastTriggeredAt + intervalMs
return resolveAutomationInitialTriggerAt(task)
}
export const resolveAutomationDueScheduleKey = (task: ExportAutomationTask, now: Date): string | null => {
const intervalMs = resolveAutomationIntervalMs(task.schedule)
if (intervalMs <= 0) return null
const nowMs = now.getTime()
const lastTriggeredAt = Math.max(0, Math.floor(Number(task.runState?.lastTriggeredAt || 0)))
if (lastTriggeredAt > 0) {
if (nowMs < lastTriggeredAt + intervalMs) return null
return `interval:${lastTriggeredAt}:${Math.floor((nowMs - lastTriggeredAt) / intervalMs)}`
}
const initialTriggerAt = resolveAutomationInitialTriggerAt(task)
if (!initialTriggerAt) return null
if (nowMs < initialTriggerAt) return null
return `first:${initialTriggerAt}`
}
// ─── Schedule formatting ─────────────────────────────────────
export const formatAutomationScheduleLabel = (schedule: ExportAutomationSchedule): string => {
const days = normalizeAutomationIntervalDays(schedule.intervalDays)
const hours = normalizeAutomationIntervalHours(schedule.intervalHours)
const parts: string[] = []
if (days > 0) parts.push(`${days} 天`)
if (hours > 0) parts.push(`${hours} 小时`)
return `每间隔 ${parts.length > 0 ? parts.join(' ') : '0 小时'} 执行一次`
}
export const resolveAutomationFirstTriggerSummary = (task: ExportAutomationTask): string => {
const firstTriggerAt = normalizeAutomationFirstTriggerAt(task.schedule.firstTriggerAt)
if (firstTriggerAt <= 0) return '未指定(默认按创建时间+间隔)'
return new Date(firstTriggerAt).toLocaleString('zh-CN')
}
export const formatAutomationCurrentState = (
task: ExportAutomationTask,
queueState: 'queued' | 'running' | null,
nowMs: number
): string => {
if (!task.enabled) return '已停用'
if (queueState === 'running') return '执行中'
if (queueState === 'queued') return '排队中'
const nextTriggerAt = resolveAutomationNextTriggerAt(task)
if (!nextTriggerAt) return '等待触发'
const diff = nextTriggerAt - nowMs
if (diff <= 0) return '即将触发'
return `等待触发 · 下次 ${new Date(nextTriggerAt).toLocaleString('zh-CN')}(约 ${formatDurationMs(diff)} 后)`
}
export const formatAutomationStopCondition = (task: ExportAutomationTask): string => {
const endAt = Number(task.stopCondition?.endAt || 0)
const maxRuns = Number(task.stopCondition?.maxRuns || 0)
const labels: string[] = []
if (endAt > 0) {
labels.push(`截止到 ${new Date(endAt).toLocaleString('zh-CN')}`)
}
if (maxRuns > 0) {
const successCount = Math.max(0, Math.floor(Number(task.runState?.successCount || 0)))
labels.push(`成功 ${successCount}/${maxRuns} 次后停止`)
}
return labels.length > 0 ? labels.join(' · ') : '无'
}
export const formatAutomationLastRunSummary = (task: ExportAutomationTask): string => {
const status = task.runState?.lastRunStatus || 'idle'
const label = (
status === 'idle' ? '尚未执行' :
status === 'queued' ? '已入队' :
status === 'running' ? '执行中' :
status === 'success' ? '执行成功' :
status === 'error' ? '执行失败' :
status === 'skipped' ? '已跳过' :
status
)
const parts: string[] = [label]
if (task.runState?.lastSuccessAt) {
parts.push(`最近成功于 ${new Date(task.runState.lastSuccessAt).toLocaleString('zh-CN')}`)
}
if (task.runState?.lastSkipReason) parts.push(task.runState.lastSkipReason)
if (task.runState?.lastError) parts.push(task.runState.lastError)
return parts.join(' · ')
}
// ─── Schedule builder ────────────────────────────────────────
export const buildAutomationSchedule = (
intervalDays: number,
intervalHours: number,
firstTriggerAt: number
): ExportAutomationSchedule => ({
type: 'interval',
intervalDays,
intervalHours,
firstTriggerAt: firstTriggerAt > 0 ? firstTriggerAt : undefined
})
// ─── Date part helpers ───────────────────────────────────────
export const buildAutomationDatePart = (timestamp: number): string => {
const date = new Date(timestamp)
if (Number.isNaN(date.getTime())) return ''
const year = date.getFullYear()
const month = `${date.getMonth() + 1}`.padStart(2, '0')
const day = `${date.getDate()}`.padStart(2, '0')
return `${year}-${month}-${day}`
}
export const buildAutomationTodayDatePart = (): string => buildAutomationDatePart(Date.now())
export const normalizeAutomationDatePart = (value: string): string => {
const text = String(value || '').trim()
return /^\d{4}-\d{2}-\d{2}$/.test(text) ? text : ''
}
export const normalizeAutomationTimePart = (value: string): string => {
const text = String(value || '').trim()
if (!/^\d{2}:\d{2}$/.test(text)) return '00:00'
const [hoursText, minutesText] = text.split(':')
const hours = Math.floor(Number(hoursText))
const minutes = Math.floor(Number(minutesText))
if (!Number.isFinite(hours) || !Number.isFinite(minutes)) return '00:00'
const safeHours = Math.min(23, Math.max(0, hours))
const safeMinutes = Math.min(59, Math.max(0, minutes))
return `${`${safeHours}`.padStart(2, '0')}:${`${safeMinutes}`.padStart(2, '0')}`
}
// ─── Date range for automation ───────────────────────────────
export type AutomationRangeMode = 'all' | 'today' | 'yesterday' | 'last7days' | 'last30days' | 'last1year' | 'lastNDays' | 'custom'
export const AUTOMATION_RANGE_OPTIONS: Array<{ mode: AutomationRangeMode; label: string }> = [
{ mode: 'all', label: '全部时间' },
{ mode: 'yesterday', label: '往前1天' },
{ mode: 'last7days', label: '往前7天' },
{ mode: 'last30days', label: '往前30天' },
{ mode: 'last1year', label: '往前1年' },
{ mode: 'lastNDays', label: '往前N天' },
{ mode: 'custom', label: '完整时间' }
]
export const AUTOMATION_LAST_N_DAYS_MIN = 1
export const AUTOMATION_LAST_N_DAYS_MAX = 3650
export const AUTOMATION_LAST_N_DAYS_DEFAULT = 3
export const normalizeAutomationLastNDays = (value: unknown): number => {
const parsed = Math.floor(Number(value) || 0)
if (!Number.isFinite(parsed) || parsed <= 0) return AUTOMATION_LAST_N_DAYS_DEFAULT
return Math.min(AUTOMATION_LAST_N_DAYS_MAX, Math.max(AUTOMATION_LAST_N_DAYS_MIN, parsed))
}
export const readAutomationLastNDays = (
config: ExportAutomationDateRangeConfig | string | null | undefined
): number | null => {
if (!config || typeof config !== 'object') return null
const raw = config as Record<string, unknown>
const mode = String(raw.relativeMode || '').trim()
if (mode !== 'last-n-days') return null
const days = Math.floor(Number(raw.relativeDays) || 0)
if (!Number.isFinite(days) || days <= 0) return null
return Math.min(AUTOMATION_LAST_N_DAYS_MAX, Math.max(AUTOMATION_LAST_N_DAYS_MIN, days))
}
export const buildAutomationLastNDaysConfig = (days: number): ExportAutomationDateRangeConfig => ({
version: 1,
preset: 'custom',
useAllTime: false,
relativeMode: 'last-n-days',
relativeDays: normalizeAutomationLastNDays(days)
})
export const resolveAutomationDateRangeSelection = (
config: ExportAutomationDateRangeConfig | string | null | undefined,
now = new Date()
): ExportDateRangeSelection => {
const relativeDays = readAutomationLastNDays(config)
if (relativeDays) {
return {
preset: 'custom',
useAllTime: false,
dateRange: createDateRangeByLastNDays(relativeDays, now)
}
}
return resolveExportDateRangeConfig(config as any, now)
}
export const resolveAutomationRangeMode = (
config: ExportAutomationDateRangeConfig | string | null | undefined,
selection: ExportDateRangeSelection
): AutomationRangeMode => {
if (readAutomationLastNDays(config)) return 'lastNDays'
if (selection.useAllTime) return 'all'
if (selection.preset === 'today') return 'today'
if (selection.preset === 'yesterday') return 'yesterday'
if (selection.preset === 'last7days') return 'last7days'
if (selection.preset === 'last30days') return 'last30days'
if (selection.preset === 'last1year') return 'last1year'
return 'custom'
}
export const createAutomationSelectionByMode = (
mode: Exclude<AutomationRangeMode, 'custom' | 'lastNDays'>,
now = new Date()
): ExportDateRangeSelection => {
const preset: ExportDateRangePreset = mode
return resolveExportDateRangeConfig({
version: 1,
preset,
useAllTime: mode === 'all'
}, now)
}
export const formatAutomationRangeLabel = (
config: ExportAutomationDateRangeConfig | string | null | undefined,
selection?: ExportDateRangeSelection
): string => {
const resolved = selection || resolveAutomationDateRangeSelection(config, new Date())
const mode = resolveAutomationRangeMode(config, resolved)
if (mode === 'all') return '每次触发导出全部历史消息'
if (mode === 'today') return '每次触发导出当天'
if (mode === 'yesterday') return '每次触发导出前1天(昨日)'
if (mode === 'last7days') return '每次触发导出前7天'
if (mode === 'last30days') return '每次触发导出前30天'
if (mode === 'last1year') return '每次触发导出前1年'
if (mode === 'lastNDays') {
return `每次触发导出前 ${readAutomationLastNDays(config) || AUTOMATION_LAST_N_DAYS_DEFAULT} 天`
}
return `完整时间:${getExportDateRangeLabel(resolved)}`
}
// ─── Task ID factories ───────────────────────────────────────
export const createTaskId = (): string => `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
export const createAutomationTaskId = (): string => `auto-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
+160
View File
@@ -0,0 +1,160 @@
/**
* ExportV2 — Avatar utility functions
* Pure functions for avatar URL normalization, caching logic, and merge operations.
*/
import type { ContactInfo } from '../../../types/models'
import type * as configService from '../../../services/config'
import { INLINE_AVATAR_CACHE_MAX_LENGTH } from '../constants'
// ─── URL normalization ───────────────────────────────────────
export const normalizeExportAvatarUrl = (value?: string | null): string | undefined => {
const normalized = String(value || '').trim()
if (!normalized) return undefined
const lower = normalized.toLowerCase()
if (lower === 'null' || lower === 'undefined') return undefined
return normalized
}
export const shouldPersistExportAvatarUrl = (value?: string | null): value is string => {
const normalized = normalizeExportAvatarUrl(value)
if (!normalized) return false
if (!normalized.startsWith('data:')) return true
return normalized.length <= INLINE_AVATAR_CACHE_MAX_LENGTH
}
// ─── Contact map from caches ─────────────────────────────────
export const toContactMapFromCaches = (
contacts: configService.ContactsListCacheContact[],
avatarEntries: Record<string, configService.ContactsAvatarCacheEntry>
): Record<string, ContactInfo> => {
const map: Record<string, ContactInfo> = {}
for (const contact of contacts || []) {
if (!contact?.username) continue
const cachedAvatarUrl = avatarEntries[contact.username]?.avatarUrl
map[contact.username] = {
...contact,
avatarUrl: shouldPersistExportAvatarUrl(cachedAvatarUrl) ? cachedAvatarUrl : undefined
}
}
return map
}
// ─── Avatar cache compaction ─────────────────────────────────
export const compactExportAvatarEntries = (
avatarEntries: Record<string, configService.ContactsAvatarCacheEntry>
): {
avatarEntries: Record<string, configService.ContactsAvatarCacheEntry>
changed: boolean
} => {
const nextCache: Record<string, configService.ContactsAvatarCacheEntry> = {}
let changed = false
for (const [username, entry] of Object.entries(avatarEntries || {})) {
const normalizedUsername = String(username || '').trim()
const avatarUrl = normalizeExportAvatarUrl(entry?.avatarUrl)
if (!normalizedUsername || !shouldPersistExportAvatarUrl(avatarUrl)) {
changed = true
continue
}
nextCache[normalizedUsername] = {
avatarUrl,
updatedAt: Number(entry?.updatedAt || 0) || Date.now(),
checkedAt: Number(entry?.checkedAt || 0) || Date.now()
}
if (
normalizedUsername !== username ||
avatarUrl !== entry?.avatarUrl ||
nextCache[normalizedUsername].updatedAt !== entry?.updatedAt ||
nextCache[normalizedUsername].checkedAt !== entry?.checkedAt
) {
changed = true
}
}
return { avatarEntries: nextCache, changed }
}
// ─── Merge avatar cache into contacts ────────────────────────
export const mergeAvatarCacheIntoContacts = (
sourceContacts: ContactInfo[],
avatarEntries: Record<string, configService.ContactsAvatarCacheEntry>
): ContactInfo[] => {
if (!sourceContacts.length || Object.keys(avatarEntries).length === 0) {
return sourceContacts
}
let changed = false
const merged = sourceContacts.map((contact) => {
const cachedAvatar = avatarEntries[contact.username]?.avatarUrl
if (!shouldPersistExportAvatarUrl(cachedAvatar) || contact.avatarUrl) {
return contact
}
changed = true
return {
...contact,
avatarUrl: cachedAvatar
}
})
return changed ? merged : sourceContacts
}
// ─── Upsert avatar cache from contacts ───────────────────────
export const upsertAvatarCacheFromContacts = (
avatarEntries: Record<string, configService.ContactsAvatarCacheEntry>,
sourceContacts: ContactInfo[],
options?: { prune?: boolean; markCheckedUsernames?: string[]; now?: number }
): {
avatarEntries: Record<string, configService.ContactsAvatarCacheEntry>
changed: boolean
updatedAt: number | null
} => {
const compactedCache = compactExportAvatarEntries(avatarEntries)
const nextCache = { ...compactedCache.avatarEntries }
const now = options?.now || Date.now()
const markCheckedSet = new Set((options?.markCheckedUsernames || []).filter(Boolean))
const usernamesInSource = new Set<string>()
let changed = compactedCache.changed
for (const contact of sourceContacts) {
const username = String(contact.username || '').trim()
if (!username) continue
usernamesInSource.add(username)
const prev = nextCache[username]
const avatarUrl = normalizeExportAvatarUrl(contact.avatarUrl)
if (!shouldPersistExportAvatarUrl(avatarUrl)) continue
const updatedAt = !prev || prev.avatarUrl !== avatarUrl ? now : prev.updatedAt
const checkedAt = markCheckedSet.has(username) ? now : (prev?.checkedAt || now)
if (!prev || prev.avatarUrl !== avatarUrl || prev.updatedAt !== updatedAt || prev.checkedAt !== checkedAt) {
nextCache[username] = { avatarUrl, updatedAt, checkedAt }
changed = true
}
}
for (const username of markCheckedSet) {
const prev = nextCache[username]
if (!prev) continue
if (prev.checkedAt !== now) {
nextCache[username] = { ...prev, checkedAt: now }
changed = true
}
}
if (options?.prune) {
for (const username of Object.keys(nextCache)) {
if (usernamesInSource.has(username)) continue
delete nextCache[username]
changed = true
}
}
return {
avatarEntries: nextCache,
changed,
updatedAt: changed ? now : null
}
}
+209
View File
@@ -0,0 +1,209 @@
/**
* ExportV2 — Formatting utilities
* Pure functions for date, time, duration, and path formatting.
*/
// ─── Duration ────────────────────────────────────────────────
export const formatDurationMs = (ms: number): string => {
const totalSeconds = Math.max(0, Math.floor(ms / 1000))
const hours = Math.floor(totalSeconds / 3600)
const minutes = Math.floor((totalSeconds % 3600) / 60)
const seconds = totalSeconds % 60
if (hours > 0) return `${hours}小时${minutes}分${seconds}秒`
if (minutes > 0) return `${minutes}分${seconds}秒`
return `${seconds}秒`
}
// ─── Absolute dates ──────────────────────────────────────────
export const formatAbsoluteDate = (timestamp: number): string => {
const d = new Date(timestamp)
const y = d.getFullYear()
const m = `${d.getMonth() + 1}`.padStart(2, '0')
const day = `${d.getDate()}`.padStart(2, '0')
return `${y}-${m}-${day}`
}
export const formatYmdDateFromSeconds = (timestamp?: number): string => {
if (!timestamp || !Number.isFinite(timestamp)) return '—'
const d = new Date(timestamp * 1000)
const y = d.getFullYear()
const m = `${d.getMonth() + 1}`.padStart(2, '0')
const day = `${d.getDate()}`.padStart(2, '0')
return `${y}-${m}-${day}`
}
export const formatYmdHmDateTime = (timestamp?: number): string => {
if (!timestamp || !Number.isFinite(timestamp)) return '—'
const d = new Date(timestamp)
const y = d.getFullYear()
const m = `${d.getMonth() + 1}`.padStart(2, '0')
const day = `${d.getDate()}`.padStart(2, '0')
const h = `${d.getHours()}`.padStart(2, '0')
const min = `${d.getMinutes()}`.padStart(2, '0')
return `${y}-${m}-${day} ${h}:${min}`
}
// ─── Relative time ───────────────────────────────────────────
export const formatLatestMessageTimeFromSeconds = (
timestamp?: number,
now: number = Date.now()
): { text: string; title: string } => {
if (!timestamp || !Number.isFinite(timestamp) || timestamp <= 0) {
return { text: '--', title: '' }
}
const ms = timestamp * 1000
const absolute = formatYmdHmDateTime(ms)
const diff = Math.max(0, now - ms)
const minute = 60 * 1000
const hour = 60 * minute
const day = 24 * hour
if (diff < minute) return { text: '刚刚', title: absolute }
if (diff < hour) {
const minutes = Math.max(1, Math.floor(diff / minute))
return { text: `${minutes} 分钟前`, title: absolute }
}
if (diff < day) {
const hours = Math.max(1, Math.floor(diff / hour))
return { text: `${hours} 小时前`, title: absolute }
}
return { text: absolute, title: absolute }
}
export const formatRecentExportTime = (timestamp?: number, now = Date.now()): string => {
if (!timestamp) return ''
const diff = Math.max(0, now - timestamp)
const minute = 60 * 1000
const hour = 60 * minute
const day = 24 * hour
if (diff < hour) {
const minutes = Math.max(1, Math.floor(diff / minute))
return `${minutes} 分钟前`
}
if (diff < day) {
const hours = Math.max(1, Math.floor(diff / hour))
return `${hours} 小时前`
}
return formatAbsoluteDate(timestamp)
}
// ─── Path formatting ─────────────────────────────────────────
export const formatPathBrief = (value: string, maxLength = 52): string => {
const normalized = String(value || '')
if (normalized.length <= maxLength) return normalized
// Try to split by path separator
const separator = normalized.includes('\\') ? '\\' : '/'
const parts = normalized.split(separator).filter(Boolean) // Handle trailing/leading slashes
if (parts.length <= 2) {
// Cannot shorten by directory levels, fallback to string slice
const headLength = Math.max(10, Math.floor(maxLength * 0.55))
const tailLength = Math.max(8, maxLength - headLength - 1)
return `${normalized.slice(0, headLength)}…${normalized.slice(-tailLength)}`
}
// Handle Windows paths like C:\ keeping the root
let first = parts[0]
if (normalized.startsWith(separator)) {
first = separator + first
} else if (normalized.includes(':\\') || normalized.includes(':/')) {
// Keep root drive like C:
first = first + separator
} else {
first = first + separator
}
const last = parts[parts.length - 1]
let result = `${first}…${separator}${last}`
let leftIdx = 1
let rightIdx = parts.length - 2
while (leftIdx <= rightIdx) {
const nextLeft = `${parts[leftIdx]}${separator}`
const nextRight = leftIdx < rightIdx ? `${separator}${parts[rightIdx]}` : ''
if (result.length + nextLeft.length + nextRight.length <= maxLength) {
result = result.replace('…', `${nextLeft}…${nextRight}`)
leftIdx++
rightIdx--
} else {
break
}
}
if (result.length > maxLength) {
const headLength = Math.max(10, Math.floor(maxLength * 0.55))
const tailLength = Math.max(8, maxLength - headLength - 1)
return `${normalized.slice(0, headLength)}…${normalized.slice(-tailLength)}`
}
return result
}
export const resolveParentDir = (value: string): string => {
const normalized = String(value || '').trim()
if (!normalized) return ''
const noTrailing = normalized.replace(/[\\/]+$/, '')
if (!noTrailing) return normalized
const lastSlash = Math.max(noTrailing.lastIndexOf('/'), noTrailing.lastIndexOf('\\'))
if (lastSlash < 0) return normalized
if (lastSlash === 0) return noTrailing.slice(0, 1)
if (/^[A-Za-z]:$/.test(noTrailing.slice(0, lastSlash))) {
return `${noTrailing.slice(0, lastSlash)}\\`
}
return noTrailing.slice(0, lastSlash)
}
// ─── Avatar ──────────────────────────────────────────────────
export const getAvatarLetter = (name: string): string => {
if (!name) return '?'
return [...name][0] || '?'
}
// ─── DateTime local value (for <input type="datetime-local">) ─
export const toDateTimeLocalValue = (timestamp: number): string => {
const date = new Date(timestamp)
if (Number.isNaN(date.getTime())) return ''
const year = date.getFullYear()
const month = `${date.getMonth() + 1}`.padStart(2, '0')
const day = `${date.getDate()}`.padStart(2, '0')
const hours = `${date.getHours()}`.padStart(2, '0')
const minutes = `${date.getMinutes()}`.padStart(2, '0')
return `${year}-${month}-${day}T${hours}:${minutes}`
}
export const parseDateTimeLocalValue = (value: string): number | null => {
const text = String(value || '').trim()
if (!text) return null
const parsed = new Date(text)
const timestamp = parsed.getTime()
if (!Number.isFinite(timestamp)) return null
return Math.floor(timestamp)
}
// ─── Number normalization ────────────────────────────────────
export const normalizeMessageCount = (value: unknown): number | undefined => {
const parsed = Number(value)
if (!Number.isFinite(parsed) || parsed < 0) return undefined
return Math.floor(parsed)
}
export const normalizeTimestampSeconds = (value: unknown): number | undefined => {
const parsed = Number(value)
if (!Number.isFinite(parsed) || parsed <= 0) return undefined
return Math.floor(parsed)
}
export const mergeStableCount = (incoming: number | undefined, previous: number | undefined): number | undefined => {
if (typeof incoming !== 'number') return previous
if (incoming === 0 && typeof previous === 'number' && previous > 0) return previous
return incoming
}
+162
View File
@@ -0,0 +1,162 @@
/**
* ExportV2 — Performance tracking utilities
* Pure functions for task performance measurement (per-session timing, stage breakdown).
*/
import type { ExportTask, TaskPerformance, TaskSessionPerformance, TaskPerfStage, ExportProgress } from '../types'
import { TASK_PERFORMANCE_UPDATE_MIN_INTERVAL_MS } from '../constants'
import { isTextBatchTask } from './progress'
// ─── Stage resolution ────────────────────────────────────────
export const resolvePerfStageByPhase = (phase?: ExportProgress['phase']): TaskPerfStage => {
if (phase === 'preparing') return 'collect'
if (phase === 'writing') return 'write'
if (phase === 'exporting' || phase === 'exporting-media' || phase === 'exporting-voice') return 'build'
return 'other'
}
// ─── Performance object factories ────────────────────────────
export const createEmptyTaskPerformance = (): TaskPerformance => ({
stages: { collect: 0, build: 0, write: 0, other: 0 },
sessions: {}
})
export const cloneTaskPerformance = (performance?: TaskPerformance): TaskPerformance => ({
stages: {
collect: performance?.stages.collect || 0,
build: performance?.stages.build || 0,
write: performance?.stages.write || 0,
other: performance?.stages.other || 0
},
sessions: { ...(performance?.sessions || {}) }
})
// ─── Session name resolution ─────────────────────────────────
export const resolveTaskSessionName = (task: ExportTask, sessionId: string, fallback?: string): string => {
const idx = task.payload.sessionIds.indexOf(sessionId)
if (idx >= 0) {
return task.payload.sessionNames[idx] || fallback || sessionId
}
return fallback || sessionId
}
// ─── Apply progress to performance ──────────────────────────
export const applyProgressToTaskPerformance = (
task: ExportTask,
payload: ExportProgress,
now: number
): TaskPerformance | undefined => {
if (!isTextBatchTask(task)) return task.performance
const sessionId = String(payload.currentSessionId || '').trim()
if (!sessionId) return task.performance || createEmptyTaskPerformance()
const currentPerformance = task.performance
const currentSession = currentPerformance?.sessions?.[sessionId]
if (
payload.phase !== 'complete' &&
currentSession &&
currentSession.lastPhase === payload.phase &&
typeof currentSession.lastPhaseStartedAt === 'number' &&
now - currentSession.lastPhaseStartedAt < TASK_PERFORMANCE_UPDATE_MIN_INTERVAL_MS
) {
return currentPerformance
}
const performance = cloneTaskPerformance(task.performance)
const sessionName = resolveTaskSessionName(task, sessionId, payload.currentSession || sessionId)
const existing = performance.sessions[sessionId]
const session: TaskSessionPerformance = existing
? { ...existing, sessionName: existing.sessionName || sessionName }
: { sessionId, sessionName, startedAt: now, elapsedMs: 0 }
if (!session.finishedAt && session.lastPhase && typeof session.lastPhaseStartedAt === 'number') {
const delta = Math.max(0, now - session.lastPhaseStartedAt)
performance.stages[resolvePerfStageByPhase(session.lastPhase)] += delta
}
session.elapsedMs = Math.max(session.elapsedMs, now - session.startedAt)
if (payload.phase === 'complete') {
session.finishedAt = now
session.lastPhase = undefined
session.lastPhaseStartedAt = undefined
} else {
session.lastPhase = payload.phase
session.lastPhaseStartedAt = now
}
performance.sessions[sessionId] = session
return performance
}
// ─── Finalize performance on task completion ─────────────────
export const finalizeTaskPerformance = (task: ExportTask, now: number): TaskPerformance | undefined => {
if (!isTextBatchTask(task) || !task.performance) return task.performance
const performance = cloneTaskPerformance(task.performance)
const nextSessions: Record<string, TaskSessionPerformance> = {}
for (const [sessionId, sourceSession] of Object.entries(performance.sessions)) {
const session: TaskSessionPerformance = { ...sourceSession }
if (session.finishedAt) continue
if (session.lastPhase && typeof session.lastPhaseStartedAt === 'number') {
const delta = Math.max(0, now - session.lastPhaseStartedAt)
performance.stages[resolvePerfStageByPhase(session.lastPhase)] += delta
}
session.elapsedMs = Math.max(session.elapsedMs, now - session.startedAt)
session.finishedAt = now
session.lastPhase = undefined
session.lastPhaseStartedAt = undefined
nextSessions[sessionId] = session
}
for (const [sessionId, sourceSession] of Object.entries(performance.sessions)) {
if (nextSessions[sessionId]) continue
nextSessions[sessionId] = { ...sourceSession }
}
performance.sessions = nextSessions
return performance
}
// ─── Stage totals (live, accounts for in-progress sessions) ──
export const getTaskPerformanceStageTotals = (
performance: TaskPerformance | undefined,
now: number
): Record<TaskPerfStage, number> => {
const totals: Record<TaskPerfStage, number> = {
collect: performance?.stages.collect || 0,
build: performance?.stages.build || 0,
write: performance?.stages.write || 0,
other: performance?.stages.other || 0
}
if (!performance) return totals
for (const session of Object.values(performance.sessions)) {
if (session.finishedAt) continue
if (!session.lastPhase || typeof session.lastPhaseStartedAt !== 'number') continue
const delta = Math.max(0, now - session.lastPhaseStartedAt)
totals[resolvePerfStageByPhase(session.lastPhase)] += delta
}
return totals
}
// ─── Top N slowest sessions ──────────────────────────────────
export const getTaskPerformanceTopSessions = (
performance: TaskPerformance | undefined,
now: number,
limit = 5
): Array<TaskSessionPerformance & { liveElapsedMs: number }> => {
if (!performance) return []
return Object.values(performance.sessions)
.map((session) => {
const liveElapsedMs = session.finishedAt
? session.elapsedMs
: Math.max(session.elapsedMs, now - session.startedAt)
return { ...session, liveElapsedMs }
})
.sort((a, b) => b.liveElapsedMs - a.liveElapsedMs)
.slice(0, limit)
}
+155
View File
@@ -0,0 +1,155 @@
/**
* ExportV2 — Progress utility functions
* Pure functions for progress normalization, comparison, and formatting.
*/
import type { TaskProgress, ExportTask, ExportProgress } from '../types'
// ─── Progress comparison ─────────────────────────────────────
export const areTaskProgressEqual = (left: TaskProgress, right: TaskProgress): boolean => (
left.current === right.current &&
left.total === right.total &&
left.currentName === right.currentName &&
left.phase === right.phase &&
left.phaseLabel === right.phaseLabel &&
left.phaseProgress === right.phaseProgress &&
left.phaseTotal === right.phaseTotal &&
left.exportedMessages === right.exportedMessages &&
left.estimatedTotalMessages === right.estimatedTotalMessages &&
left.collectedMessages === right.collectedMessages &&
left.writtenFiles === right.writtenFiles &&
left.mediaDoneFiles === right.mediaDoneFiles &&
left.mediaCacheHitFiles === right.mediaCacheHitFiles &&
left.mediaCacheMissFiles === right.mediaCacheMissFiles &&
left.mediaCacheFillFiles === right.mediaCacheFillFiles &&
left.mediaDedupReuseFiles === right.mediaDedupReuseFiles &&
left.mediaBytesWritten === right.mediaBytesWritten
)
// ─── Progress normalization ──────────────────────────────────
export const normalizeProgressFloat = (value: unknown, digits = 3): number => {
const parsed = Number(value)
if (!Number.isFinite(parsed)) return 0
const factor = 10 ** digits
return Math.round(parsed * factor) / factor
}
export const normalizeProgressInt = (value: unknown): number => {
const parsed = Number(value)
if (!Number.isFinite(parsed)) return 0
return Math.max(0, Math.floor(parsed))
}
// ─── Progress payload signature (for dedup) ──────────────────
export const buildProgressPayloadSignature = (payload: ExportProgress): string => ([
String(payload.phase || ''),
String(payload.currentSessionId || ''),
String(payload.currentSession || ''),
String(payload.phaseLabel || ''),
normalizeProgressFloat(payload.current, 4),
normalizeProgressFloat(payload.total, 4),
normalizeProgressFloat(payload.phaseProgress, 2),
normalizeProgressFloat(payload.phaseTotal, 2),
normalizeProgressInt(payload.collectedMessages),
normalizeProgressInt(payload.exportedMessages),
normalizeProgressInt(payload.estimatedTotalMessages),
normalizeProgressInt(payload.writtenFiles),
normalizeProgressInt(payload.mediaDoneFiles),
normalizeProgressInt(payload.mediaCacheHitFiles),
normalizeProgressInt(payload.mediaCacheMissFiles),
normalizeProgressInt(payload.mediaCacheFillFiles),
normalizeProgressInt(payload.mediaDedupReuseFiles),
normalizeProgressInt(payload.mediaBytesWritten)
].join('|'))
// ─── Task status helpers ─────────────────────────────────────
export const isExportTaskActiveStatus = (status: import('../types').TaskStatus): boolean => (
status === 'queued' ||
status === 'running' ||
status === 'pause_requested' ||
status === 'paused' ||
status === 'cancel_requested'
)
export const getTaskStatusLabel = (task: ExportTask): string => {
if (task.status === 'queued') return '排队中'
if (task.status === 'running') return '进行中'
if (task.status === 'pause_requested') return '暂停中'
if (task.status === 'paused') return '已暂停'
if (task.status === 'cancel_requested') return '取消中'
if (task.status === 'success') return '已完成'
return '失败'
}
export const resolveExportTaskCardClass = (
status: import('../types').TaskStatus
): 'queued' | 'running' | 'paused' | 'stopped' | 'success' | 'error' => {
if (status === 'pause_requested' || status === 'paused') return 'paused'
if (status === 'cancel_requested') return 'stopped'
return status
}
export const resolveBackgroundTaskCardClass = (
status: import('../../../types/backgroundTask').BackgroundTaskRecord['status']
): 'running' | 'paused' | 'stopped' | 'success' | 'error' => {
if (status === 'running') return 'running'
if (status === 'pause_requested' || status === 'paused') return 'paused'
if (status === 'cancel_requested' || status === 'canceled') return 'stopped'
if (status === 'completed') return 'success'
return 'error'
}
// ─── Background task progress parsing ────────────────────────
export const parseBackgroundTaskProgress = (progressText?: string): {
current: number
total: number
ratio: number | null
} => {
const normalized = String(progressText || '').trim()
if (!normalized) return { current: 0, total: 0, ratio: null }
const match = normalized.match(/(\d+)\s*\/\s*(\d+)/)
if (!match) return { current: 0, total: 0, ratio: null }
const current = Math.max(0, Math.floor(Number(match[1]) || 0))
const total = Math.max(0, Math.floor(Number(match[2]) || 0))
if (total <= 0) return { current, total, ratio: null }
return {
current,
total,
ratio: Math.max(0, Math.min(1, current / total))
}
}
// ─── Task scope helpers ──────────────────────────────────────
export const isTextBatchTask = (task: ExportTask): boolean =>
task.payload.scope === 'content' && task.payload.contentType === 'text'
export const isImageExportTask = (task: ExportTask): boolean => {
if (task.payload.scope === 'sns') {
return Boolean(task.payload.snsOptions?.exportImages)
}
if (task.payload.scope !== 'content') return false
if (task.payload.contentType === 'image') return true
return Boolean(task.payload.options?.exportImages)
}
// ─── Task open directory resolution ──────────────────────────
import { resolveParentDir } from './format'
export const resolveTaskOpenDir = (task: ExportTask): string => {
const sessionIds = Array.isArray(task.payload.sessionIds) ? task.payload.sessionIds : []
if (sessionIds.length === 1) {
const onlySessionId = String(sessionIds[0] || '').trim()
const outputPath = onlySessionId ? String(task.sessionOutputPaths?.[onlySessionId] || '').trim() : ''
if (outputPath) {
return resolveParentDir(outputPath) || task.payload.outputDir
}
}
return task.payload.outputDir
}
+152
View File
@@ -0,0 +1,152 @@
/**
* ExportV2 — Session utility functions
* Pure functions for session classification, sorting, and merging.
*/
import type { ChatSession as AppChatSession, ContactInfo } from '../../../types/models'
import type { ConversationTab, SessionRow } from '../types'
// ─── Session type classification ─────────────────────────────
export const toKindByContactType = (session: AppChatSession, contact?: ContactInfo): ConversationTab => {
if (session.username.endsWith('@chatroom')) return 'group'
if (session.username.startsWith('gh_')) return 'official'
if (contact?.type === 'official') return 'official'
if (contact?.type === 'former_friend') return 'former_friend'
return 'private'
}
export const toKindByContact = (contact: ContactInfo): ConversationTab => {
if (contact.type === 'group') return 'group'
if (contact.type === 'official') return 'official'
if (contact.type === 'former_friend') return 'former_friend'
return 'private'
}
// ─── Session filtering predicates ────────────────────────────
export const isContentScopeSession = (session: SessionRow): boolean =>
session.kind === 'private' || session.kind === 'group' || session.kind === 'former_friend'
export const isExportConversationSession = (session: SessionRow): boolean =>
session.kind === 'private' || session.kind === 'group' || session.kind === 'former_friend'
export const isSingleContactSession = (sessionId: string): boolean => {
const normalized = String(sessionId || '').trim()
if (!normalized) return false
if (normalized.includes('@chatroom')) return false
if (normalized.startsWith('gh_')) return false
return true
}
export const matchesContactTab = (contact: ContactInfo, tab: ConversationTab): boolean => {
if (tab === 'private') return contact.type === 'friend'
if (tab === 'group') return contact.type === 'group'
if (tab === 'official') return contact.type === 'official'
return contact.type === 'former_friend'
}
// ─── Build session rows from sessions + contacts ─────────────
export const toSessionRowsWithContacts = (
sessions: AppChatSession[],
contactMap: Record<string, ContactInfo>
): SessionRow[] => {
const sessionMap = new Map<string, AppChatSession>()
for (const session of sessions || []) {
sessionMap.set(session.username, session)
}
const contacts = Object.values(contactMap)
.filter((contact) => (
contact.type === 'friend' ||
contact.type === 'group' ||
contact.type === 'official' ||
contact.type === 'former_friend'
))
if (contacts.length > 0) {
return contacts
.map((contact) => {
const session = sessionMap.get(contact.username)
const latestTs = session?.sortTimestamp || session?.lastTimestamp || 0
return {
...(session || {
username: contact.username,
type: 0,
unreadCount: 0,
summary: '',
sortTimestamp: latestTs,
lastTimestamp: latestTs,
lastMsgType: 0
}),
username: contact.username,
kind: toKindByContact(contact),
wechatId: contact.username,
displayName: contact.displayName || session?.displayName || contact.username,
avatarUrl: session?.avatarUrl || contact.avatarUrl,
remark: contact.remark,
nickname: contact.nickname,
hasSession: Boolean(session)
} as SessionRow
})
.sort((a, b) => {
const latestA = a.sortTimestamp || a.lastTimestamp || 0
const latestB = b.sortTimestamp || b.lastTimestamp || 0
if (latestA !== latestB) return latestB - latestA
return (a.displayName || a.username).localeCompare(b.displayName || b.username, 'zh-Hans-CN')
})
}
return sessions
.map((session) => {
const contact = contactMap[session.username]
return {
...session,
kind: toKindByContactType(session, contact),
wechatId: contact?.username || session.username,
displayName: contact?.displayName || session.displayName || session.username,
avatarUrl: session.avatarUrl || contact?.avatarUrl,
remark: contact?.remark,
nickname: contact?.nickname,
hasSession: true
} as SessionRow
})
.sort((a, b) => (b.sortTimestamp || b.lastTimestamp || 0) - (a.sortTimestamp || a.lastTimestamp || 0))
}
// ─── Array equality check ────────────────────────────────────
export const areStringArraysEqual = (left: string[], right: string[]): boolean => {
if (left === right) return true
if (left.length !== right.length) return false
for (let index = 0; index < left.length; index += 1) {
if (left[index] !== right[index]) return false
}
return true
}
export const getSelectionScopeFromRows = (rows: SessionRow[]): import('../types').TaskScope => {
if (rows.length === 0) return 'single'
if (rows.length === 1) return 'single'
return 'multi'
}
export const resolveScopeDisplayNames = (
rows: SessionRow[],
pref: import('../types').DisplayNamePreference
): string[] => {
return rows.map(r => r.remark || r.nickname || r.displayName || r.username)
}
// ─── Name comparison helper ──────────────────────────────────
export const toComparableNameSet = (values: Array<string | undefined | null>): Set<string> => {
const set = new Set<string>()
for (const value of values) {
const normalized = String(value || '').trim()
if (!normalized) continue
set.add(normalized)
}
return set
}