Ctrl K
ComunidadGuías…

src/utils/importData.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import * as DocumentPicker from 'expo-document-picker';
import { DeviceEventEmitter } from 'react-native';
import { File } from 'expo-file-system';
import {
  bulkInsertRecords,
  replaceRecordsByIdentity,
  replaceRecurringRulesByIdentity,
  replaceCustomCategoriesByIdentity,
  saveSetting,
  addCustomCategory,
  addRecurringRule,
  setCustomCategoriesCache,
  getCategoryConfig,
} from '../database/ledgerDB';
import { LEDGER_EVENTS, EXPORT_VERSION, setCategoryConfig } from '../constants';
import {
  isValidRecord,
  normalizeRecord,
  sanitizeExportSettings,
  clip,
  normUuid,
  SERVER_LIMITS,
} from './exportData';
import type { CustomCategory, LedgerRecord, RecurringRule } from '../types';

export type ImportStrategy = 'merge' | 'replace';

export interface ImportResult {
  success: boolean;
  strategy?: ImportStrategy;
  imported: number;
  skipped: number;
  failed?: number; // 附属数据(周期/分类)写入失败条数
  error?: string;
  cancelled?: boolean;
}

interface ParsedBackup {
  records: Omit<LedgerRecord, 'id'>[];
  settings: Record<string, string>;
  recurring: RecurringRule[];
  customCategories: CustomCategory[];
  skipped: number;
  error?: string;
}

function clampInt(value: unknown, min: number, max: number, fallback: number): number {
  const n = Math.round(Number(value));
  return Number.isFinite(n) && n >= min && n <= max ? n : fallback;
}

// 附属数据同样要归一到服务端字段上限(记录走 normalizeRecord)
function normalizeRecurring(r: RecurringRule): RecurringRule {
  const timestamp = Number.isFinite(r.createdAt) ? Math.max(0, Math.round(r.createdAt)) : Date.now();
  const amount = Number(r.amount);
  return {
    ...r,
    uuid: normUuid(r.uuid),
    name: clip(String(r.name ?? ''), SERVER_LIMITS.name) || '订阅',
    amount: Number.isFinite(amount) && amount > 0 ? Math.round(amount * 100) / 100 : 0,
    category: clip(String(r.category ?? ''), SERVER_LIMITS.category) || 'other',
    frequency: (['daily', 'weekly', 'monthly', 'yearly'] as const).includes(r.frequency)
      ? r.frequency
      : 'monthly',
    dayOfWeek: clampInt(r.dayOfWeek, 0, 6, 0),
    dayOfMonth: clampInt(r.dayOfMonth, 1, 31, 1),
    monthOfYear: clampInt(r.monthOfYear, 1, 12, 1),
    note: clip(typeof r.note === 'string' ? r.note : '', SERVER_LIMITS.note),
    enabled: Boolean(r.enabled),
    lastGenerated: clip(typeof r.lastGenerated === 'string' ? r.lastGenerated : '', 10),
    createdAt: timestamp,
    updatedAt: Number(r.updatedAt) > 0 ? Math.round(Number(r.updatedAt)) : timestamp,
  };
}

function normalizeCategory(c: CustomCategory): CustomCategory {
  const timestamp = Number.isFinite(c.createdAt) ? Math.max(0, Math.round(c.createdAt)) : Date.now();
  const emoji = typeof c.emoji === 'string' && c.emoji.length <= SERVER_LIMITS.emoji ? c.emoji : '📌';
  return {
    ...c,
    uuid: normUuid(c.uuid),
    key: clip(String(c.key ?? ''), SERVER_LIMITS.key) || `custom_${Date.now()}`,
    label: clip(String(c.label ?? ''), SERVER_LIMITS.label) || '分类',
    emoji,
    color: clip(typeof c.color === 'string' ? c.color : '', SERVER_LIMITS.color) || '#90A4AE',
    type: c.type === 'income' ? 'income' : 'expense',
    createdAt: timestamp,
    updatedAt: Number(c.updatedAt) > 0 ? Math.round(Number(c.updatedAt)) : timestamp,
  };
}

// 解析 JSON 备份
function parseJSONBackup(text: string): ParsedBackup {
  const empty: ParsedBackup = { records: [], settings: {}, recurring: [], customCategories: [], skipped: 0 };
  let parsed: unknown;
  try {
    parsed = JSON.parse(text);
  } catch {
    return { ...empty, error: 'JSON 解析失败' };
  }
  if (!parsed || typeof parsed !== 'object') {
    return { ...empty, error: 'JSON 结构无效' };
  }
  const obj = parsed as Record<string, unknown>;
  // 兼容 v2(无同步字段)与 v3(含 uuid/updatedAt)
  if (obj.version !== 2 && obj.version !== EXPORT_VERSION) {
    return { ...empty, error: `不支持的备份版本: ${String(obj.version)}` };
  }
  if (!Array.isArray(obj.records)) {
    return { ...empty, error: '缺少 records 字段' };
  }
  const records: Omit<LedgerRecord, 'id'>[] = [];
  let skipped = 0;
  for (const r of obj.records) {
    if (isValidRecord(r)) {
      records.push(normalizeRecord(r as LedgerRecord));
    } else {
      skipped++;
    }
  }
  const recurringRaw = Array.isArray(obj.recurring) ? (obj.recurring as RecurringRule[]) : [];
  const categoriesRaw = Array.isArray(obj.customCategories) ? (obj.customCategories as CustomCategory[]) : [];
  const recurring = recurringRaw.map(normalizeRecurring);
  // 金额非正的规则传上去必被服务端判非法,导入阶段直接丢弃并计入 skipped
  const validRecurring = recurring.filter((r) => r.amount > 0);
  return {
    records,
    settings: (obj.settings && typeof obj.settings === 'object' ? obj.settings : {}) as Record<
      string,
      string
    >,
    recurring: validRecurring,
    customCategories: categoriesRaw.map(normalizeCategory),
    skipped: skipped + (recurring.length - validRecurring.length),
  };
}

// 执行导入写入
async function applyImport(data: ParsedBackup, strategy: ImportStrategy): Promise<ImportResult> {
  try {
    let failed = 0;
    // 合并策略实际插入条数(已存在的 uuid 会跳过,v0.11.21)
    let inserted = data.records.length;
    // 1) 数据写入
    if (strategy === 'replace') {
      // 三张表统一按同步身份(uuid)替换:本地多出来的打墓碑、同 uuid 覆盖、新的插入。
      // 此前是「硬删本地 + 重插」,不产生墓碑 → 家人设备与服务器仍留着旧记录,
      // 换机/重装后被 pull 回来,表现为「替换导入没生效、账目翻倍」。
      await replaceRecordsByIdentity(data.records);
      await replaceRecurringRulesByIdentity(data.recurring);
      await replaceCustomCategoriesByIdentity(data.customCategories);
    } else {
      inserted = await bulkInsertRecords(data.records);
      // 3) 周期规则(合并策略逐条插入,冲突即计入 failed)
      for (const r of data.recurring) {
        try {
          await addRecurringRule({
            name: r.name,
            amount: r.amount,
            type: r.type,
            category: r.category,
            frequency: r.frequency,
            dayOfWeek: r.dayOfWeek,
            dayOfMonth: r.dayOfMonth,
            monthOfYear: r.monthOfYear,
            note: r.note,
            enabled: r.enabled,
            lastGenerated: r.lastGenerated,
            uuid: r.uuid || undefined,
            updatedAt: r.updatedAt || undefined,
            userId: r.userId || undefined,
          });
        } catch {
          failed++;
        }
      }
      // 4) 自定义分类
      for (const c of data.customCategories) {
        try {
          await addCustomCategory({
            key: c.key,
            label: c.label,
            emoji: c.emoji,
            color: c.color,
            type: c.type,
            uuid: c.uuid || undefined,
            updatedAt: c.updatedAt || undefined,
          });
        } catch {
          failed++;
        }
      }
    }
    // 2) 设置(v0.11 安全:剔除 sync.* 私有键,防止导入旧备份覆盖/劫持当前登录态)
    for (const [k, v] of Object.entries(sanitizeExportSettings(data.settings))) {
      if (typeof v === 'string') await saveSetting(k, v).catch(() => {});
    }
    await setCustomCategoriesCache();
    const cfg = await getCategoryConfig();
    setCategoryConfig(cfg);
    DeviceEventEmitter.emit(LEDGER_EVENTS.DATA_IMPORTED);
    return { success: true, strategy, imported: inserted, skipped: data.skipped, failed };
  } catch {
    return { success: false, imported: 0, skipped: 0, error: '数据库写入失败' };
  }
}

// 主入口:选择文件 + 解析 + 导入
export async function pickAndImportData(strategy: ImportStrategy): Promise<ImportResult> {
  let pickResult;
  try {
    pickResult = await DocumentPicker.getDocumentAsync({
      type: ['application/json', 'public.json'],
      copyToCacheDirectory: true,
    });
  } catch {
    return { success: false, imported: 0, skipped: 0, error: '无法选择文件' };
  }

  if (pickResult.canceled || !pickResult.assets || pickResult.assets.length === 0) {
    return { success: false, imported: 0, skipped: 0, cancelled: true };
  }

  const asset = pickResult.assets[0];
  const fileName = asset.name || '';
  if (!fileName.toLowerCase().endsWith('.json')) {
    return { success: false, imported: 0, skipped: 0, error: '请选择 JSON 文件' };
  }

  let text: string;
  try {
    const file = new File(asset.uri);
    text = await file.text();
  } catch {
    return { success: false, imported: 0, skipped: 0, error: '文件读取失败' };
  }

  const parsed = parseJSONBackup(text);
  if (parsed.error) {
    return { success: false, imported: 0, skipped: 0, error: parsed.error };
  }
  if (parsed.records.length === 0) {
    return { success: false, imported: 0, skipped: 0, error: '备份中无有效记录' };
  }
  return applyImport(parsed, strategy);
}