first release

This commit is contained in:
tamakyi
2026-09-18 13:05:49 +08:00
commit b327bc1230
123 changed files with 30003 additions and 0 deletions
+675
View File
@@ -0,0 +1,675 @@
// 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
}