Ctrl K
ComunidadGuías…

src/utils/exportData.ts

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

Archivo original ↗Preguntar a KiBot
import { File, Paths } from 'expo-file-system';
import * as Sharing from 'expo-sharing';
import { getAllRecords, getAllSettings, getRecurringRules, getCustomCategories } from '../database/ledgerDB';
import { EXPORT_VERSION } from '../constants';
import type { CustomCategory, ExportData, LedgerRecord, RecurringRule } from '../types';

// v0.11 安全:备份不得携带登录凭证/身份/水位等同步私有键(分享即泄露 token,导入即劫持账号)
// v0.11.21:按前缀判断,而不是匹配 SETTING_KEYS 里的固定键名——同步水位实际存为
// `sync.last_push_at.<ledgerId>` / `sync.last_pull_at.<ledgerId>`(按账本动态拼接),
// 旧实现只过滤固定键,水位会随备份导出;导入到另一台设备后水位跳到旧设备进度,
// 会跳过区间内的家人变更或本地未传变更。
function isPrivateSyncKey(key: string): boolean {
  return key.startsWith('sync.');
}

// 过滤 settings:剔除 sync.* 私有键
export function sanitizeExportSettings(settings: Record<string, string>): Record<string, string> {
  const out: Record<string, string> = {};
  for (const [k, v] of Object.entries(settings)) {
    if (!isPrivateSyncKey(k)) out[k] = v;
  }
  return out;
}

// 导出全部数据为 JSON 文件并分享
export async function exportLedgerData(): Promise<{ success: boolean; count: number; error?: string }> {
  try {
    const records = await getAllRecords();
    if (records.length === 0) {
      return { success: false, count: 0, error: '暂无数据可导出' };
    }

    let settings: Record<string, string> = {};
    let recurring: RecurringRule[] = [];
    let customCategories: CustomCategory[] = [];
    try {
      const [s, r, c] = await Promise.all([getAllSettings(), getRecurringRules(), getCustomCategories()]);
      settings = sanitizeExportSettings(s);
      recurring = r;
      customCategories = c;
    } catch {
      // 附属数据读取失败不影响记录导出
    }

    const data: ExportData = {
      version: EXPORT_VERSION,
      exportedAt: new Date().toISOString(),
      count: records.length,
      records,
      settings,
      recurring,
      customCategories,
    };

    const fileName = `tapledger_backup_${getDateStr()}.json`;
    const file = new File(Paths.cache, fileName);
    file.create({ intermediates: true, overwrite: true });
    file.write(JSON.stringify(data, null, 2));

    if (await Sharing.isAvailableAsync()) {
      await Sharing.shareAsync(file.uri, {
        mimeType: 'application/json',
        dialogTitle: '导出数据',
        UTI: 'public.json',
      });
      return { success: true, count: records.length };
    }
    return { success: false, count: records.length, error: '当前设备不支持分享' };
  } catch {
    return { success: false, count: 0, error: '导出失败,请重试' };
  }
}

// 文件名日期
function getDateStr(): string {
  const now = new Date();
  return `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`;
}

// 校验单条记录(v0.3 字段;uuid 等同步字段可选)
export function isValidRecord(input: unknown): input is LedgerRecord {
  if (!input || typeof input !== 'object') return false;
  const r = input as Record<string, unknown>;
  return (
    typeof r.amount === 'number' &&
    Number.isFinite(r.amount) &&
    r.amount > 0 &&
    typeof r.category === 'string' &&
    r.category.length > 0 &&
    (r.type === 'expense' || r.type === 'income') &&
    typeof r.date === 'string' &&
    /^\d{4}-\d{2}-\d{2}$/.test(r.date) &&
    typeof r.timestamp === 'number'
  );
}

// 服务端 push 侧的字段上限(server/src/routes/sync.ts 的 zod schema)。
// 导入的数据若超限,服务端会逐条判非法并拒收,而本地已写入 → 表现为「我这有、家人没有」。
// 所以在导入入口就归一到上限内,宁可用默认值也不留一条永远传不上去的脏数据。
export const SERVER_LIMITS = {
  note: 60,
  category: 40,
  name: 20,
  label: 12,
  key: 60,
  color: 16,
  emoji: 8,
  uuidMin: 8,
  uuidMax: 64,
};

// 按码点截断(避免把 emoji 从中间切成乱码)
export function clip(value: string, max: number): string {
  const chars = Array.from(value ?? '');
  return chars.length <= max ? value : chars.slice(0, max).join('');
}

// uuid 不合法(过短/过长,多为外部工具伪造)时清空,由调用方重新生成
export function normUuid(uuid: unknown): string {
  if (typeof uuid !== 'string') return '';
  return uuid.length >= SERVER_LIMITS.uuidMin && uuid.length <= SERVER_LIMITS.uuidMax ? uuid : '';
}

// 清洗记录,补齐同步字段(v2 备份无 uuid → 自动生成)
export function normalizeRecord(r: LedgerRecord): Omit<LedgerRecord, 'id'> {
  const timestamp = Number.isFinite(r.timestamp) ? Math.max(0, Math.round(r.timestamp)) : Date.now();
  const updatedAt = Number(r.updatedAt) > 0 ? Math.round(Number(r.updatedAt)) : timestamp;
  return {
    uuid: normUuid(r.uuid),
    userId: Number(r.userId) > 0 ? Number(r.userId) : 0,
    amount: Math.round(r.amount * 100) / 100,
    category: clip(r.category, SERVER_LIMITS.category),
    type: r.type,
    note: clip(typeof r.note === 'string' ? r.note : '', SERVER_LIMITS.note),
    date: r.date,
    timestamp,
    reimbursable: Boolean(r.reimbursable),
    reimbursed: Boolean(r.reimbursed),
    updatedAt,
    deleted: Boolean(r.deleted),
  };
}