Files
rond-with-you/internal/rond/parse.go
T
2026-09-18 13:05:49 +08:00

676 lines
19 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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_<tagEnt>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
}
// 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)
}
return b, nil
}
// 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_<tagEnt>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, &notnull, &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
}