// Package rond 负责解析 rond(iOS 应用 LifeEasy)导出的 .rondbackup 备份包。 // // 备份包本质是 zip,内含 Core Data 的 SQLite 库 LifeEasy.sqlite 及同名 -wal / -shm。 // 数据主要在 WAL 里,因此必须三个文件一起解出来再打开,否则会丢失绝大部分记录。 package rond import ( "archive/zip" "context" "database/sql" "encoding/base64" "fmt" "io" "log" "math" "os" "path/filepath" "strings" "time" _ "modernc.org/sqlite" ) // Core Data 时间戳基准:2001-01-01 UTC var appleEpoch = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) func appleTime(v sql.NullFloat64) *time.Time { if !v.Valid { return nil } // 秒的整数部分和小数部分分开算:8 亿量级的秒乘 1e6/1e9 已超出 float64 的 // 精确整数范围(2^53),一次性相乘再取整会系统性吃掉最后 1 微秒, // 往返导出后时间戳就对不上了。 sec := math.Floor(v.Float64) us := int64(sec)*1_000_000 + int64(math.Round((v.Float64-sec)*1e6)) t := appleEpoch.Add(time.Duration(us) * time.Microsecond).Local() return &t } type Activity struct { SrcPK int Name string Color string Icon string IsHome bool IsWork bool IsExcluded bool IsArchived bool } type Tag struct { SrcPK int Name string Color string GroupName string } type Location struct { SrcPK int Name string POICategory string Lat float64 Lon float64 CountryCode string Province string City string District string Sublocality string Thoroughfare string SubThoroughfare string Timezone string } type Visit struct { SrcPK int LocationSrcPK *int ActivitySrcPK *int Arrival *time.Time Departure *time.Time Bookmarked bool UserAdded bool Remark string Emoji string WeatherSymbol string TagSrcPKs []int } type Movement struct { SrcPK int TransportSrc *int FromVisitSrc *int ToVisitSrc *int Start *time.Time End *time.Time } type Weather struct { SrcPK int VisitSrc *int At *time.Time IsDaylight bool TempC *float64 ApparentC *float64 Humidity *float64 PrecipAmt *float64 PrecipPct *float64 WindSpeed *float64 Visibility *float64 UVIndex *int Condition string Symbol string UVCategory string WindDir string } type Transport struct { SrcPK int Name string Color string Icon string } // CoreDataMeta 记录备份包里 Core Data 库的结构信息(实体编号、建表 DDL、关联表), // 作为数据集的结构快照留档;导出 rondbackup 已改为以「基线库」为底覆盖写入,不再依赖它。 type CoreDataMeta struct { // 实体名(ZPRIMARYKEY.Z_NAME) -> 实体编号(Z_ENT) 与当前最大主键(Z_MAX) Entities map[string]EntityInfo `json:"entities"` // 表名 -> CREATE TABLE 语句(含全部列与默认值,导出只 INSERT 其中部分列) DDL map[string]string `json:"ddl"` // 访问↔标签多对多关联表(Core Data 自动生成的 Z_VISITS_) JoinTable JoinTableInfo `json:"join_visit_tag"` } type EntityInfo struct { Ent int `json:"ent"` Max int `json:"max"` } type JoinTableInfo struct { Name string `json:"name"` VisitCol string `json:"visit_col"` TagCol string `json:"tag_col"` } type Backup struct { Activities []Activity Tags []Tag Transports []Transport Locations []Location Visits []Visit Movements []Movement Weather []Weather RawVisitNum int TagGroups map[int]string Meta *CoreDataMeta // RawRows 是各实体在 Core Data 里的完整原始行:「表名 -> Z_PK -> 列名 -> 值」。 // 站点只映射了其中一部分字段,留档让导出能逐列还原,不必依赖基线库补剩下的列。 RawRows map[string]map[int]map[string]any // VisitUUID / ActivityUUID 是真机给每条到访、每个活动存的 16 字节 UUID(小写十六进制)。 // **这是跨备份唯一稳定的对齐键**:rond 每次导出/同步都会重排 Z_PK——实测两份备份里 // 822 条到访的 UUID 完全一致、Z_PK 100% 不同——所以「这份备份」与「库里的那一份」 // 之间只能按它对齐。地点/标签/交通方式没有 UUID,只能退到名称与坐标。 VisitUUID map[int]string ActivityUUID map[int]string } // Open 解压备份包到 dir,返回可用的 SQLite 连接。 // 调用方负责 Close 与删除 dir。 func Open(archivePath, dir string) (*sql.DB, error) { if err := os.MkdirAll(dir, 0o755); err != nil { return nil, err } zr, err := zip.OpenReader(archivePath) if err != nil { return nil, fmt.Errorf("备份包不是有效的 zip: %w", err) } defer zr.Close() var dbPath string for _, f := range zr.File { base := filepath.Base(f.Name) if f.FileInfo().IsDir() || base == "" { continue } // 只取 Core Data 主库及其 WAL / SHM low := strings.ToLower(base) if !strings.HasSuffix(low, ".sqlite") && !strings.HasSuffix(low, ".sqlite-wal") && !strings.HasSuffix(low, ".sqlite-shm") { continue } dst := filepath.Join(dir, base) rc, err := f.Open() if err != nil { return nil, err } out, err := os.Create(dst) if err != nil { rc.Close() return nil, err } _, cerr := io.Copy(out, rc) rc.Close() out.Close() if cerr != nil { return nil, cerr } if strings.HasSuffix(low, ".sqlite") { dbPath = dst } } if dbPath == "" { return nil, fmt.Errorf("备份包内未找到 .sqlite 数据库文件") } db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(dbPath)+"?_pragma=busy_timeout(5000)") if err != nil { return nil, err } db.SetMaxOpenConns(1) // 把 WAL 合并回主库,避免后续读取时依赖 -shm 状态 if _, err := db.Exec("PRAGMA wal_checkpoint(TRUNCATE)"); err != nil { db.Close() return nil, fmt.Errorf("合并 WAL 失败: %w", err) } return db, nil } // Parse 读取 Core Data 库,转换为领域模型。 func Parse(db *sql.DB) (*Backup, error) { b := &Backup{TagGroups: map[int]string{}} var err error // 标签分组 rows, err := db.Query(`SELECT Z_PK, COALESCE(ZNAME_,'') FROM ZTAGGROUP`) if err != nil { return nil, fmt.Errorf("读取标签分组: %w", err) } for rows.Next() { var pk int var name string if err = rows.Scan(&pk, &name); err != nil { rows.Close() return nil, err } b.TagGroups[pk] = name } rows.Close() // 标签(含所属分组关系 Z_11TAGS_ 的逆向:ZTAG.ZGROUP -> ZTAGGROUP) tagGroup := map[int]int{} grows, err := db.Query(`SELECT Z_PK, COALESCE(ZGROUP,0) FROM ZTAG`) if err != nil { return nil, fmt.Errorf("读取标签: %w", err) } for grows.Next() { var pk, g int if err = grows.Scan(&pk, &g); err != nil { grows.Close() return nil, err } tagGroup[pk] = g } grows.Close() trows, err := db.Query(`SELECT Z_PK, COALESCE(ZNAME_,''), COALESCE(ZCOLOR_,'') FROM ZTAG`) if err != nil { return nil, fmt.Errorf("读取标签: %w", err) } for trows.Next() { var t Tag if err = trows.Scan(&t.SrcPK, &t.Name, &t.Color); err != nil { trows.Close() return nil, err } t.GroupName = b.TagGroups[tagGroup[t.SrcPK]] b.Tags = append(b.Tags, t) } trows.Close() // 类型 / 活动 arows, err := db.Query(`SELECT Z_PK, COALESCE(ZNAME_,''), COALESCE(ZCOLOR_,''), COALESCE(ZICON_,''), COALESCE(ZISHOME,0), COALESCE(ZISWORK,0), COALESCE(ZISEXCLUDED,0), COALESCE(ZISARCHIVED,0) FROM ZACTIVITY`) if err != nil { return nil, fmt.Errorf("读取活动类型: %w", err) } for arows.Next() { var a Activity var h, w, e, ar int if err = arows.Scan(&a.SrcPK, &a.Name, &a.Color, &a.Icon, &h, &w, &e, &ar); err != nil { arows.Close() return nil, err } a.IsHome, a.IsWork, a.IsExcluded, a.IsArchived = h == 1, w == 1, e == 1, ar == 1 b.Activities = append(b.Activities, a) } arows.Close() // 交通方式 trrows, err := db.Query(`SELECT Z_PK, COALESCE(ZNAME_,''), COALESCE(ZCOLOR_,''), COALESCE(ZICON_,'') FROM ZTRANSPORT`) if err != nil { return nil, fmt.Errorf("读取交通方式: %w", err) } for trrows.Next() { var t Transport if err = trrows.Scan(&t.SrcPK, &t.Name, &t.Color, &t.Icon); err != nil { trrows.Close() return nil, err } b.Transports = append(b.Transports, t) } trrows.Close() // 地点 lrows, err := db.Query(`SELECT Z_PK, COALESCE(ZNAME_,''), COALESCE(ZCATEGORY_,''), COALESCE(ZLATITUDE,0), COALESCE(ZLONGITUDE,0), COALESCE(ZISOCOUNTRYCODE,''), COALESCE(ZADMINISTRATIVEAREA,''), COALESCE(ZLOCALITY,''), COALESCE(ZSUBLOCALITY,''), COALESCE(ZTHOROUGHFARE,''), COALESCE(ZSUBTHOROUGHFARE,''), COALESCE(ZTIMEZONE,'') FROM ZLOCATION`) if err != nil { return nil, fmt.Errorf("读取地点: %w", err) } for lrows.Next() { var l Location if err = lrows.Scan(&l.SrcPK, &l.Name, &l.POICategory, &l.Lat, &l.Lon, &l.CountryCode, &l.Province, &l.City, &l.Sublocality, &l.Thoroughfare, &l.SubThoroughfare, &l.Timezone); err != nil { lrows.Close() return nil, err } b.Locations = append(b.Locations, l) } lrows.Close() // 访问记录 vrows, err := db.Query(`SELECT Z_PK, ZLOCATION, ZACTIVITY_, ZARRIVALDATE_, ZDEPARTUREDATE_, COALESCE(ZBOOKMARKED,0), COALESCE(ZUSERADDED,0), COALESCE(ZREMARK_,''), COALESCE(ZEMOJINAME_,''), COALESCE(ZWEATHERSYMBOL_,'') FROM ZVISIT`) if err != nil { return nil, fmt.Errorf("读取访问记录: %w", err) } visitIdx := map[int]int{} for vrows.Next() { var v Visit var loc, act sql.NullInt64 var arr, dep sql.NullFloat64 var bm, ua int if err = vrows.Scan(&v.SrcPK, &loc, &act, &arr, &dep, &bm, &ua, &v.Remark, &v.Emoji, &v.WeatherSymbol); err != nil { vrows.Close() return nil, err } if loc.Valid { pk := int(loc.Int64) v.LocationSrcPK = &pk } if act.Valid { pk := int(act.Int64) v.ActivitySrcPK = &pk } v.Arrival = appleTime(arr) v.Departure = appleTime(dep) v.Bookmarked, v.UserAdded = bm == 1, ua == 1 visitIdx[v.SrcPK] = len(b.Visits) b.Visits = append(b.Visits, v) } vrows.Close() // 访问 → 标签 vtrows, err := db.Query(`SELECT Z_18VISITS_, Z_11TAGS_5 FROM Z_11VISITS_`) if err != nil { return nil, fmt.Errorf("读取访问标签: %w", err) } for vtrows.Next() { var vid, tid int if err = vtrows.Scan(&vid, &tid); err != nil { vtrows.Close() return nil, err } if i, ok := visitIdx[vid]; ok { b.Visits[i].TagSrcPKs = append(b.Visits[i].TagSrcPKs, tid) } } vtrows.Close() // 位移 mrows, err := db.Query(`SELECT Z_PK, ZTRANSPORT_, ZVISITFROM_, ZVISITTO_, ZSTART_, ZEND_ FROM ZMOVEMENT`) if err != nil { return nil, fmt.Errorf("读取位移记录: %w", err) } for mrows.Next() { var m Movement var tr, from, to sql.NullInt64 var st, en sql.NullFloat64 if err = mrows.Scan(&m.SrcPK, &tr, &from, &to, &st, &en); err != nil { mrows.Close() return nil, err } if tr.Valid { pk := int(tr.Int64) m.TransportSrc = &pk } if from.Valid { pk := int(from.Int64) m.FromVisitSrc = &pk } if to.Valid { pk := int(to.Int64) m.ToVisitSrc = &pk } m.Start = appleTime(st) m.End = appleTime(en) b.Movements = append(b.Movements, m) } mrows.Close() // 小时级天气 wrows, err := db.Query(`SELECT Z_PK, ZVISIT, ZDATE_, COALESCE(ZISDAYLIGHT,0), ZTEMPERATURE_, ZAPPARENTTEMPERATURE_, ZHUMIDITY_, ZPRECIPITATIONAMOUNT_, ZPRECIPITATIONCHANCE_, ZWINDSPEED_, ZVISIBILITY_, ZUVINDEXVALUE_, COALESCE(ZCONDITION_,''), COALESCE(ZSYMBOLNAME_,''), COALESCE(ZUVINDEXCATEGORY_,''), COALESCE(ZWINDCOMPASSDIRECTION_,'') FROM ZHOURLYWEATHER`) if err != nil { return nil, fmt.Errorf("读取天气记录: %w", err) } for wrows.Next() { var w Weather var vis sql.NullInt64 var at sql.NullFloat64 var dl int var temp, app, hum, pa, pc, ws, vv sql.NullFloat64 var uv sql.NullInt64 if err = wrows.Scan(&w.SrcPK, &vis, &at, &dl, &temp, &app, &hum, &pa, &pc, &ws, &vv, &uv, &w.Condition, &w.Symbol, &w.UVCategory, &w.WindDir); err != nil { wrows.Close() return nil, err } if vis.Valid { pk := int(vis.Int64) w.VisitSrc = &pk } w.At = appleTime(at) w.IsDaylight = dl == 1 w.TempC = fptr(temp) w.ApparentC = fptr(app) w.Humidity = fptr(hum) w.PrecipAmt = fptr(pa) w.PrecipPct = fptr(pc) w.WindSpeed = fptr(ws) w.Visibility = fptr(vv) if uv.Valid { n := int(uv.Int64) w.UVIndex = &n } b.Weather = append(b.Weather, w) } wrows.Close() if err := db.QueryRow(`SELECT count(*) FROM ZRAWVISIT`).Scan(&b.RawVisitNum); err != nil { b.RawVisitNum = 0 } // 抓取 Core Data 结构元信息(实体编号 / 建表 DDL / 关联表),作为结构快照留档。 // 失败不致命:旧备份可能没有这些表。 if meta, merr := captureMeta(db); merr == nil { b.Meta = meta } else { log.Printf("抓取 Core Data 元信息失败: %v", merr) } // 整行留档:导出时逐列还原,不丢任何 rond 自己写的字段。 if raw, rerr := captureRawRows(db); rerr == nil { b.RawRows = raw } else { log.Printf("抓取原始行留档失败(导出将退回基线补列): %v", rerr) } // 对齐键:导出不依赖它,但「两份数据比对」全靠它,缺了就只能放弃逐条对齐 if b.VisitUUID, err = uuidColumn(db, "ZVISIT", "ZIDENTIFIER_"); err != nil { return nil, fmt.Errorf("读取到访 UUID: %w", err) } if b.ActivityUUID, err = uuidColumn(db, "ZACTIVITY", "ZUID_"); err != nil { return nil, fmt.Errorf("读取活动 UUID: %w", err) } return b, nil } // uuidColumn 读出某表某一列的 UUID,返回 Z_PK -> 小写十六进制。 // 列不存在(很老的备份)返回空表而不是错误;全零或空的当作没有。 func uuidColumn(db *sql.DB, table, col string) (map[int]string, error) { rows, err := db.Query(fmt.Sprintf(`SELECT Z_PK, hex(%s) FROM %s`, col, table)) if err != nil { return nil, nil } defer rows.Close() out := map[int]string{} for rows.Next() { var pk int var h sql.NullString if err := rows.Scan(&pk, &h); err != nil { return nil, err } if h.Valid && strings.Trim(h.String, "0") != "" { out[pk] = strings.ToLower(h.String) } } return out, rows.Err() } // rawTables 是需要整行留档的 Core Data 表(站点管理的实体 + 标签分组)。 var rawTables = []string{"ZLOCATION", "ZVISIT", "ZMOVEMENT", "ZHOURLYWEATHER", "ZACTIVITY", "ZTAG", "ZTAGGROUP", "ZTRANSPORT"} // captureRawRows 读出上表每一行的全部列,供导出逐列还原。 // BLOB 编码成 {"$blob": base64}(JSON 没有字节串类型),时间戳维持 Core Data 浮点秒原值。 func captureRawRows(db *sql.DB) (map[string]map[int]map[string]any, error) { out := map[string]map[int]map[string]any{} for _, t := range rawTables { var n int if err := db.QueryRow(`SELECT count(*) FROM sqlite_master WHERE type='table' AND name=?`, t).Scan(&n); err != nil || n == 0 { continue // 旧备份可能没这张表 } cols, err := queryStrings(context.Background(), db, fmt.Sprintf("SELECT name FROM pragma_table_info('%s')", t)) if err != nil || len(cols) == 0 { continue } quoted := make([]string, len(cols)) for i, c := range cols { quoted[i] = `"` + strings.ReplaceAll(c, `"`, `""`) + `"` } rows, err := db.Query(fmt.Sprintf("SELECT %s FROM %s", strings.Join(quoted, ","), t)) if err != nil { return nil, fmt.Errorf("读取 %s 原始行: %w", t, err) } vals := make([]any, len(cols)) ptrs := make([]any, len(cols)) for i := range vals { ptrs[i] = &vals[i] } m := map[int]map[string]any{} for rows.Next() { if err := rows.Scan(ptrs...); err != nil { rows.Close() return nil, err } var pk int row := make(map[string]any, len(cols)) for i, c := range cols { if c == "Z_PK" { if v, ok := vals[i].(int64); ok { pk = int(v) } } row[c] = jsonSafe(vals[i]) } if pk > 0 { m[pk] = row } } rows.Close() if err := rows.Err(); err != nil { return nil, err } out[t] = m } return out, nil } // jsonSafe 把驱动值换成能被 JSON 无损表达的形式。 func jsonSafe(v any) any { if b, ok := v.([]byte); ok { return map[string]any{"$blob": base64.StdEncoding.EncodeToString(b)} } return v } // WriteBaseline 把备份包内的 Core Data 库另存为一份「单文件基线」,供导出时作为底稿。 // // 为什么要有基线:rond 是 Core Data 应用,库里有 Z_METADATA(模型版本哈希)、 // Z_MODELCACHE、持久化历史表(ACHANGE/ATRANSACTION*)、大量索引,以及我们并不落库的实体 // (ZRAWVISIT 原始 GPS 采样点、ZKEYWORD、ZTRIP* 等)。仅凭抓到的建表语句重建只能得到 // 一个「空壳」,Core Data 会判定与模型不兼容而拒绝打开。以基线为底、只覆盖我们管理的 // 那几张实体表,上述内容就都能原样保留。 // // 备份包里数据主要在 WAL 中,所以这里先按 Open 的方式解包并合并 WAL,再用 // VACUUM INTO 导出一份自洽的单文件副本(不依赖 -wal/-shm)。 func WriteBaseline(archivePath, dst string) error { dir, err := os.MkdirTemp("", "rond-base-") if err != nil { return err } defer os.RemoveAll(dir) db, err := Open(archivePath, dir) // Open 内部已做 wal_checkpoint(TRUNCATE) if err != nil { return err } defer db.Close() if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { return err } _ = os.Remove(dst) // VACUUM INTO 需要字面量路径(不用参数绑定),单引号按 SQL 规则转义 lit := strings.ReplaceAll(filepath.ToSlash(dst), "'", "''") if _, err := db.Exec("VACUUM INTO '" + lit + "'"); err != nil { // 退路:主库在 Open 里已合并过 WAL,直接复制也自洽 src := filepath.Join(dir, "LifeEasy.sqlite") if cerr := copyFile(src, dst); cerr != nil { return fmt.Errorf("生成基线库失败: %v(VACUUM INTO 亦失败: %w)", cerr, err) } } return nil } func copyFile(src, dst string) error { in, err := os.Open(src) if err != nil { return err } defer in.Close() out, err := os.Create(dst) if err != nil { return err } if _, err := io.Copy(out, in); err != nil { out.Close() return err } return out.Close() } // captureMeta 读取 ZPRIMARYKEY(实体编号与最大主键)与所需的 Z* 建表 DDL, // 并定位「访问↔标签」关联表及其两列。作为数据集上的 Core Data 结构快照留档。 func captureMeta(db *sql.DB) (*CoreDataMeta, error) { m := &CoreDataMeta{Entities: map[string]EntityInfo{}, DDL: map[string]string{}} rows, err := db.Query(`SELECT COALESCE(Z_ENT,0), COALESCE(Z_NAME,''), COALESCE(Z_MAX,0) FROM Z_PRIMARYKEY`) if err != nil { return nil, fmt.Errorf("读取 Z_PRIMARYKEY: %w", err) } for rows.Next() { var ent, max int var name string if err := rows.Scan(&ent, &name, &max); err != nil { rows.Close() return nil, err } if name != "" { m.Entities[name] = EntityInfo{Ent: ent, Max: max} } } rows.Close() drows, err := db.Query(`SELECT name, sql FROM sqlite_master WHERE type='table' AND name LIKE 'Z%' AND sql IS NOT NULL`) if err != nil { return nil, fmt.Errorf("读取建表 DDL: %w", err) } for drows.Next() { var name, ddl string if err := drows.Scan(&name, &ddl); err != nil { drows.Close() return nil, err } m.DDL[name] = ddl } drows.Close() // 访问↔标签关联表:表名形如 Z_VISITS_,两列分别指向 Visit / Tag 实体。 visitEnt, okV := m.Entities["Visit"] tagEnt, okT := m.Entities["Tag"] if okV && okT { for tname, ddl := range m.DDL { if !strings.HasPrefix(tname, "Z_") || !strings.HasSuffix(tname, "VISITS_") { continue } _ = ddl crows, err := db.Query(fmt.Sprintf("PRAGMA table_info(%s)", tname)) if err != nil { continue } var vcol, tcol string for crows.Next() { var cid int var cname, ctype string var notnull int var dflt sql.NullString var pk int if err := crows.Scan(&cid, &cname, &ctype, ¬null, &dflt, &pk); err != nil { crows.Close() continue } if strings.Contains(cname, fmt.Sprintf("Z_%dVISITS", visitEnt.Ent)) { vcol = cname } if strings.Contains(cname, fmt.Sprintf("Z_%dTAGS", tagEnt.Ent)) { tcol = cname } } crows.Close() if vcol != "" && tcol != "" { m.JoinTable = JoinTableInfo{Name: tname, VisitCol: vcol, TagCol: tcol} break } } } return m, nil } func fptr(v sql.NullFloat64) *float64 { if !v.Valid { return nil } f := v.Float64 return &f }