diff --git a/.gitattributes b/.gitattributes index 8d9b95a..ad213f9 100644 --- a/.gitattributes +++ b/.gitattributes @@ -5,9 +5,10 @@ *.yml text eol=lf *.yaml text eol=lf *.sh text eol=lf +*.lua text eol=lf +*.jsonl text eol=lf *.xmp text eol=lf *.gpx text eol=lf *.png binary *.jpg binary *.jpeg binary - diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ed3f6e6..1ebdbee 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,6 +31,12 @@ jobs: xcodebuild -version swift --version + - name: Install Lightroom-compatible Lua test runtime + run: | + brew install micromamba + micromamba create -y -p "$RUNNER_TEMP/rawgeosync-lua51" -c conda-forge lua=5.1 + echo "RAWGEOSYNC_LUA=$RUNNER_TEMP/rawgeosync-lua51/bin/lua" >> "$GITHUB_ENV" + - name: Full quality gate run: ./Scripts/ci.sh diff --git a/.gitignore b/.gitignore index 3cda5ef..f8c691e 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,11 @@ xcuserdata/ *.log *.env +# Generated location bridge manifests contain private paths and coordinates. +RawGeoSync.locations.jsonl +!**/Tests/Fixtures/RawGeoSync.locations.jsonl +!**/Tests/Fixtures/**/RawGeoSync.locations.jsonl + # User photographs and tracks never belong in source control. *.nef *.NEF @@ -33,4 +38,3 @@ xcuserdata/ # Synthetic fixtures are explicitly allowed. !**/Tests/Fixtures/*.gpx !**/Tests/Fixtures/**/*.gpx - diff --git a/CHANGELOG.md b/CHANGELOG.md index 39d0d36..3cf5f49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,28 @@ 本文件记录面向用户和贡献者的重要变化。版本遵循语义化版本的意图;在 1.0.0 前,MVP 的行为和界面仍可能调整。 +## [0.3.0] - 2026-08-12 + +### 新增 + +- 新增默认的 Lightroom Classic Catalog Bridge:每个照片根目录只生成一份可校验的位置清单,由纯 Lua 插件批量写入 Catalog。 +- Lightroom 插件提供导入预检、精确路径和身份核验、批量复读、离线跳过、来源标记与跨重启整批撤销。 +- App 提供插件一键安装/更新,并继续提供传统 XMP Sidecar 兼容模式。 + +### 性能 + +- 桥接导出不再执行逐 RAW 完整摘要、逐 XMP ExifTool 复读或逐照片事务清单重写,千张和万张任务按 O(N) 流式处理。 + +### 隐私 + +- 位置清单只保存照片根目录相对路径,不保存绝对路径、GPX 路径或完整轨迹;插件无网络且不在照片目录写日志。 + +### 修复 + +- GPX 输入现在同时支持通过文件选择器选择、或直接拖入单个 `.gpx` 文件;原有的 GPX 目录递归读取仍然保留。 +- 修复 Lightroom 真机环境中普通 `pcall/xpcall` 阻止任务让出,以及目录写锁竞争未等待导致批量导入失败的问题。 +- 撤销收据新增 Finder 管理入口;插件不会在未确认的情况下自动删除敏感恢复数据。 + ## [0.2.0] - 2026-08-10 ### 新增 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3684306..e3502be 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,13 +1,13 @@ # 参与贡献 -感谢你关注 RawGeoSync。项目目前以 macOS 本机离线处理和个人照片工作流为目标,外部贡献应先确认不会改变 RAW 只读、XMP sidecar 输出和隐私边界。 +感谢你关注 RawGeoSync。项目目前以 macOS 本机离线处理和个人照片工作流为目标,外部贡献应先确认不会改变 RAW 只读、Lightroom Catalog Bridge、XMP 兼容输出和隐私边界。 ## 开发环境 - macOS 15 或更高版本 - Xcode 26.3 或兼容的 Swift 6 工具链 - 系统 Perl 仅用于运行随项目锁定的 ExifTool 13.59 -- Swift 依赖只使用本地 Swift Package,不提交 Homebrew 或 Conda 环境 +- Swift 依赖只使用本地 Swift Package;Lua 测试环境放在被忽略的项目 `.local/` conda 环境,不修改 base 首次开发前,确认 `xcode-select -p` 指向完整 Xcode,而不是只安装 Command Line Tools。 @@ -36,8 +36,9 @@ RawGeoSync 不上传照片、轨迹或坐标。新增网络请求、遥测、反 ## 元数据边界 - RAW 文件永远不能作为写入目标。 -- GPS 写入只能通过同名 XMP sidecar 完成。 -- 不要修改 `DateTimeOriginal`,也不要未经用户确认覆盖已有 GPS。 +- 默认 GPS 写入通过版本化清单和 Lightroom 插件完成;兼容模式才写同名 XMP sidecar。 +- 不要修改 `DateTimeOriginal`。Catalog Bridge 按用户已锁定策略以本次清单覆盖不同 GPS,但必须提供预览和可恢复的整批撤销。 +- 禁止 basename 模糊匹配、直接访问 `.lrcat` SQLite、在插件中启动网络/shell/ExifTool/Python,或把绝对照片路径写入清单。 - 更新 ExifTool 时必须同步版本清单、归档 SHA-256、上游许可证说明和真实契约测试。 ## Pull Request 检查清单 diff --git a/Docs/Decisions/0003-lightroom-catalog-bridge.md b/Docs/Decisions/0003-lightroom-catalog-bridge.md new file mode 100644 index 0000000..7c5d2d6 --- /dev/null +++ b/Docs/Decisions/0003-lightroom-catalog-bridge.md @@ -0,0 +1,52 @@ +# ADR 0003:以 Lightroom Catalog Bridge 作为默认输出 + +- 状态:已接受 +- 日期:2026-08-12 +- 取代范围:补充 ADR 0001;不移除其 XMP 安全边界 + +## 背景 + +逐照片 XMP 在大批量任务中同时带来两个问题:照片目录文件数量翻倍,以及写入前后对每张 RAW、XMP 和事务清单进行校验所造成的高延迟。现有 XMP 实现仍适合作为与 Lightroom Catalog 无关的兼容输出,但不适合作为数千张照片的默认路径。 + +Lightroom Classic SDK 允许插件在 Catalog 写事务内更新原生 GPS 和海拔,而无需改写 RAW。用户已决定默认使用 Catalog Bridge、保留 XMP 兼容模式;桥接清单只包含在 RawGeoSync 中明确勾选且具有最终坐标的照片。 + +## 决策 + +RawGeoSync 默认在用户选择的照片根目录生成唯一的 `RawGeoSync.locations.jsonl`。清单使用 UTF-8 JSON Lines,由 header、按相对路径稳定排序的 asset records 和 trailer 组成,并使用 SHA-256 校验记录与整体 payload。 + +清单只保存相对路径和完成目录身份核验所需的最少信息;禁止绝对路径、basename 模糊匹配、路径大小写归一化和完整 RAW SHA。写入通过同目录临时文件和原子替换完成;相同语义重复导出不改动文件。 + +配套的纯 Lua Lightroom Classic 插件负责: + +- 流式验证清单并按 `manifest parent + relativePath` 精确查找 Catalog 照片; +- 在写入前展示可写、相同、覆盖、离线、缺失和身份冲突数量; +- 默认处理清单全部记录,也允许限制为 Lightroom 当前选择; +- 按用户选择,以新清单覆盖 Catalog 中不同的 GPS; +- 跳过离线或身份无法核验的照片; +- 写入原生 GPS、可选海拔和不含明文坐标的来源 token; +- 批量复读验证,并提供跨 Lightroom 重启仍可用的安全整批撤销。 + +插件不得解析 GPX、运行 ExifTool/Python/shell、启动网络服务、直接访问 `.lrcat` SQLite,或在照片目录写日志和撤销记录。 + +现有 XMP 输出继续存在,但桥接实现不得复用 XMP transaction 的逐 RAW 摘要、逐 sidecar 备份/复读和逐条事务清单保存循环。桥接结果只能称为“清单已生成”;只有插件完成 Catalog 复读后才能称为“GPS 已应用并验证”。 + +## 数据与隐私 + +清单包含精确位置,权限设为仅当前用户,Git 默认忽略。它不包含绝对照片路径、GPX 路径或完整轨迹。插件日志默认只记录匿名标识、数量和错误类型。 + +撤销收据位于用户的 Lightroom Application Support 范围,不进入照片目录。收据保存恢复所需的原 GPS/海拔和摘要,因此同样视为敏感数据;清理必须由用户显式执行。 + +## 兼容边界 + +- 首发支持 macOS 15 及 Lightroom Classic 15.4.1 或更高版本。 +- 不支持云端版 Lightroom。 +- 插件无法通过公开 SDK 可靠读取 Lightroom 的“自动写入 XMP”设置,因此始终提示:启用该选项时,Lightroom 自身仍可能创建 sidecar。 +- 已存在的 XMP 不自动删除或迁移。 +- 清单没有海拔时保留 Catalog 现有海拔;只有清单明确提供海拔才更新。 + +## 验收门禁 + +- Auto XMP 关闭时,大批量任务在照片根目录只增加一份清单,RAW 内容与 mtime 不变。 +- 清单导出、插件预检、写入和复读的复杂度为 O(N),不读取完整 RAW 内容。 +- 取消或意外失败最终全成或全退;显式撤销不覆盖导入后被用户再次修改的坐标。 +- 同名跨目录照片、Unicode 路径、活动目录整体移动、离线照片和损坏清单均有自动或真机测试。 diff --git a/Docs/LIGHTROOM_BRIDGE.md b/Docs/LIGHTROOM_BRIDGE.md new file mode 100644 index 0000000..9f1158a --- /dev/null +++ b/Docs/LIGHTROOM_BRIDGE.md @@ -0,0 +1,69 @@ +# Lightroom Catalog Bridge 使用指南 + +Catalog Bridge 是 RawGeoSync 的默认输出方式。它让一个照片根目录只增加一份 +`RawGeoSync.locations.jsonl`,再由 Lightroom Classic 插件把其中已确认的位置批量写入当前目录。 +RAW 文件始终只读;插件也不会直接修改 `.lrcat` 数据库文件。 + +## 第一次使用 + +1. 启动 RawGeoSync,保留默认输出“Lightroom Classic 单清单”。 +2. 选择或拖入一个 `.gpx` 文件,也可以选择包含多个 GPX 的目录。 +3. 选择照片活动根目录。建议选择同时包含 `Z50`、`Z5` 等相机子目录的活动目录,而不是更高层的整个照片库。 +4. 完成分析,复核黄色和粗略候选,并勾选本次要交给 Lightroom 的照片。 +5. 点击生成清单。照片根目录只会创建或原子更新一个 `RawGeoSync.locations.jsonl`。 +6. 在结果页点击“安装/更新 Lightroom 插件”。如果 Lightroom 正在运行,安装后重启一次 Lightroom。 +7. 先把对应 RAW 导入 Lightroom Classic,再选择菜单“图库 → 插件增效工具 → RawGeoSync:导入位置清单…”。 +8. 选择刚生成的清单,检查预检数量后开始导入。插件默认处理清单全部照片;需要时可限制为 Lightroom 当前选择。 + +插件按清单父目录和照片相对路径精确查找,不使用文件名模糊匹配。已有不同 GPS 会在预检中明确计数,并由本次已确认清单覆盖;相同坐标跳过,原文件离线、路径缺失或字节数不一致的照片安全跳过。 + +## 撤销与恢复 + +一次成功导入会形成 Lightroom 原生撤销记录,同时把恢复所需的敏感事务收据保存在 Lightroom 的 Application Support 目录,而不是照片目录。 + +- 刚导入后可以使用 Lightroom 的“撤销”;大批任务按 200 张提交,系统撤销可能按批次出现,插件菜单才是整批恢复入口。 +- Lightroom 重启后,使用“图库 → 插件增效工具 → RawGeoSync:撤销最近一次导入…”。 +- 如果照片的 GPS 在导入后又被用户或其他插件修改,持久撤销会跳过该照片,不覆盖较新的工作。 +- 导入中取消、写入失败或复读不一致时,插件自动恢复此前已经提交的批次;若自动恢复受到外部并发修改阻止,收据仍可用于安全补救。 + +事务收据包含原 GPS 和恢复信息,应和 Catalog 备份一样视为敏感本地数据。不要上传或纳入 Git。 + +不再需要历史撤销时,可运行“图库 → 插件增效工具 → RawGeoSync:打开撤销收据文件夹…”,在 Finder 中手工清理旧 `.jsonl` 收据。删除收据后无法再用插件恢复对应批次,因此插件不会自动清理。 + +## 关于 XMP + +插件只调用 Lightroom SDK 写入 Catalog。若 Lightroom 偏好设置中启用了“自动将更改写入 XMP”,Lightroom 自身仍可能生成 sidecar;公开 SDK 无法可靠读取或关闭这个选项。希望照片目录始终只有单一清单时,请先在 Lightroom 的“目录设置 → 元数据”中关闭自动写入 XMP。 + +已有 XMP 不会被自动删除。需要绕过 Lightroom Catalog、与其他软件交换元数据时,可以在 RawGeoSync 输出选项中切回“XMP Sidecar(兼容模式)”。 + +## 日常启动与更新 + +日常可从 Finder、Spotlight 或启动台打开 `RawGeoSync.app`,也可以运行: + +```sh +open "$HOME/Applications/RawGeoSync.app" +``` + +源码更新后,在项目目录运行 `./Scripts/build-release.sh` 会重新构建并安全替换本机应用。插件有更新时,打开应用后再次点击“安装/更新 Lightroom 插件”,然后重启 Lightroom。 + +## 常见问题 + +### 清单中的照片显示“不在当前目录” + +先确认 RAW 已导入当前 Lightroom Catalog。若移动了整个活动目录,先在 Lightroom 使用“查找丢失的文件夹”或“更新文件夹位置”指向新路径,并让清单随目录一起移动;若单独改了 RAW 文件名或目录层级,请回到 RawGeoSync 重新分析并生成清单。 + +### 导入后地图或元数据面板没有立即刷新 + +插件会直接复读 Catalog 元数据验证写入。若结果页显示验证成功但界面仍旧,切换照片或重启 Lightroom 后再看;界面缓存不能替代插件复读结果。 + +### 为什么离线照片不写入 + +离线状态下只能依赖 Catalog 路径和智能预览,无法核验磁盘文件身份。为避免把坐标写给错误照片,当前版本默认跳过,待原文件联机后重新导入清单即可。 + +### 清单是否可以分享 + +不建议。清单包含精确坐标、照片相对路径、拍摄时间、相机型号、可能的机身/内部序列号、快门数、匹配证据摘要及轨迹摘要。权限仅授予当前用户读取;它不上传网络,但仍应按敏感位置与设备身份数据保护。 + +### 不通过 App,怎样安装独立插件 ZIP + +解压 `RawGeoSync-Lightroom-Bridge-.zip`,在 Lightroom 的“文件 → 增效工具管理器…”中点击“添加”,选择解压后的 `RawGeoSync.lrplugin`。确认状态为已安装且版本为 0.3.x 后重启 Lightroom。不要把 Lightroom 指向 ZIP 文件本身。 diff --git a/Docs/PRIVACY.md b/Docs/PRIVACY.md index 848e136..85676be 100644 --- a/Docs/PRIVACY.md +++ b/Docs/PRIVACY.md @@ -9,11 +9,16 @@ RawGeoSync 处理照片、拍摄时间和精确位置,这些数据可以还原 | RAW 与照片 | 用户选择的目录 | 读取拍摄时间和已有元数据 | 不复制、不修改 | | GPX | 用户选择的文件或目录 | 建立时间到位置的候选 | 不保存完整轨迹副本 | | XMP sidecar | 照片同目录 | 预检、合并和写入 GPS | 用户确认后创建或更新 | +| 位置桥接清单 | 照片根目录 | 把已批准的位置交给 Lightroom 插件 | 默认保留一份,后续导出原子替换 | +| Lightroom Catalog | 用户当前打开的目录 | 保存原生 GPS、海拔和来源 token | 由 Lightroom 管理 | +| 插件撤销收据 | Lightroom Application Support | 跨重启整批恢复 Catalog GPS | 通过“打开撤销收据文件夹…”由用户显式清理 | | 匹配证据 | 内存 | 解释候选与冲突 | 未写入的分析默认不保存;已写入项在事务清单保留必要证据 | | 原 XMP 备份 | Application Support | 撤销和崩溃恢复 | 默认最近 10 批且不超过 30 天 | | 偏好 | UserDefaults | 时区、相机偏移等设置 | 不包含完整轨迹或照片内容 | -RAW 始终只读。分析和 dry-run 不得在 GPX 或照片源目录创建临时文件、缓存、XMP、索引或隐藏文件。写入阶段只允许修改同名 XMP sidecar,并必须经过不可变计划、原子替换和复读验证。 +RAW 始终只读。分析和 dry-run 不得在 GPX 或照片源目录创建临时文件、缓存、XMP、索引或隐藏文件。默认导出阶段只允许原子创建或替换一份 `RawGeoSync.locations.jsonl`;兼容模式才允许修改同名 XMP sidecar。 + +位置清单包含精确坐标和相对照片路径,属于敏感本地数据。它不保存绝对路径、GPX 路径或完整轨迹,文件权限仅限当前用户。Lightroom 插件不联网、不回写清单,也不在照片目录创建日志或撤销记录。 事务清单为完成撤销、幂等和来源审计,可能包含本地文件引用、GPS、指纹和已选证据;它与 XMP 备份同属敏感本地数据,受相同保留和清理策略约束,不得作为普通诊断日志分享。 @@ -40,4 +45,4 @@ RAW 始终只读。分析和 dry-run 不得在 GPX 或照片源目录创建临 ## 删除、撤销与分享 -用户可以撤销仍未被外部程序修改的事务,并可删除 Application Support 中的历史备份。清理备份不会删除原始照片或用户主动保留的 XMP。若怀疑真实位置或照片被误提交,应立即停止分享、从当前分支移除数据并按安全策略报告;仅新增 `.gitignore` 不能清除既有 Git 历史。 +用户可以撤销仍未被外部程序修改的 XMP 或 Lightroom Catalog 事务,并可在相应界面删除 Application Support 中的历史备份。清理备份不会删除原始照片、位置清单或用户主动保留的 XMP。若怀疑真实位置或照片被误提交,应立即停止分享、从当前分支移除数据并按安全策略报告;仅新增 `.gitignore` 不能清除既有 Git 历史。 diff --git a/Docs/RELEASE.md b/Docs/RELEASE.md index 1a42db7..b548ce6 100644 --- a/Docs/RELEASE.md +++ b/Docs/RELEASE.md @@ -42,14 +42,32 @@ Mac App Store 不是当前 MVP 目标。若未来进入 Mac App Store,需要重新设计 App Sandbox、用户选定目录权限、ExifTool helper 和崩溃恢复流程。 +## v0.3 Catalog Bridge 门禁 + +1. 确认 [ADR 0003](Decisions/0003-lightroom-catalog-bridge.md) 与 JSONL schema、App 默认输出和插件实现一致。 +2. Swift 与 Lua 共享 golden fixtures 全部通过,仓库策略已扫描 `.lua`,真实生成清单保持 Git ignored。 +3. 在独立 Lightroom Catalog 上完成 20 张完整功能样本和 100 张五批次真机样本;完成 1000、5000、10000 条合成复杂度验收。不得使用正式 Catalog 或照片原件。 +4. Auto XMP 关闭时证明 RAW 目录只增加一份清单,RAW 内容和 mtime 不变;开启时验证警告与 Lightroom 实际行为一致。 +5. 验证原生 Undo、跨重启插件撤销、取消自动回滚、后续编辑冲突保护和重复导入 no-op。 +6. 确认插件 ZIP 与 App 内置插件的版本、schema major/minor 和 SHA-256 一致。 +7. 发布物同时包含 App ZIP、`RawGeoSync-Lightroom-Bridge-.zip`、校验文件和安装/日常使用说明。 + +本机构建上述三份发布物: + +```sh +./Scripts/package-release.sh 0.3.0 +``` + +输出位于被 Git 忽略的 `.local/release/v0.3.0/`。脚本会核对 App 版本、内置插件与独立插件逐文件一致,再生成 `SHA256SUMS.txt`。 + ## 发布前数据安全验收 - 分析阶段不产生照片目录写入; -- 写入只创建或更新同名 XMP,NEF SHA-256 不变; -- 已有不同 GPS 默认跳过; +- 默认桥接只创建或原子替换单一位置清单;兼容模式才创建或更新同名 XMP,RAW SHA-256 不变; +- Catalog 中已有不同 GPS 会在预览明确计数并按本次清单覆盖;插件撤销收据必须先成功持久化; - 重复运行识别为 already-applied 且不改变 XMP mtime; - 取消、单项失败和崩溃不会留下半写 XMP; -- 撤销遇到后续 Lightroom 修改时必须拒绝覆盖; +- XMP 和 Catalog 撤销遇到后续 Lightroom 修改时必须拒绝覆盖; - 强候选冲突、传播循环和跨活动区候选不能自动写入; - 源无 hacc 时界面和报告都显示 unknown,不生成伪精度; - 全量 dry-run 的输入目录前后 SHA-256 清单完全一致。 diff --git a/Docs/TESTING.md b/Docs/TESTING.md index 2815cb4..6d56444 100644 --- a/Docs/TESTING.md +++ b/Docs/TESTING.md @@ -7,6 +7,8 @@ 3. 应用构建与静态分析验证 Swift 6 严格并发和模块集成。 4. 参数化真实数据回归只执行 dry-run,用于发现记录器行为、相机元数据和全量性能问题。 5. 写入验收只对 `.local/` 中的最小副本执行,不接触原始目录。 +6. Catalog Bridge 共享 golden fixtures:Swift 生成的清单必须由 Lua 读取并得到相同摘要和记录。 +7. Lightroom 真机验收使用 `.local/` 内照片副本和独立 Catalog,不打开用户正式 Catalog。 测试数量会随功能演进,不在文档中锁定。门禁以命令退出状态、行为断言和不变量为准。 @@ -92,3 +94,26 @@ CLI 运行时间与输入哈希时间分开记录。`real-data-regression.sh` ## 副本写入验收 从真实数据中选择最小集合,复制到 `.local/write-regression/<随机目录>/` 后执行:首次预检、应用、复读、第二次预检、撤销。必须证明 RAW SHA-256 始终不变,第二次预检为 already-applied 且 XMP mtime 不变,撤销不会覆盖之后被 Lightroom 修改的 sidecar。原始目录的前后快照也必须保持一致。 + +## Lightroom Catalog Bridge 验收 + +Swift 清单测试必须覆盖:空/单条/千条/万条、Unicode 与大小写敏感路径、路径穿越、重复记录、坐标越界、非有限数字、未知 schema、截断、记录和 payload 摘要损坏、原子替换失败、损坏旧目标保护、重复导出 no-op,以及桥接路径不读取完整 RAW 内容或调用 XMP writer。 + +Lua 纯模块测试必须以 Lightroom SDK 支持的 Lua 语义运行,覆盖:流式解析、SHA-256、精确相对路径、身份核验、已有相同/不同 GPS、离线、Catalog 未找到、导入范围、来源 token、重复导入、取消、故障回滚、写后复读、跨重启撤销和导入后再次修改的冲突保护。 + +本地 `Scripts/test-lua.sh` 优先使用项目内 Conda Lua 5.1 环境;创建命令为 `conda create -y -p .local/conda-lua51 -c conda-forge lua=5.1`。Lua 5.4 只可作为额外前向兼容检查,不能替代与 Lightroom SDK Lua 语义相近的 5.1 门禁。 + +在当前 Lightroom Classic 15.4.1 上用独立测试 Catalog 验证: + +- Auto XMP 关闭时,RAW 目录除了单一清单不新增 sidecar,RAW SHA-256 和 mtime 不变; +- Auto XMP 开启时,准确提示 Lightroom 自身可能生成 sidecar; +- `getRawMetadata("gps")` 和海拔与清单一致;元数据面板缓存不能替代目录复读; +- 同名跨目录照片、活动目录整体移动、离线照片、虚拟副本和损坏清单均不会误写; +- 一次导入可原生 Undo,也可在 Lightroom 重启后使用插件收据整批撤销; +- 撤销不覆盖导入后被用户修改的 GPS。 + +v0.3.0 真机基线(2026-08-12,本机 macOS 15.7.7 / Lightroom Classic 15.4.1):20 张完整功能样本首次写入与复读 20/20、重复导入 20/20 判定为已有相同坐标、冲突覆盖 20/20、跨重启收据撤销 20/20;另以 100 张、20 张每批的测试构造验证 5 个连续 Catalog 写入/复读批次,预检约 6 秒,写入与复读约 9.5 秒。两组测试均保持 RAW SHA-256、mtime 与目录 sidecar 数量不变。生产批次恢复为 200 张;20/100 张只是隔离真机功能基线,不替代 1000/10000 条合成复杂度门禁。 + +10,000 条 Lua 流式清单基线:5.20 MB,解析 1.157 秒,Lua 保留内存增量 11.8 MiB;进程 peak memory footprint 83.0 MiB(max RSS 113.7 MiB,含夹具生成)。Swift 的 10,000 条真实路径 `export` 自动门禁包含逐路径文件身份核验:受控本机工程目标为 3 秒。共享 GitHub runner 仍实际执行并验证全部 10,000 条记录及“不读取 RAW 内容”等不变量,但因文件系统调度波动不以绝对 wall-clock 判定成败。测试总时长约 6.5 秒还包含创建及清理 10,000 个硬链接。 + +桥接性能门禁:10000 条清单导出受控本机工程目标不超过 3 秒;共享 GitHub runner 只执行规模、不变量和正确性门禁,不以绝对 wall-clock 失败。预检不超过 15 秒;1000 条应用并验证不超过 15 秒,10000 条不超过 90 秒;20000 条总耗时不得超过 10000 条的 2.5 倍;Lua 解析保留内存目标低于 100 MB;取消响应不超过 250 ms 或 200 条处理周期。若 Lightroom API 的绝对时限在隔离 POC 中证明不可达,必须记录实测基线和原因,但 O(N)、不读完整 RAW、单清单和可取消仍是不可放宽的发布门禁。 diff --git a/LightroomPlugin/RawGeoSync.lrplugin/CatalogAdapter.lua b/LightroomPlugin/RawGeoSync.lrplugin/CatalogAdapter.lua new file mode 100644 index 0000000..15c7f29 --- /dev/null +++ b/LightroomPlugin/RawGeoSync.lrplugin/CatalogAdapter.lua @@ -0,0 +1,89 @@ +local LrFileUtils = import "LrFileUtils" +local LrPathUtils = import "LrPathUtils" + +local PathPolicy = require "PathPolicy" + +local CatalogAdapter = {} + +function CatalogAdapter.new(catalog, root) + local pathAdapter = { + child = function(parent, child) return LrPathUtils.child(parent, child) end, + standardize = function(path) return LrPathUtils.standardizePath(path) end, + } + local adapter + adapter = { + resolvedByRecord = {}, + resolvedByPhoto = {}, + resolveExact = function(record) + local cached = adapter.resolvedByRecord[record] + if cached then return cached.result, cached.error end + local absolutePath, pathError = PathPolicy.resolve(root, record.relativePath, pathAdapter) + if not absolutePath then + adapter.resolvedByRecord[record] = { error = pathError } + return nil, pathError + end + local photo = catalog:findPhotoByPath(absolutePath) + if not photo then + local missingError = "精确路径不在当前 Lightroom 目录中" + adapter.resolvedByRecord[record] = { error = missingError } + return nil, missingError + end + if LrFileUtils.exists(absolutePath) ~= "file" then + local offline = { + photo = photo, + path = absolutePath, + available = false, + } + adapter.resolvedByRecord[record] = { result = offline } + adapter.resolvedByPhoto[photo] = adapter.resolvedByPhoto[photo] or {} + adapter.resolvedByPhoto[photo][#adapter.resolvedByPhoto[photo] + 1] = offline + return offline + end + local attributes = LrFileUtils.fileAttributes(absolutePath) + local result = { + photo = photo, + path = absolutePath, + available = true, + byteCount = attributes and attributes.fileSize or nil, + } + adapter.resolvedByRecord[record] = { result = result } + adapter.resolvedByPhoto[photo] = adapter.resolvedByPhoto[photo] or {} + adapter.resolvedByPhoto[photo][#adapter.resolvedByPhoto[photo] + 1] = result + return result + end, + } + return adapter +end + +function CatalogAdapter.prefetch(adapter, catalog, records, plugin, metadataFields, batchSize) + batchSize = batchSize or 500 + local photos = {} + local seen = {} + for _, record in ipairs(records) do + local resolved = adapter.resolveExact(record) + if resolved and resolved.available ~= false and not seen[resolved.photo] then + seen[resolved.photo] = true + photos[#photos + 1] = resolved.photo + end + end + + for startIndex = 1, #photos, batchSize do + local batch = {} + local endIndex = math.min(startIndex + batchSize - 1, #photos) + for index = startIndex, endIndex do batch[#batch + 1] = photos[index] end + local rawByPhoto = catalog:batchGetRawMetadata(batch, { "gps", "gpsAltitude", "uuid" }) + local propertiesByPhoto = catalog:batchGetPropertyForPlugin(batch, plugin, metadataFields) + for _, photo in ipairs(batch) do + local resolvedRaw = rawByPhoto[photo] + if resolvedRaw then + for _, resolved in ipairs(adapter.resolvedByPhoto[photo] or {}) do + resolved.rawMetadata = resolvedRaw + resolved.uuid = resolvedRaw.uuid + resolved.pluginMetadata = propertiesByPhoto[photo] or {} + end + end + end + end +end + +return CatalogAdapter diff --git a/LightroomPlugin/RawGeoSync.lrplugin/CatalogWriter.lua b/LightroomPlugin/RawGeoSync.lrplugin/CatalogWriter.lua new file mode 100644 index 0000000..88b00ea --- /dev/null +++ b/LightroomPlugin/RawGeoSync.lrplugin/CatalogWriter.lua @@ -0,0 +1,283 @@ +local Geo = require "Geo" +local Json = require "JSON" +local Receipt = require "Receipt" + +local CatalogWriter = {} + +local function nullableLocation(location) + if location == nil then return Json.null end + return { + latitude = location.latitude, + longitude = location.longitude, + altitude = Receipt.nullable(location.altitude), + } +end + +local function snapshotProperties(properties, fields) + local result = {} + for _, field in ipairs(fields) do result[field] = Receipt.nullable(properties[field]) end + return result +end + +local function desiredProperties(plan, item, options) + return { + sourceToken = item.sourceToken, + manifestID = plan.manifestID, + activityID = plan.activityID, + revision = tostring(plan.revision), + recordID = item.record.recordID, + source = item.record.decision.method, + quality = item.record.decision.confidence, + verification = item.record.decision.verification, + granularity = item.record.decision.granularity, + appliedAtUTC = options.appliedAtUTC, + } +end + +local function receiptItem(plan, item, properties, fields) + return { + uuid = item.uuid, + recordID = item.record.recordID, + sourceToken = item.sourceToken, + before = { + location = nullableLocation(item.before), + altitude = Receipt.nullable(item.beforeAltitude), + properties = snapshotProperties(item.beforePluginMetadata, fields), + }, + after = { + location = nullableLocation(item.desired), + altitude = Receipt.nullable(item.desired.altitude), + properties = properties, + }, + } +end + +local function setLocation(photo, location, altitude) + if location == nil then + photo:setRawMetadata("gps", nil) + else + photo:setRawMetadata("gps", { + latitude = location.latitude, + longitude = location.longitude, + }) + end + photo:setRawMetadata("gpsAltitude", altitude) +end + +local function verifyBatch(catalog, items, startIndex, endIndex, pluginId) + local photos = {} + for index = startIndex, endIndex do photos[#photos + 1] = items[index].photo end + local rawByPhoto = catalog:batchGetRawMetadata(photos, { "gps", "gpsAltitude" }) + local propertiesByPhoto = catalog:batchGetPropertyForPlugin(photos, pluginId, { "sourceToken" }) + local errors = {} + for index = startIndex, endIndex do + local item = items[index] + local raw = rawByPhoto[item.photo] or {} + local properties = propertiesByPhoto[item.photo] or {} + if not Geo.same(Geo.fromRaw(raw.gps, raw.gpsAltitude), item.desired) then + errors[item] = "GPS 复读结果与写入值不一致" + elseif properties.sourceToken ~= item.sourceToken then + errors[item] = "来源令牌复读失败" + end + end + return errors +end + +function CatalogWriter.apply(plan, options) + local catalog = assert(options.catalog) + local pluginId = assert(options.pluginId) + local receiptPath = assert(options.receiptPath) + local receiptAdapter = assert(options.receiptAdapter) + local fields = assert(options.metadataFields) + local progress = assert(options.progress) + local batchSize = options.batchSize or 200 + local transactionID = assert(options.transactionID) + local protectedCall = options.protectedCall or pcall + local result = { + applied = 0, + committed = 0, + verified = 0, + failed = 0, + rolledBack = 0, + canceled = false, + } + local committedItems = {} + + local function automaticRollback() + if #committedItems == 0 then return true end + local rollbackStart = 1 + local rollbackBatch = 0 + while rollbackStart <= #committedItems do + rollbackBatch = rollbackBatch + 1 + local rollbackEnd = math.min(rollbackStart + batchSize - 1, #committedItems) + local rollbackBatchID = string.format("auto-rollback-%s-%06d", transactionID, rollbackBatch) + local restoredThisBatch = 0 + local ok, rollbackError = protectedCall(function() + local executed = false + catalog:withWriteAccessDo("RawGeoSync:自动恢复导入前状态", function() + executed = true + for index = rollbackStart, rollbackEnd do + local item = committedItems[index] + if item.photo:getPropertyForPlugin(pluginId, "sourceToken") == item.sourceToken + and Geo.same(Geo.read(item.photo), item.desired) then + setLocation(item.photo, item.before, item.beforeAltitude) + for _, field in ipairs(fields) do + item.photo:setPropertyForPlugin( + pluginId, + field, + item.beforePluginMetadata[field] + ) + end + restoredThisBatch = restoredThisBatch + 1 + end + end + end, { timeout = options.writeAccessTimeoutSeconds or 30 }) + if not executed then error("等待 Lightroom 自动恢复写锁超时", 0) end + end) + if not ok then + result.rollbackError = tostring(rollbackError) + return nil, rollbackError + end + result.rolledBack = result.rolledBack + restoredThisBatch + Receipt.append(receiptPath, { + kind = "automaticRollbackBatchCommitted", + transactionID = transactionID, + batchID = rollbackBatchID, + restored = restoredThisBatch, + }, receiptAdapter) + rollbackStart = rollbackEnd + 1 + end + result.applied = result.committed - result.rolledBack + if result.applied == 0 then result.verified = 0 end + return result.applied == 0 + end + + Receipt.append(receiptPath, { + kind = "transactionPrepared", + transactionID = transactionID, + catalogToken = options.catalogToken, + manifestID = plan.manifestID, + activityID = plan.activityID, + revision = plan.revision, + createdAtUTC = options.appliedAtUTC, + }, receiptAdapter) + if options.onReceiptPrepared then options.onReceiptPrepared(receiptPath) end + + local startIndex = 1 + local batchNumber = 0 + while startIndex <= #plan.writable do + if progress:isCanceled() then result.canceled = true break end + batchNumber = batchNumber + 1 + local endIndex = math.min(startIndex + batchSize - 1, #plan.writable) + local batchID = string.format("%s-%06d", transactionID, batchNumber) + local preparedItems = Json.array() + local desiredByItem = {} + for index = startIndex, endIndex do + local item = plan.writable[index] + local properties = desiredProperties(plan, item, options) + desiredByItem[item] = properties + preparedItems[#preparedItems + 1] = receiptItem(plan, item, properties, fields) + end + Receipt.append(receiptPath, { + kind = "batchPrepared", + transactionID = transactionID, + batchID = batchID, + items = preparedItems, + }, receiptAdapter) + + local ok, writeError = protectedCall(function() + local executed = false + catalog:withWriteAccessDo(options.actionName or "RawGeoSync:写入 GPS", function() + executed = true + for index = startIndex, endIndex do + if progress:isCanceled() then error("__RAWGEOSYNC_CANCEL__", 0) end + local item = plan.writable[index] + setLocation(item.photo, item.desired, item.desired.altitude) + for _, field in ipairs(fields) do + item.photo:setPropertyForPlugin(pluginId, field, desiredByItem[item][field]) + end + end + end, { timeout = options.writeAccessTimeoutSeconds or 30 }) + if not executed then error("等待 Lightroom 目录写锁超时", 0) end + end) + if not ok then + Receipt.append(receiptPath, { + kind = "batchAborted", + transactionID = transactionID, + batchID = batchID, + reason = tostring(writeError), + }, receiptAdapter) + if tostring(writeError):find("__RAWGEOSYNC_CANCEL__", 1, true) then + result.canceled = true + else + result.failed = endIndex - startIndex + 1 + result.error = tostring(writeError) + end + break + end + + result.committed = result.committed + (endIndex - startIndex + 1) + result.applied = result.committed + for index = startIndex, endIndex do + committedItems[#committedItems + 1] = plan.writable[index] + end + Receipt.append(receiptPath, { + kind = "batchCommitted", + transactionID = transactionID, + batchID = batchID, + }, receiptAdapter) + + local verificationOk, verificationErrors = protectedCall( + verifyBatch, + catalog, + plan.writable, + startIndex, + endIndex, + pluginId + ) + if not verificationOk then + result.failed = result.failed + (endIndex - startIndex + 1) + result.error = "批量复读失败:" .. tostring(verificationErrors) + else + for index = startIndex, endIndex do + local verifyError = verificationErrors[plan.writable[index]] + if not verifyError then + result.verified = result.verified + 1 + else + result.failed = result.failed + 1 + if not result.error then result.error = verifyError end + end + end + end + for index = startIndex, endIndex do + progress:setPortionComplete(index, #plan.writable) + end + if progress:isCanceled() then result.canceled = true end + if result.failed > 0 then break end + if result.canceled then break end + startIndex = endIndex + 1 + if options.yield then options.yield() end + end + + if result.canceled or result.failed > 0 then + local rolledBack = automaticRollback() + if not rolledBack and not result.rollbackError then + result.rollbackError = "部分照片已在外部修改,无法自动恢复;可使用持久事务收据安全撤销" + end + end + + Receipt.append(receiptPath, { + kind = "transactionFinished", + transactionID = transactionID, + applied = result.applied, + committed = result.committed, + verified = result.verified, + failed = result.failed, + rolledBack = result.rolledBack, + rollbackError = Receipt.nullable(result.rollbackError), + canceled = result.canceled, + }, receiptAdapter) + return result +end + +return CatalogWriter diff --git a/LightroomPlugin/RawGeoSync.lrplugin/Constants.lua b/LightroomPlugin/RawGeoSync.lrplugin/Constants.lua new file mode 100644 index 0000000..7165aa2 --- /dev/null +++ b/LightroomPlugin/RawGeoSync.lrplugin/Constants.lua @@ -0,0 +1,19 @@ +local Constants = {} + +Constants.PLUGIN_ID = "com.sssimplec.rawgeosync.lightroom" +Constants.RECEIPT_DIRECTORY = "RawGeoSync/LightroomBridge/Receipts" +Constants.CATALOG_TOKEN_FIELD = "catalogToken" +Constants.METADATA_FIELDS = { + "sourceToken", + "manifestID", + "activityID", + "revision", + "recordID", + "source", + "quality", + "verification", + "granularity", + "appliedAtUTC", +} + +return Constants diff --git a/LightroomPlugin/RawGeoSync.lrplugin/ControllerSupport.lua b/LightroomPlugin/RawGeoSync.lrplugin/ControllerSupport.lua new file mode 100644 index 0000000..be8f6a0 --- /dev/null +++ b/LightroomPlugin/RawGeoSync.lrplugin/ControllerSupport.lua @@ -0,0 +1,45 @@ +local LrApplication = import "LrApplication" +local LrDialogs = import "LrDialogs" + +local Constants = require "Constants" +local Runtime = require "Runtime" + +local Support = {} + +function Support.catalog() + return LrApplication.activeCatalog() +end + +function Support.getOrCreateCatalogToken(catalog) + local token = catalog:getPropertyForPlugin(_PLUGIN, Constants.CATALOG_TOKEN_FIELD) + if token then return token end + token = Runtime.uniqueId("catalog") + local executed = false + catalog:withPrivateWriteAccessDo(function() + executed = true + local current = catalog:getPropertyForPlugin(_PLUGIN, Constants.CATALOG_TOKEN_FIELD) + if current then token = current + else catalog:setPropertyForPlugin(_PLUGIN, Constants.CATALOG_TOKEN_FIELD, token) end + end, { timeout = 30 }) + if not executed then error("等待 Lightroom 私有目录写锁超时", 0) end + return token +end + +function Support.showError(title, value) + LrDialogs.message(title, tostring(value), "critical") +end + +function Support.summaryCounts(counts) + return table.concat({ + string.format("新增写入:%d", counts.ready or 0), + string.format("已有相同坐标:%d", counts.alreadySame or 0), + string.format("已有不同坐标:%d", counts.conflict or 0), + string.format("不在当前目录:%d", counts.notInCatalog or 0), + string.format("原文件离线:%d", counts.offline or 0), + string.format("文件身份不一致:%d", counts.identityMismatch or 0), + string.format("路径解析重复:%d", counts.duplicateResolvedPath or 0), + string.format("因“仅当前选择”跳过:%d", counts.notSelected or 0), + }, "\n") +end + +return Support diff --git a/LightroomPlugin/RawGeoSync.lrplugin/Geo.lua b/LightroomPlugin/RawGeoSync.lrplugin/Geo.lua new file mode 100644 index 0000000..14f92a4 --- /dev/null +++ b/LightroomPlugin/RawGeoSync.lrplugin/Geo.lua @@ -0,0 +1,72 @@ +local Geo = {} + +local function finite(value) + return type(value) == "number" + and value == value + and value ~= math.huge + and value ~= -math.huge +end + +function Geo.validate(location) + if type(location) ~= "table" then return nil, "location 必须是对象" end + if not finite(location.latitude) or location.latitude < -90 or location.latitude > 90 then + return nil, "latitude 必须位于 -90 至 90" + end + if not finite(location.longitude) or location.longitude < -180 or location.longitude > 180 then + return nil, "longitude 必须位于 -180 至 180" + end + if location.altitude ~= nil and not finite(location.altitude) then + return nil, "altitude 必须是有限数字" + end + return { + latitude = location.latitude, + longitude = location.longitude, + altitude = location.altitude, + } +end + +function Geo.fromRaw(rawGps, altitude) + if type(rawGps) ~= "table" + or not finite(rawGps.latitude) + or not finite(rawGps.longitude) then + return nil + end + return { + latitude = rawGps.latitude, + longitude = rawGps.longitude, + altitude = finite(altitude) and altitude or nil, + } +end + +function Geo.read(photo) + return Geo.fromRaw(photo:getRawMetadata("gps"), Geo.readAltitude(photo)) +end + +function Geo.readAltitude(photo) + local altitude = photo:getRawMetadata("gpsAltitude") + return finite(altitude) and altitude or nil +end + +function Geo.withEffectiveAltitude(location, existingAltitude) + return { + latitude = location.latitude, + longitude = location.longitude, + altitude = location.altitude ~= nil and location.altitude or existingAltitude, + } +end + +function Geo.same(left, right, coordinateEpsilon, altitudeEpsilon) + if left == nil or right == nil then return left == nil and right == nil end + coordinateEpsilon = coordinateEpsilon or 0.0000001 + altitudeEpsilon = altitudeEpsilon or 0.01 + if math.abs(left.latitude - right.latitude) > coordinateEpsilon + or math.abs(left.longitude - right.longitude) > coordinateEpsilon then + return false + end + if left.altitude == nil or right.altitude == nil then + return left.altitude == nil and right.altitude == nil + end + return math.abs(left.altitude - right.altitude) <= altitudeEpsilon +end + +return Geo diff --git a/LightroomPlugin/RawGeoSync.lrplugin/ImportLocations.lua b/LightroomPlugin/RawGeoSync.lrplugin/ImportLocations.lua new file mode 100644 index 0000000..4badcf9 --- /dev/null +++ b/LightroomPlugin/RawGeoSync.lrplugin/ImportLocations.lua @@ -0,0 +1,186 @@ +local LrBinding = import "LrBinding" +local LrDialogs = import "LrDialogs" +local LrFunctionContext = import "LrFunctionContext" +local LrPathUtils = import "LrPathUtils" +local LrPrefs = import "LrPrefs" +local LrProgressScope = import "LrProgressScope" +local LrTasks = import "LrTasks" +local LrView = import "LrView" + +local CatalogAdapter = require "CatalogAdapter" +local CatalogWriter = require "CatalogWriter" +local Constants = require "Constants" +local Manifest = require "Manifest" +local Planner = require "Planner" +local Runtime = require "Runtime" +local Support = require "ControllerSupport" + +local function chooseManifest() + local paths = LrDialogs.runOpenPanel { + title = "选择 RawGeoSync 位置清单", + prompt = "选择清单", + canChooseFiles = true, + canChooseDirectories = false, + allowsMultipleSelection = false, + fileTypes = { "jsonl" }, + } + return paths and paths[1] or nil +end + +local function makePlan(manifest, adapter, transactionID, overwrite, includePhotoSet) + return Planner.build(manifest, adapter, { + pluginId = _PLUGIN, + metadataFields = Constants.METADATA_FIELDS, + overwriteDifferentGps = overwrite, + includePhotoSet = includePhotoSet, + makeSourceToken = function(record) + return transactionID .. ":" .. record.recordID + end, + }) +end + +local function confirmPlan(context, plan, selectedCount) + local factory = LrView.osFactory() + local properties = LrBinding.makePropertyTable(context) + properties.selectedOnly = false + local view = factory:column { + bind_to_object = properties, + spacing = factory:control_spacing(), + factory:static_text { + title = Support.summaryCounts(plan.counts), + width_in_chars = 58, + }, + factory:checkbox { + title = string.format("仅处理 Lightroom 当前选择(当前 %d 张)", selectedCount), + value = LrView.bind("selectedOnly"), + enabled = selectedCount > 0, + }, + factory:static_text { + title = string.format( + "RawGeoSync 将按已确认策略覆盖 %d 张已有不同 GPS 的照片。", + plan.counts.conflict or 0 + ), + width_in_chars = 58, + }, + factory:separator { fill_horizontal = 1 }, + factory:static_text { + title = "提示:Lightroom SDK 无法可靠检测“自动将更改写入 XMP”。本操作只更新目录;如该选项已开启,Lightroom 可能随后写入旁车文件。建议导入前关闭该选项。", + width_in_chars = 58, + height_in_lines = 4, + }, + } + local answer = LrDialogs.presentModalDialog { + title = "RawGeoSync 导入预检", + contents = view, + actionVerb = "开始导入", + cancelVerb = "取消", + } + return answer == "ok", properties.selectedOnly +end + +local function run(context) + local manifestPath = chooseManifest() + if not manifestPath then return end + + local parseProgress = LrProgressScope { title = "正在校验 RawGeoSync 位置清单" } + parseProgress:setCancelable(false) + local manifest = Manifest.read(manifestPath, Runtime.files, { + digest = Runtime.sha256, + new = Runtime.sha256New, + }) + parseProgress:done() + + local catalog = Support.catalog() + local catalogToken = Support.getOrCreateCatalogToken(catalog) + local transactionID = Runtime.uniqueId(manifest.header.manifestID) + local catalogAdapter = CatalogAdapter.new(catalog, manifest.root) + CatalogAdapter.prefetch( + catalogAdapter, + catalog, + manifest.assets, + _PLUGIN, + Constants.METADATA_FIELDS, + 500 + ) + local targetPhotos = catalog:getTargetPhotos() + local selectedSet = {} + for _, photo in ipairs(targetPhotos) do selectedSet[photo] = true end + local previewPlan = makePlan(manifest, catalogAdapter, transactionID, false, nil) + local confirmed, selectedOnly = confirmPlan(context, previewPlan, #targetPhotos) + if not confirmed then return end + if selectedOnly then + local selectedPreview = makePlan(manifest, catalogAdapter, transactionID, false, selectedSet) + local selectedConfirmation = LrDialogs.confirm( + "复核 Lightroom 当前选择", + Support.summaryCounts(selectedPreview.counts) + .. string.format( + "\n\n将覆盖已有不同 GPS:%d 张", + selectedPreview.counts.conflict or 0 + ), + "按当前选择导入", + "取消" + ) + if selectedConfirmation ~= "ok" then return end + end + local plan = makePlan( + manifest, + catalogAdapter, + transactionID, + true, + selectedOnly and selectedSet or nil + ) + if #plan.writable == 0 then + LrDialogs.message("RawGeoSync", "预检后没有需要写入的照片。", "info") + return + end + + local receiptPath = Runtime.receiptPath(transactionID) + local prefs = LrPrefs.prefsForPlugin() + local previousReceiptPath = prefs.latestReceiptPath + local progress = LrProgressScope { + title = string.format("正在向 Lightroom 目录写入 %d 张照片的位置", #plan.writable), + } + progress:setCancelable(true) + local result = CatalogWriter.apply(plan, { + catalog = catalog, + pluginId = _PLUGIN, + metadataFields = Constants.METADATA_FIELDS, + receiptPath = receiptPath, + receiptAdapter = Runtime.receipts, + transactionID = transactionID, + catalogToken = catalogToken, + appliedAtUTC = Runtime.nowUtc(), + progress = progress, + batchSize = 200, + yield = LrTasks.yield, + protectedCall = LrTasks.pcall, + onReceiptPrepared = function(path) + prefs.previousReceiptPath = previousReceiptPath + prefs.latestReceiptPath = path + prefs.latestReceiptCatalogToken = catalogToken + end, + }) + progress:done() + + if result.applied == 0 and result.rolledBack == result.committed then + prefs.latestReceiptPath = previousReceiptPath + prefs.latestReceiptCatalogToken = nil + prefs.previousReceiptPath = nil + end + + local messages = { + string.format("成功写入并复读验证:%d 张", result.verified), + string.format("最终保留本次写入:%d 张", result.applied), + string.format("自动恢复:%d 张", result.rolledBack), + result.canceled and "状态:用户取消" or "状态:处理完成", + } + if result.error then messages[#messages + 1] = "错误:" .. result.error end + if result.rollbackError then messages[#messages + 1] = "自动恢复异常:" .. result.rollbackError end + messages[#messages + 1] = "若 Lightroom 地图/位置面板暂未刷新,请重启 Lightroom 后复核;目录元数据复读结果才是本次校验依据。" + local message = table.concat(messages, "\n") + LrDialogs.message("RawGeoSync 导入结果", message, result.applied > 0 and "info" or "warning") +end + +LrTasks.startAsyncTask(function() + LrFunctionContext.callWithContext("RawGeoSyncImport", run) +end) diff --git a/LightroomPlugin/RawGeoSync.lrplugin/Info.lua b/LightroomPlugin/RawGeoSync.lrplugin/Info.lua new file mode 100644 index 0000000..c10b5d4 --- /dev/null +++ b/LightroomPlugin/RawGeoSync.lrplugin/Info.lua @@ -0,0 +1,30 @@ +local VERSION = {} +VERSION.major = 0 +VERSION.minor = 3 +VERSION.revision = 0 +VERSION.build = 1 + +return { + LrSdkVersion = 6.0, + LrSdkMinimumVersion = 6.0, + LrToolkitIdentifier = "com.sssimplec.rawgeosync.lightroom", + LrPluginName = "RawGeoSync Lightroom Bridge", + LrPluginInfoUrl = "https://github.com/sssimplec/RawGeoSync", + LrPluginInfoProvider = "PluginInfoProvider.lua", + LrMetadataProvider = "MetadataDefinition.lua", + VERSION = VERSION, + LrLibraryMenuItems = { + { + title = "RawGeoSync:导入位置清单…", + file = "ImportLocations.lua", + }, + { + title = "RawGeoSync:撤销最近一次导入…", + file = "UndoImport.lua", + }, + { + title = "RawGeoSync:打开撤销收据文件夹…", + file = "RevealReceipts.lua", + }, + }, +} diff --git a/LightroomPlugin/RawGeoSync.lrplugin/JSON.lua b/LightroomPlugin/RawGeoSync.lrplugin/JSON.lua new file mode 100644 index 0000000..e736ffc --- /dev/null +++ b/LightroomPlugin/RawGeoSync.lrplugin/JSON.lua @@ -0,0 +1,335 @@ +local Json = {} + +local ARRAY_MT = { __rawgeosync_json_array = true } +local NULL = setmetatable({}, { __tostring = function() return "null" end }) + +Json.null = NULL + +function Json.array(values) + return setmetatable(values or {}, ARRAY_MT) +end + +local function fail(position, message) + error(string.format("JSON 字节 %d:%s", position, message), 0) +end + +local function utf8(codepoint) + if codepoint <= 0x7F then + return string.char(codepoint) + elseif codepoint <= 0x7FF then + return string.char( + 0xC0 + math.floor(codepoint / 0x40), + 0x80 + (codepoint % 0x40) + ) + elseif codepoint <= 0xFFFF then + return string.char( + 0xE0 + math.floor(codepoint / 0x1000), + 0x80 + (math.floor(codepoint / 0x40) % 0x40), + 0x80 + (codepoint % 0x40) + ) + end + return string.char( + 0xF0 + math.floor(codepoint / 0x40000), + 0x80 + (math.floor(codepoint / 0x1000) % 0x40), + 0x80 + (math.floor(codepoint / 0x40) % 0x40), + 0x80 + (codepoint % 0x40) + ) +end + +function Json.decode(source, options) + options = options or {} + if type(source) ~= "string" then + error("JSON 输入必须是字符串", 0) + end + if source:sub(1, 3) == "\239\187\191" then + error("JSON 不允许 UTF-8 BOM", 0) + end + + local length = #source + local position = 1 + local maxDepth = options.maxDepth or 64 + local canonical = options.canonical == true + + local function skipWhitespace() + local start = position + while position <= length do + local byte = source:byte(position) + if byte == 0x20 or byte == 0x09 or byte == 0x0A or byte == 0x0D then + position = position + 1 + else + break + end + end + if canonical and position ~= start then + fail(start, "规范 JSON 不允许结构性空白") + end + end + + local function parseString() + local start = position + position = position + 1 + local chunks = {} + local chunkStart = position + + while position <= length do + local byte = source:byte(position) + if byte == 0x22 then + chunks[#chunks + 1] = source:sub(chunkStart, position - 1) + position = position + 1 + return table.concat(chunks) + elseif byte == 0x5C then + chunks[#chunks + 1] = source:sub(chunkStart, position - 1) + position = position + 1 + if position > length then fail(position, "字符串转义未结束") end + local escape = source:sub(position, position) + local replacements = { + ['"'] = '"', ['\\'] = '\\', ['/'] = '/', + b = '\b', f = '\f', n = '\n', r = '\r', t = '\t' + } + if replacements[escape] then + chunks[#chunks + 1] = replacements[escape] + position = position + 1 + elseif escape == "u" then + local hex = source:sub(position + 1, position + 4) + if #hex ~= 4 or not hex:match("^[0-9a-fA-F]+$") then + fail(position, "无效的 Unicode 转义") + end + local codepoint = tonumber(hex, 16) + position = position + 5 + if codepoint >= 0xD800 and codepoint <= 0xDBFF then + if source:sub(position, position + 1) ~= "\\u" then + fail(position, "高代理项缺少低代理项") + end + local lowHex = source:sub(position + 2, position + 5) + if #lowHex ~= 4 or not lowHex:match("^[0-9a-fA-F]+$") then + fail(position, "无效的低代理项") + end + local low = tonumber(lowHex, 16) + if low < 0xDC00 or low > 0xDFFF then + fail(position, "无效的低代理项") + end + codepoint = 0x10000 + (codepoint - 0xD800) * 0x400 + (low - 0xDC00) + position = position + 6 + elseif codepoint >= 0xDC00 and codepoint <= 0xDFFF then + fail(position, "孤立的低代理项") + end + chunks[#chunks + 1] = utf8(codepoint) + else + fail(position, "未知的字符串转义") + end + chunkStart = position + elseif byte < 0x20 then + fail(position, "字符串包含控制字符") + else + position = position + 1 + end + end + fail(start, "字符串未结束") + end + + local parseValue + + local function parseNumber() + local start = position + if source:sub(position, position) == "-" then position = position + 1 end + if source:sub(position, position) == "0" then + position = position + 1 + if source:sub(position, position):match("%d") then + fail(position, "数字不允许前导零") + end + else + if not source:sub(position, position):match("[1-9]") then + fail(position, "无效数字") + end + repeat + position = position + 1 + until not source:sub(position, position):match("%d") + end + if source:sub(position, position) == "." then + position = position + 1 + if not source:sub(position, position):match("%d") then + fail(position, "小数点后必须有数字") + end + repeat + position = position + 1 + until not source:sub(position, position):match("%d") + end + local exponent = source:sub(position, position) + if exponent == "e" or exponent == "E" then + position = position + 1 + local sign = source:sub(position, position) + if sign == "+" or sign == "-" then position = position + 1 end + if not source:sub(position, position):match("%d") then + fail(position, "指数部分必须有数字") + end + repeat + position = position + 1 + until not source:sub(position, position):match("%d") + end + local value = tonumber(source:sub(start, position - 1)) + if value == nil or value ~= value or value == math.huge or value == -math.huge then + fail(start, "数字超出可表示范围") + end + return value + end + + local function parseArray(depth) + position = position + 1 + local result = Json.array() + skipWhitespace() + if source:sub(position, position) == "]" then + position = position + 1 + return result + end + while true do + result[#result + 1] = parseValue(depth + 1) + skipWhitespace() + local separator = source:sub(position, position) + if separator == "]" then + position = position + 1 + return result + elseif separator ~= "," then + fail(position, "数组元素之间缺少逗号") + end + position = position + 1 + skipWhitespace() + end + end + + local function parseObject(depth) + position = position + 1 + local result = {} + local previousKey = nil + skipWhitespace() + if source:sub(position, position) == "}" then + position = position + 1 + return result + end + while true do + if source:sub(position, position) ~= '"' then + fail(position, "对象键必须是字符串") + end + local key = parseString() + if result[key] ~= nil then + fail(position, "对象包含重复键:" .. key) + end + if canonical and previousKey ~= nil and key <= previousKey then + fail(position, "对象键未按升序排列") + end + previousKey = key + skipWhitespace() + if source:sub(position, position) ~= ":" then + fail(position, "对象键后缺少冒号") + end + position = position + 1 + skipWhitespace() + result[key] = parseValue(depth + 1) + skipWhitespace() + local separator = source:sub(position, position) + if separator == "}" then + position = position + 1 + return result + elseif separator ~= "," then + fail(position, "对象成员之间缺少逗号") + end + position = position + 1 + skipWhitespace() + end + end + + parseValue = function(depth) + if depth > maxDepth then fail(position, "嵌套层级过深") end + skipWhitespace() + local marker = source:sub(position, position) + if marker == '"' then + return parseString() + elseif marker == "{" then + return parseObject(depth) + elseif marker == "[" then + return parseArray(depth) + elseif marker == "-" or marker:match("%d") then + return parseNumber() + elseif source:sub(position, position + 3) == "true" then + position = position + 4 + return true + elseif source:sub(position, position + 4) == "false" then + position = position + 5 + return false + elseif source:sub(position, position + 3) == "null" then + position = position + 4 + return NULL + end + fail(position, "无法识别的值") + end + + local value = parseValue(0) + skipWhitespace() + if position <= length then fail(position, "根值之后还有内容") end + return value +end + +local function encodeString(value) + local result = { '"' } + for index = 1, #value do + local byte = value:byte(index) + if byte == 0x22 then result[#result + 1] = '\\"' + elseif byte == 0x5C then result[#result + 1] = '\\\\' + elseif byte == 0x08 then result[#result + 1] = '\\b' + elseif byte == 0x0C then result[#result + 1] = '\\f' + elseif byte == 0x0A then result[#result + 1] = '\\n' + elseif byte == 0x0D then result[#result + 1] = '\\r' + elseif byte == 0x09 then result[#result + 1] = '\\t' + elseif byte < 0x20 then result[#result + 1] = string.format("\\u%04x", byte) + else result[#result + 1] = string.char(byte) end + end + result[#result + 1] = '"' + return table.concat(result) +end + +local encodeValue + +local function isArray(value) + return getmetatable(value) == ARRAY_MT +end + +encodeValue = function(value, stack) + local valueType = type(value) + if value == NULL then return "null" end + if valueType == "string" then return encodeString(value) end + if valueType == "boolean" then return value and "true" or "false" end + if valueType == "number" then + if value ~= value or value == math.huge or value == -math.huge then + error("JSON 不支持非有限数字", 0) + end + if value == 0 then return "0" end + return string.format("%.17g", value) + end + if valueType ~= "table" then + error("JSON 不支持类型:" .. valueType, 0) + end + if stack[value] then error("JSON 不支持循环引用", 0) end + stack[value] = true + local output = {} + if isArray(value) then + for index = 1, #value do output[index] = encodeValue(value[index], stack) end + stack[value] = nil + return "[" .. table.concat(output, ",") .. "]" + end + local keys = {} + for key in pairs(value) do + if type(key) ~= "string" then error("JSON 对象键必须是字符串", 0) end + keys[#keys + 1] = key + end + table.sort(keys) + for index, key in ipairs(keys) do + output[index] = encodeString(key) .. ":" .. encodeValue(value[key], stack) + end + stack[value] = nil + return "{" .. table.concat(output, ",") .. "}" +end + +function Json.encode(value) + return encodeValue(value, {}) +end + +return Json diff --git a/LightroomPlugin/RawGeoSync.lrplugin/Manifest.lua b/LightroomPlugin/RawGeoSync.lrplugin/Manifest.lua new file mode 100644 index 0000000..b50023b --- /dev/null +++ b/LightroomPlugin/RawGeoSync.lrplugin/Manifest.lua @@ -0,0 +1,340 @@ +local Json = require "JSON" +local Geo = require "Geo" +local PathPolicy = require "PathPolicy" + +local Manifest = {} + +Manifest.FORMAT = "com.sssimplec.rawgeosync.locations" +Manifest.SCHEMA_MAJOR = 1 +Manifest.SCHEMA_MINOR = 0 + +local function manifestError(lineNumber, message) + if lineNumber then + error(string.format("清单第 %d 行:%s", lineNumber, message), 0) + end + error("清单:" .. message, 0) +end + +local function requireType(value, expected, name, lineNumber) + if type(value) ~= expected then + manifestError(lineNumber, name .. " 类型无效") + end + return value +end + +local function requireNonempty(value, name, lineNumber) + requireType(value, "string", name, lineNumber) + if value == "" then manifestError(lineNumber, name .. " 不得为空") end + return value +end + +local function requireInteger(value, name, lineNumber, minimum) + requireType(value, "number", name, lineNumber) + if value ~= math.floor(value) or value < (minimum or 0) then + manifestError(lineNumber, name .. " 必须是有效整数") + end + if value > 9007199254740991 then + manifestError(lineNumber, name .. " 超出 Lightroom Lua 的安全整数范围") + end + return value +end + +local function optionalString(value, name, lineNumber) + if value ~= nil then requireNonempty(value, name, lineNumber) end +end + +local function isUuid(value) + if type(value) ~= "string" then return false end + local a, b, c, d, e = value:match("^(%x+)%-(%x+)%-(%x+)%-(%x+)%-(%x+)$") + return a ~= nil and #a == 8 and #b == 4 and #c == 4 and #d == 4 and #e == 12 +end + +local function requireUuid(value, name, lineNumber) + if not isUuid(value) then manifestError(lineNumber, name .. " 必须是 UUID") end +end + +local function isSha256(value) + return type(value) == "string" and #value == 64 and value:match("^[0-9a-f]+$") ~= nil +end + +local function requireSha256(value, name, lineNumber) + if not isSha256(value) then manifestError(lineNumber, name .. " 必须是 64 位小写 SHA-256") end +end + +local function requireUtc(value, name, lineNumber) + requireNonempty(value, name, lineNumber) + local year, month, day, hour, minute, second = value:match( + "^(%d%d%d%d)%-(%d%d)%-(%d%d)T(%d%d):(%d%d):(%d%d)Z$" + ) + if not year then + year, month, day, hour, minute, second = value:match( + "^(%d%d%d%d)%-(%d%d)%-(%d%d)T(%d%d):(%d%d):(%d%d)%.%d+Z$" + ) + end + if not year then + manifestError(lineNumber, name .. " 必须是 UTC RFC3339 时间") + end + year, month, day = tonumber(year), tonumber(month), tonumber(day) + hour, minute, second = tonumber(hour), tonumber(minute), tonumber(second) + local monthDays = { 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 } + if year % 4 == 0 and (year % 100 ~= 0 or year % 400 == 0) then monthDays[2] = 29 end + if month < 1 or month > 12 or day < 1 or day > monthDays[month] + or hour > 23 or minute > 59 or second > 60 then + manifestError(lineNumber, name .. " 不是有效的 UTC RFC3339 时间") + end +end + +local function requireFiniteOptional(value, name, lineNumber, minimum) + if value == nil then return end + if type(value) ~= "number" or value ~= value or value == math.huge or value == -math.huge + or (minimum ~= nil and value < minimum) then + manifestError(lineNumber, name .. " 必须是有效数字") + end +end + +local function validateHeader(header, lineNumber) + if header.type ~= "header" then manifestError(lineNumber, "首行 type 必须是 header") end + if header.format ~= Manifest.FORMAT then manifestError(lineNumber, "format 不受支持") end + requireType(header.schemaVersion, "table", "schemaVersion", lineNumber) + local major = requireInteger(header.schemaVersion.major, "schemaVersion.major", lineNumber) + requireInteger(header.schemaVersion.minor, "schemaVersion.minor", lineNumber) + if major ~= Manifest.SCHEMA_MAJOR then + manifestError(lineNumber, "schemaVersion.major 不受支持") + end + requireUuid(header.manifestID, "manifestID", lineNumber) + requireUuid(header.activityID, "activityID", lineNumber) + requireInteger(header.revision, "revision", lineNumber, 1) + requireUtc(header.createdAtUTC, "createdAtUTC", lineNumber) + requireNonempty(header.appVersion, "appVersion", lineNumber) + requireNonempty(header.algorithmVersion, "algorithmVersion", lineNumber) + requireInteger(header.recordCount, "recordCount", lineNumber) + requireNonempty(header.rootDisplayName, "rootDisplayName", lineNumber) + if header.priorPayloadSHA256 ~= nil then + requireSha256(header.priorPayloadSHA256, "priorPayloadSHA256", lineNumber) + end +end + +local function validateFileIdentity(identity, lineNumber) + requireType(identity, "table", "fileIdentity", lineNumber) + requireInteger(identity.byteCount, "fileIdentity.byteCount", lineNumber) + requireNonempty(identity.exifDateTimeOriginal, "fileIdentity.exifDateTimeOriginal", lineNumber) + optionalString(identity.subsecondTimeOriginal, "fileIdentity.subsecondTimeOriginal", lineNumber) + optionalString(identity.offsetTimeOriginal, "fileIdentity.offsetTimeOriginal", lineNumber) + optionalString(identity.make, "fileIdentity.make", lineNumber) + optionalString(identity.model, "fileIdentity.model", lineNumber) + optionalString(identity.serialNumber, "fileIdentity.serialNumber", lineNumber) + optionalString(identity.internalSerialNumber, "fileIdentity.internalSerialNumber", lineNumber) + if identity.shutterCount ~= nil then + requireInteger(identity.shutterCount, "fileIdentity.shutterCount", lineNumber) + end +end + +local function validateDecision(decision, lineNumber) + requireType(decision, "table", "decision", lineNumber) + requireNonempty(decision.confidence, "decision.confidence", lineNumber) + requireNonempty(decision.method, "decision.method", lineNumber) + requireNonempty(decision.granularity, "decision.granularity", lineNumber) + local verification = requireNonempty(decision.verification, "decision.verification", lineNumber) + if verification ~= "automatic" and verification ~= "userConfirmed" and verification ~= "manual" then + manifestError(lineNumber, "decision.verification 不受支持") + end + requireNonempty(decision.ruleVersion, "decision.ruleVersion", lineNumber) + requireFiniteOptional(decision.estimatedRadiusMeters, "decision.estimatedRadiusMeters", lineNumber, 0) + requireFiniteOptional(decision.temporalDistanceSeconds, "decision.temporalDistanceSeconds", lineNumber, 0) + optionalString(decision.evidenceSummary, "decision.evidenceSummary", lineNumber) + if decision.trackFileSHA256 ~= nil then + requireSha256(decision.trackFileSHA256, "decision.trackFileSHA256", lineNumber) + end +end + +local function unsignedRecordLine(rawLine, digest, lineNumber) + local escapedDigest = digest:gsub("(%W)", "%%%1") + local unsigned, replacements = rawLine:gsub( + ',"recordDigestSHA256":"' .. escapedDigest .. '"', + "", + 1 + ) + if replacements ~= 1 then + manifestError(lineNumber, "无法定位规范的 recordDigestSHA256 字段") + end + return unsigned +end + +local function validateAsset(asset, rawLine, lineNumber, sha256) + if asset.type ~= "asset" then manifestError(lineNumber, "中间行 type 必须是 asset") end + requireUuid(asset.recordID, "recordID", lineNumber) + requireNonempty(asset.relativePath, "relativePath", lineNumber) + local _, pathError = PathPolicy.validateRelative(asset.relativePath) + if pathError then manifestError(lineNumber, pathError) end + validateFileIdentity(asset.fileIdentity, lineNumber) + requireUtc(asset.correctedCaptureTimeUTC, "correctedCaptureTimeUTC", lineNumber) + local normalized, geoError = Geo.validate(asset.location) + if not normalized then manifestError(lineNumber, geoError) end + asset.location = normalized + validateDecision(asset.decision, lineNumber) + requireSha256(asset.recordDigestSHA256, "recordDigestSHA256", lineNumber) + local unsigned = unsignedRecordLine(rawLine, asset.recordDigestSHA256, lineNumber) + if sha256(unsigned) ~= asset.recordDigestSHA256 then + manifestError(lineNumber, "recordDigestSHA256 校验失败") + end +end + +local function validateTrailer(trailer, lineNumber) + if trailer.type ~= "trailer" then manifestError(lineNumber, "末行 type 必须是 trailer") end + requireInteger(trailer.recordCount, "recordCount", lineNumber) + requireSha256(trailer.payloadSHA256, "payloadSHA256", lineNumber) +end + +function Manifest.parse(contents, sha256, limits) + requireType(contents, "string", "contents") + if type(sha256) ~= "function" then error("Manifest.parse 需要 SHA-256 函数", 0) end + limits = limits or {} + if #contents == 0 then manifestError(nil, "文件为空") end + if #contents > (limits.maxBytes or 33554432) then manifestError(nil, "文件超过安全大小限制") end + if contents:find("\r", 1, true) then manifestError(nil, "只允许 LF 换行") end + if contents:sub(-1) ~= "\n" then manifestError(nil, "末行必须以 LF 结束") end + + local lines = {} + for line in contents:gmatch("([^\n]*)\n") do + if line == "" then manifestError(#lines + 1, "不允许空行") end + lines[#lines + 1] = line + end + if #lines < 2 then manifestError(nil, "至少需要 header 与 trailer") end + if #lines - 2 > (limits.maxRecords or 1000000) then + manifestError(nil, "记录数超过安全限制") + end + + local decoded = {} + for index, line in ipairs(lines) do + local ok, value = pcall(Json.decode, line, { canonical = true, maxDepth = 32 }) + if not ok then manifestError(index, value) end + if type(value) ~= "table" then manifestError(index, "物理行必须是 JSON 对象") end + decoded[index] = value + end + + local header = decoded[1] + local trailer = decoded[#decoded] + validateHeader(header, 1) + validateTrailer(trailer, #decoded) + + local assets = {} + local seenPaths = {} + local seenRecordIds = {} + local payloadParts = { lines[1], "\n" } + for index = 2, #decoded - 1 do + local asset = decoded[index] + validateAsset(asset, lines[index], index, sha256) + if seenPaths[asset.relativePath] then + manifestError(index, "relativePath 重复") + end + if seenRecordIds[asset.recordID] then + manifestError(index, "recordID 重复") + end + seenPaths[asset.relativePath] = true + seenRecordIds[asset.recordID] = true + assets[#assets + 1] = asset + payloadParts[#payloadParts + 1] = lines[index] + payloadParts[#payloadParts + 1] = "\n" + end + if header.recordCount ~= #assets or trailer.recordCount ~= #assets then + manifestError(nil, "header/trailer 的 recordCount 与 asset 行数不一致") + end + if sha256(table.concat(payloadParts)) ~= trailer.payloadSHA256 then + manifestError(#decoded, "payloadSHA256 校验失败") + end + + return { + header = header, + assets = assets, + trailer = trailer, + } +end + +function Manifest.read(path, fileAdapter, sha256, limits) + limits = limits or {} + local digest = type(sha256) == "table" and sha256.digest or sha256 + local newDigest = type(sha256) == "table" and sha256.new or nil + if not fileAdapter.openLines or type(newDigest) ~= "function" then + local contents, readError = fileAdapter.read(path) + if not contents then error("无法读取清单:" .. tostring(readError), 0) end + local parsed = Manifest.parse(contents, digest, limits) + parsed.path = path + parsed.root = fileAdapter.parent(path) + return parsed + end + + local reader, openError = fileAdapter.openLines(path) + if not reader then error("无法读取清单:" .. tostring(openError), 0) end + local function closeReader() pcall(reader.close) end + local ok, result = pcall(function() + if reader.byteCount == 0 then manifestError(nil, "文件为空") end + if reader.byteCount > (limits.maxBytes or 33554432) then + manifestError(nil, "文件超过安全大小限制") + end + if not reader.hasTrailingLF then manifestError(nil, "末行必须以 LF 结束") end + + local lineNumber = 1 + local headerLine = reader.nextLine() + if not headerLine or headerLine == "" then manifestError(1, "缺少 header") end + if headerLine:find("\r", 1, true) then manifestError(1, "只允许 LF 换行") end + if #headerLine > (limits.maxLineBytes or 1048576) then manifestError(1, "物理行过长") end + local headerOk, header = pcall(Json.decode, headerLine, { canonical = true, maxDepth = 32 }) + if not headerOk then manifestError(1, header) end + validateHeader(header, 1) + + local payload = newDigest() + payload:update(headerLine .. "\n") + local assets = {} + local seenPaths = {} + local seenRecordIds = {} + local trailer = nil + while true do + local rawLine = reader.nextLine() + if rawLine == nil then break end + lineNumber = lineNumber + 1 + if rawLine == "" then manifestError(lineNumber, "不允许空行") end + if rawLine:find("\r", 1, true) then manifestError(lineNumber, "只允许 LF 换行") end + if #rawLine > (limits.maxLineBytes or 1048576) then + manifestError(lineNumber, "物理行过长") + end + local lineOk, value = pcall(Json.decode, rawLine, { canonical = true, maxDepth = 32 }) + if not lineOk then manifestError(lineNumber, value) end + if type(value) ~= "table" then manifestError(lineNumber, "物理行必须是 JSON 对象") end + if value.type == "trailer" then + trailer = value + validateTrailer(trailer, lineNumber) + if reader.nextLine() ~= nil then manifestError(lineNumber + 1, "trailer 后仍有内容") end + break + end + validateAsset(value, rawLine, lineNumber, digest) + if seenPaths[value.relativePath] then manifestError(lineNumber, "relativePath 重复") end + if seenRecordIds[value.recordID] then manifestError(lineNumber, "recordID 重复") end + seenPaths[value.relativePath] = true + seenRecordIds[value.recordID] = true + assets[#assets + 1] = value + if #assets > (limits.maxRecords or 1000000) then + manifestError(lineNumber, "记录数超过安全限制") + end + payload:update(rawLine .. "\n") + end + if not trailer then manifestError(nil, "缺少 trailer") end + if header.recordCount ~= #assets or trailer.recordCount ~= #assets then + manifestError(nil, "header/trailer 的 recordCount 与 asset 行数不一致") + end + if payload:finish() ~= trailer.payloadSHA256 then + manifestError(lineNumber, "payloadSHA256 校验失败") + end + return { + header = header, + assets = assets, + trailer = trailer, + path = path, + root = fileAdapter.parent(path), + } + end) + closeReader() + if not ok then error(result, 0) end + return result +end + +return Manifest diff --git a/LightroomPlugin/RawGeoSync.lrplugin/MetadataDefinition.lua b/LightroomPlugin/RawGeoSync.lrplugin/MetadataDefinition.lua new file mode 100644 index 0000000..0b14178 --- /dev/null +++ b/LightroomPlugin/RawGeoSync.lrplugin/MetadataDefinition.lua @@ -0,0 +1,15 @@ +return { + schemaVersion = 1, + metadataFieldsForPhotos = { + { id = "sourceToken" }, + { id = "manifestID" }, + { id = "activityID" }, + { id = "revision" }, + { id = "recordID" }, + { id = "source" }, + { id = "quality" }, + { id = "verification" }, + { id = "granularity" }, + { id = "appliedAtUTC" }, + }, +} diff --git a/LightroomPlugin/RawGeoSync.lrplugin/PathPolicy.lua b/LightroomPlugin/RawGeoSync.lrplugin/PathPolicy.lua new file mode 100644 index 0000000..1e3b25f --- /dev/null +++ b/LightroomPlugin/RawGeoSync.lrplugin/PathPolicy.lua @@ -0,0 +1,97 @@ +local PathPolicy = {} + +local function decodeCodepoints(value) + local index = 1 + local length = #value + local codepoints = {} + while index <= length do + local first = value:byte(index) + local count, codepoint + if first <= 0x7F then + count, codepoint = 1, first + elseif first >= 0xC2 and first <= 0xDF then + count, codepoint = 2, first - 0xC0 + elseif first >= 0xE0 and first <= 0xEF then + count, codepoint = 3, first - 0xE0 + elseif first >= 0xF0 and first <= 0xF4 then + count, codepoint = 4, first - 0xF0 + else + return nil, "路径不是有效 UTF-8" + end + if index + count - 1 > length then return nil, "路径 UTF-8 序列不完整" end + for offset = 2, count do + local continuation = value:byte(index + offset - 1) + if continuation < 0x80 or continuation > 0xBF then + return nil, "路径不是有效 UTF-8" + end + codepoint = codepoint * 0x40 + (continuation - 0x80) + end + if (count == 3 and codepoint < 0x800) + or (count == 4 and codepoint < 0x10000) + or codepoint > 0x10FFFF + or (codepoint >= 0xD800 and codepoint <= 0xDFFF) then + return nil, "路径包含无效 Unicode 码位" + end + codepoints[#codepoints + 1] = codepoint + index = index + count + end + return codepoints +end + +function PathPolicy.validateRelative(relativePath) + if type(relativePath) ~= "string" or relativePath == "" then + return nil, "relativePath 必须是非空字符串" + end + if relativePath:sub(1, 1) == "/" or relativePath:match("^%a:") then + return nil, "relativePath 不得是绝对路径" + end + if relativePath:find("\\", 1, true) then + return nil, "relativePath 只能使用正斜杠" + end + if relativePath:sub(-1) == "/" or relativePath:find("//", 1, true) then + return nil, "relativePath 包含空路径段" + end + + local codepoints, unicodeError = decodeCodepoints(relativePath) + if not codepoints then return nil, unicodeError end + for _, codepoint in ipairs(codepoints) do + if codepoint <= 0x1F or (codepoint >= 0x7F and codepoint <= 0x9F) then + return nil, "relativePath 包含控制字符" + end + if (codepoint >= 0x0300 and codepoint <= 0x036F) + or (codepoint >= 0x1AB0 and codepoint <= 0x1AFF) + or (codepoint >= 0x1DC0 and codepoint <= 0x1DFF) + or (codepoint >= 0x20D0 and codepoint <= 0x20FF) + or (codepoint >= 0xFE20 and codepoint <= 0xFE2F) then + return nil, "relativePath 必须使用 NFC;不接受分解组合字符" + end + end + + local segments = {} + for segment in relativePath:gmatch("[^/]+") do + if segment == "." or segment == ".." then + return nil, "relativePath 不得包含 . 或 .." + end + segments[#segments + 1] = segment + end + if #segments == 0 then return nil, "relativePath 没有有效路径段" end + return segments +end + +function PathPolicy.resolve(root, relativePath, pathAdapter) + if type(root) ~= "string" or root == "" then return nil, "清单根目录无效" end + local segments, pathError = PathPolicy.validateRelative(relativePath) + if not segments then return nil, pathError end + local candidate = root + for _, segment in ipairs(segments) do candidate = pathAdapter.child(candidate, segment) end + local standardizedRoot = pathAdapter.standardize(root) + local standardizedCandidate = pathAdapter.standardize(candidate) + local prefix = standardizedRoot + if prefix:sub(-1) ~= "/" then prefix = prefix .. "/" end + if standardizedCandidate:sub(1, #prefix) ~= prefix then + return nil, "路径解析结果越出清单目录" + end + return standardizedCandidate +end + +return PathPolicy diff --git a/LightroomPlugin/RawGeoSync.lrplugin/Planner.lua b/LightroomPlugin/RawGeoSync.lrplugin/Planner.lua new file mode 100644 index 0000000..2ae7914 --- /dev/null +++ b/LightroomPlugin/RawGeoSync.lrplugin/Planner.lua @@ -0,0 +1,96 @@ +local Geo = require "Geo" + +local Planner = {} + +local function increment(counts, status) + counts[status] = (counts[status] or 0) + 1 +end + +local function priorPluginMetadata(photo, pluginId, fields) + local result = {} + for _, field in ipairs(fields) do + result[field] = photo:getPropertyForPlugin(pluginId, field) + end + return result +end + +local function metadataFor(resolved, photo, pluginId, fields) + if resolved.pluginMetadata then return resolved.pluginMetadata end + return priorPluginMetadata(photo, pluginId, fields) +end + +function Planner.build(manifest, adapter, options) + options = options or {} + local pluginId = assert(options.pluginId, "pluginId is required") + local fields = assert(options.metadataFields, "metadataFields is required") + local plan = { + manifestID = manifest.header.manifestID, + activityID = manifest.header.activityID, + revision = manifest.header.revision, + items = {}, + writable = {}, + counts = {}, + } + local seenResolvedPaths = {} + + for _, record in ipairs(manifest.assets) do + local item = { record = record } + local resolved, resolveError = adapter.resolveExact(record) + if not resolved then + item.status = "notInCatalog" + item.reason = resolveError or "照片不在当前 Lightroom 目录中" + elseif options.includePhotoSet and not options.includePhotoSet[resolved.photo] then + item.status = "notSelected" + item.photo = resolved.photo + item.path = resolved.path + item.reason = "不在 Lightroom 当前选择中" + elseif seenResolvedPaths[resolved.path] then + item.status = "duplicateResolvedPath" + item.photo = resolved.photo + item.path = resolved.path + item.reason = "多个清单路径解析到同一照片,已拒绝重复处理" + elseif resolved.available == false then + seenResolvedPaths[resolved.path] = true + item.status = "offline" + item.photo = resolved.photo + item.path = resolved.path + item.reason = "原始文件离线;为避免把智能预览误作原文件,已跳过" + elseif resolved.byteCount ~= record.fileIdentity.byteCount then + seenResolvedPaths[resolved.path] = true + item.status = "identityMismatch" + item.photo = resolved.photo + item.path = resolved.path + item.reason = "文件字节数与清单不一致" + else + seenResolvedPaths[resolved.path] = true + item.photo = resolved.photo + item.path = resolved.path + item.uuid = resolved.uuid or (resolved.rawMetadata and resolved.rawMetadata.uuid) + if resolved.rawMetadata then + item.beforeAltitude = resolved.rawMetadata.gpsAltitude + item.before = Geo.fromRaw(resolved.rawMetadata.gps, item.beforeAltitude) + else + item.before = Geo.read(resolved.photo) + item.beforeAltitude = Geo.readAltitude(resolved.photo) + end + item.desired = Geo.withEffectiveAltitude(record.location, item.beforeAltitude) + item.beforePluginMetadata = metadataFor(resolved, resolved.photo, pluginId, fields) + if Geo.same(item.before, item.desired) then + item.status = "alreadySame" + item.reason = "目录中的 GPS 已与清单一致" + elseif item.before ~= nil and not options.overwriteDifferentGps then + item.status = "conflict" + item.reason = "照片已有不同 GPS;默认保留" + else + item.status = "ready" + item.sourceToken = assert(options.makeSourceToken(record), "source token is required") + plan.writable[#plan.writable + 1] = item + end + end + plan.items[#plan.items + 1] = item + increment(plan.counts, item.status) + end + return plan +end + +return Planner diff --git a/LightroomPlugin/RawGeoSync.lrplugin/PluginInfoProvider.lua b/LightroomPlugin/RawGeoSync.lrplugin/PluginInfoProvider.lua new file mode 100644 index 0000000..d4fc91e --- /dev/null +++ b/LightroomPlugin/RawGeoSync.lrplugin/PluginInfoProvider.lua @@ -0,0 +1,20 @@ +local LrView = import "LrView" + +return { + sectionsForTopOfDialog = function(_, propertyTable) + local factory = LrView.osFactory() + return { + { + title = "RawGeoSync Lightroom Bridge", + synopsis = "0.3.0(构建 1)", + factory:row { + spacing = factory:control_spacing(), + factory:static_text { + title = "从 RawGeoSync.locations.jsonl 批量导入 GPS,并提供安全撤销。", + fill_horizontal = 1, + }, + }, + }, + } + end, +} diff --git a/LightroomPlugin/RawGeoSync.lrplugin/Receipt.lua b/LightroomPlugin/RawGeoSync.lrplugin/Receipt.lua new file mode 100644 index 0000000..d429c93 --- /dev/null +++ b/LightroomPlugin/RawGeoSync.lrplugin/Receipt.lua @@ -0,0 +1,74 @@ +local Json = require "JSON" + +local Receipt = {} + +Receipt.FORMAT = "com.sssimplec.rawgeosync.lightroom-receipt" +Receipt.VERSION = 1 + +function Receipt.nullable(value) + if value == nil then return Json.null end + return value +end + +function Receipt.append(path, event, fileAdapter) + event.receiptFormat = Receipt.FORMAT + event.receiptVersion = Receipt.VERSION + local line = Json.encode(event) .. "\n" + local ok, appendError = fileAdapter.append(path, line) + if not ok then error("无法持久化事务收据:" .. tostring(appendError), 0) end +end + +function Receipt.parse(contents) + if type(contents) ~= "string" or contents == "" then error("事务收据为空", 0) end + if contents:find("\r", 1, true) then error("事务收据包含无效换行", 0) end + local events = {} + for line in contents:gmatch("([^\n]*)\n") do + if line == "" then error("事务收据包含空行", 0) end + local event = Json.decode(line, { canonical = true, maxDepth = 32 }) + if type(event) ~= "table" + or event.receiptFormat ~= Receipt.FORMAT + or event.receiptVersion ~= Receipt.VERSION then + error("事务收据格式不受支持", 0) + end + events[#events + 1] = event + end + if #events == 0 or events[1].kind ~= "transactionPrepared" then + error("事务收据缺少 transactionPrepared", 0) + end + return events +end + +function Receipt.undoState(events) + local header = events[1] + local prepared = {} + local order = {} + local aborted = {} + for _, event in ipairs(events) do + if event.transactionID ~= header.transactionID then + error("事务收据混入其他 transactionID", 0) + end + if event.kind == "batchPrepared" then + if prepared[event.batchID] then error("事务收据 batchID 重复", 0) end + prepared[event.batchID] = event + order[#order + 1] = event.batchID + elseif event.kind == "batchAborted" then + aborted[event.batchID] = true + end + end + local items = {} + for _, batchID in ipairs(order) do + if not aborted[batchID] then + local event = prepared[batchID] + if type(event.items) ~= "table" then error("batchPrepared 缺少 items", 0) end + for _, item in ipairs(event.items) do items[#items + 1] = item end + end + end + return { + transactionID = header.transactionID, + catalogToken = header.catalogToken, + manifestID = header.manifestID, + items = items, + } +end + +return Receipt diff --git a/LightroomPlugin/RawGeoSync.lrplugin/RevealReceipts.lua b/LightroomPlugin/RawGeoSync.lrplugin/RevealReceipts.lua new file mode 100644 index 0000000..426353c --- /dev/null +++ b/LightroomPlugin/RawGeoSync.lrplugin/RevealReceipts.lua @@ -0,0 +1,17 @@ +local LrDialogs = import "LrDialogs" +local LrFileUtils = import "LrFileUtils" +local LrShell = import "LrShell" +local LrTasks = import "LrTasks" + +local Runtime = require "Runtime" + +LrTasks.startAsyncTask(function() + local directory = Runtime.receiptDirectory() + LrFileUtils.createAllDirectories(directory) + LrShell.revealInShell(directory) + LrDialogs.message( + "RawGeoSync 撤销收据", + "已在 Finder 中打开撤销收据文件夹。收据可能包含原 GPS 与拍摄身份信息;只应在确认不再需要跨重启撤销后手工删除。", + "info" + ) +end) diff --git a/LightroomPlugin/RawGeoSync.lrplugin/Runtime.lua b/LightroomPlugin/RawGeoSync.lrplugin/Runtime.lua new file mode 100644 index 0000000..6c9e6ea --- /dev/null +++ b/LightroomPlugin/RawGeoSync.lrplugin/Runtime.lua @@ -0,0 +1,167 @@ +local LrFileUtils = import "LrFileUtils" +local LrPathUtils = import "LrPathUtils" + +local SHA256 = require "SHA256" + +local function normalizeDigest(digest) + if type(digest) ~= "string" then return nil end + if #digest == 64 and digest:match("^[0-9a-fA-F]+$") then + return string.lower(digest) + elseif #digest == 32 then + return (digest:gsub(".", function(byte) return string.format("%02x", byte:byte()) end)) + end + return nil +end + +local nativeSHA256 = nil +local nativeSHA256Init = nil +local nativeOk, LrDigest = pcall(import, "LrDigest") +if nativeOk and LrDigest and LrDigest.SHA256 and type(LrDigest.SHA256.digest) == "function" then + local candidate = LrDigest.SHA256.digest + local digestOk, emptyDigest = pcall(candidate, "") + if digestOk + and normalizeDigest(emptyDigest) == "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" then + nativeSHA256 = candidate + end + if type(LrDigest.SHA256.init) == "function" then + local initOk, context = pcall(LrDigest.SHA256.init) + if initOk and context and type(context.update) == "function" and type(context.digest) == "function" then + local checkOk, checkDigest = pcall(function() + context:update("") + return context:digest() + end) + if checkOk + and normalizeDigest(checkDigest) == "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" then + nativeSHA256Init = LrDigest.SHA256.init + end + end + end +end + +local Runtime = {} + +Runtime.path = { + child = function(parent, child) return LrPathUtils.child(parent, child) end, + parent = function(path) return LrPathUtils.parent(path) end, + standardize = function(path) return LrPathUtils.standardizePath(path) end, +} + +Runtime.files = { + read = function(path) + local ok, value = pcall(LrFileUtils.readFile, path) + if ok then return value end + return nil, value + end, + parent = function(path) return LrPathUtils.parent(path) end, + exists = function(path) return LrFileUtils.exists(path) == "file" end, + byteCount = function(path) + local attributes = LrFileUtils.fileAttributes(path) + return attributes and attributes.fileSize or nil + end, + openLines = function(path) + local file, openError = io.open(path, "rb") + if not file then return nil, openError end + local byteCount = file:seek("end") + local hasTrailingLF = false + if byteCount and byteCount > 0 then + file:seek("set", byteCount - 1) + hasTrailingLF = file:read(1) == "\n" + end + file:seek("set", 0) + return { + byteCount = byteCount or 0, + hasTrailingLF = hasTrailingLF, + nextLine = function() return file:read("*l") end, + close = function() return file:close() end, + } + end, +} + +Runtime.receipts = { + append = function(path, contents) + local parent = LrPathUtils.parent(path) + local ok, directoryError = pcall(LrFileUtils.createAllDirectories, parent) + if not ok then return nil, directoryError end + local file, openError = io.open(path, "ab") + if not file then return nil, openError end + local wrote, writeError = file:write(contents) + if wrote then wrote, writeError = file:flush() end + local closed, closeError = file:close() + if not wrote then return nil, writeError end + if not closed then return nil, closeError end + return true + end, + read = function(path) + local ok, value = pcall(LrFileUtils.readFile, path) + if ok then return value end + return nil, value + end, +} + +function Runtime.sha256(contents) + if nativeSHA256 then + local ok, digest = pcall(nativeSHA256, contents) + if ok and normalizeDigest(digest) then return normalizeDigest(digest) end + nativeSHA256 = nil + end + return SHA256.digest(contents) +end + +function Runtime.sha256New() + if nativeSHA256Init then + local nativeContext = nativeSHA256Init() + return { + update = function(_, contents) + nativeContext:update(contents) + end, + finish = function() + local digest = normalizeDigest(nativeContext:digest()) + if not digest then error("Lightroom SHA-256 返回了无效摘要", 0) end + return digest + end, + } + end + return SHA256.new() +end + +function Runtime.nowUtc() + return os.date("!%Y-%m-%dT%H:%M:%SZ") +end + +function Runtime.uniqueId(seed) + local value = table.concat({ + tostring(seed or ""), + Runtime.nowUtc(), + tostring(os.clock()), + tostring({}), + }, ":") + return Runtime.sha256(value):sub(1, 32) +end + +function Runtime.receiptPath(transactionID) + local appData = LrPathUtils.getStandardFilePath("appData") + local directory = appData + for segment in ("RawGeoSync/LightroomBridge/Receipts"):gmatch("[^/]+") do + directory = LrPathUtils.child(directory, segment) + end + return LrPathUtils.child(directory, transactionID .. ".jsonl") +end + +function Runtime.receiptDirectory() + local appData = LrPathUtils.getStandardFilePath("appData") + local directory = appData + for segment in ("RawGeoSync/LightroomBridge/Receipts"):gmatch("[^/]+") do + directory = LrPathUtils.child(directory, segment) + end + return LrPathUtils.standardizePath(directory) +end + +function Runtime.isSafeReceiptPath(path) + if type(path) ~= "string" or path == "" then return false end + local standardized = LrPathUtils.standardizePath(path) + local prefix = Runtime.receiptDirectory() + if prefix:sub(-1) ~= "/" then prefix = prefix .. "/" end + return standardized:sub(1, #prefix) == prefix +end + +return Runtime diff --git a/LightroomPlugin/RawGeoSync.lrplugin/SHA256.lua b/LightroomPlugin/RawGeoSync.lrplugin/SHA256.lua new file mode 100644 index 0000000..dcfc8b7 --- /dev/null +++ b/LightroomPlugin/RawGeoSync.lrplugin/SHA256.lua @@ -0,0 +1,168 @@ +local SHA256 = {} + +local MOD = 4294967296 +local XOR = {} +local AND = {} + +for left = 0, 15 do + for right = 0, 15 do + local xorValue = 0 + local andValue = 0 + local place = 1 + local a = left + local b = right + for _ = 1, 4 do + local aBit = a % 2 + local bBit = b % 2 + if aBit ~= bBit then xorValue = xorValue + place end + if aBit == 1 and bBit == 1 then andValue = andValue + place end + a = math.floor(a / 2) + b = math.floor(b / 2) + place = place * 2 + end + XOR[left * 16 + right] = xorValue + AND[left * 16 + right] = andValue + end +end + +local function bitop(left, right, lookup) + local result = 0 + local place = 1 + for _ = 1, 8 do + local a = left % 16 + local b = right % 16 + result = result + lookup[a * 16 + b] * place + left = math.floor(left / 16) + right = math.floor(right / 16) + place = place * 16 + end + return result +end + +local function bxor(left, right) return bitop(left, right, XOR) end +local function band(left, right) return bitop(left, right, AND) end +local function bnot(value) return 4294967295 - value end +local function rshift(value, count) return math.floor(value / (2 ^ count)) end +local function lshift(value, count) + return (value % (2 ^ (32 - count))) * (2 ^ count) +end +local function ror(value, count) + return (rshift(value, count) + lshift(value, 32 - count)) % MOD +end +local function bxor3(a, b, c) return bxor(bxor(a, b), c) end +local function add(...) + local result = 0 + for index = 1, select("#", ...) do result = (result + select(index, ...)) % MOD end + return result +end + +local CONSTANTS = { + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, + 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, + 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, + 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, + 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, + 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, + 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, + 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, + 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, +} + +local function wordBytes(value) + return string.char( + rshift(value, 24) % 256, + rshift(value, 16) % 256, + rshift(value, 8) % 256, + value % 256 + ) +end + +local function processBlock(h, block) + local words = {} + for index = 1, 16 do + local offset = (index - 1) * 4 + 1 + local a, b, c, d = block:byte(offset, offset + 3) + words[index] = ((a * 256 + b) * 256 + c) * 256 + d + end + for index = 17, 64 do + local previous = words[index - 15] + local sigma0 = bxor3(ror(previous, 7), ror(previous, 18), rshift(previous, 3)) + previous = words[index - 2] + local sigma1 = bxor3(ror(previous, 17), ror(previous, 19), rshift(previous, 10)) + words[index] = add(words[index - 16], sigma0, words[index - 7], sigma1) + end + + local a, b, c, d = h[1], h[2], h[3], h[4] + local e, f, g, hh = h[5], h[6], h[7], h[8] + for index = 1, 64 do + local sum1 = bxor3(ror(e, 6), ror(e, 11), ror(e, 25)) + local choice = bxor(band(e, f), band(bnot(e), g)) + local temp1 = add(hh, sum1, choice, CONSTANTS[index], words[index]) + local sum0 = bxor3(ror(a, 2), ror(a, 13), ror(a, 22)) + local majority = bxor3(band(a, b), band(a, c), band(b, c)) + local temp2 = add(sum0, majority) + hh, g, f, e, d, c, b, a = g, f, e, add(d, temp1), c, b, a, add(temp1, temp2) + end + h[1], h[2], h[3], h[4] = add(h[1], a), add(h[2], b), add(h[3], c), add(h[4], d) + h[5], h[6], h[7], h[8] = add(h[5], e), add(h[6], f), add(h[7], g), add(h[8], hh) +end + +function SHA256.new() + local context = { + h = { + 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, + 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19, + }, + buffer = "", + byteCount = 0, + finished = false, + } + + function context:update(contents) + if self.finished then error("SHA256 上下文已经结束", 0) end + if type(contents) ~= "string" then error("SHA256 输入必须是字符串", 0) end + self.byteCount = self.byteCount + #contents + local pending = self.buffer .. contents + local position = 1 + while #pending - position + 1 >= 64 do + processBlock(self.h, pending:sub(position, position + 63)) + position = position + 64 + end + self.buffer = pending:sub(position) + return self + end + + function context:finish() + if self.finished then error("SHA256 上下文已经结束", 0) end + self.finished = true + local bitLength = self.byteCount * 8 + local zeroCount = (56 - ((#self.buffer + 1) % 64)) % 64 + local padded = self.buffer + .. string.char(0x80) + .. string.rep("\0", zeroCount) + .. wordBytes(math.floor(bitLength / MOD)) + .. wordBytes(bitLength % MOD) + for position = 1, #padded, 64 do + processBlock(self.h, padded:sub(position, position + 63)) + end + return string.format( + "%08x%08x%08x%08x%08x%08x%08x%08x", + self.h[1], self.h[2], self.h[3], self.h[4], + self.h[5], self.h[6], self.h[7], self.h[8] + ) + end + return context +end + +function SHA256.digest(contents) + return SHA256.new():update(contents):finish() +end + +return SHA256 diff --git a/LightroomPlugin/RawGeoSync.lrplugin/UndoImport.lua b/LightroomPlugin/RawGeoSync.lrplugin/UndoImport.lua new file mode 100644 index 0000000..bfe79f9 --- /dev/null +++ b/LightroomPlugin/RawGeoSync.lrplugin/UndoImport.lua @@ -0,0 +1,98 @@ +local LrDialogs = import "LrDialogs" +local LrFunctionContext = import "LrFunctionContext" +local LrPrefs = import "LrPrefs" +local LrProgressScope = import "LrProgressScope" +local LrTasks = import "LrTasks" + +local Constants = require "Constants" +local Receipt = require "Receipt" +local Runtime = require "Runtime" +local Support = require "ControllerSupport" +local UndoWriter = require "UndoWriter" + +local function readState(path) + if not Runtime.isSafeReceiptPath(path) then error("事务收据路径不安全或已失效", 0) end + local contents, readError = Runtime.receipts.read(path) + if not contents then error("无法读取事务收据:" .. tostring(readError), 0) end + return Receipt.undoState(Receipt.parse(contents)) +end + +local function run() + local prefs = LrPrefs.prefsForPlugin() + local receiptPath = prefs.latestReceiptPath + if not receiptPath then + LrDialogs.message("RawGeoSync", "没有可撤销的 RawGeoSync 导入事务。", "info") + return + end + local state = readState(receiptPath) + if #state.items == 0 and prefs.previousReceiptPath then + receiptPath = prefs.previousReceiptPath + state = readState(receiptPath) + end + + local catalog = Support.catalog() + local catalogToken = catalog:getPropertyForPlugin(_PLUGIN, Constants.CATALOG_TOKEN_FIELD) + if type(state.catalogToken) ~= "string" or state.catalogToken ~= catalogToken then + error("事务收据不属于当前 Lightroom 目录,已拒绝撤销", 0) + end + local preflight = UndoWriter.preflight(state, { + catalog = catalog, + pluginId = _PLUGIN, + }) + if #preflight.eligible == 0 then + LrDialogs.message( + "RawGeoSync", + string.format( + "没有可安全撤销的照片。\n找不到:%d 张\n导入后已改变:%d 张", + preflight.skippedMissing, + preflight.skippedChanged + ), + "warning" + ) + return + end + local answer = LrDialogs.confirm( + "撤销最近一次 RawGeoSync 导入?", + string.format( + "将恢复 %d 张照片导入前的 GPS。另有 %d 张已被外部修改,将安全跳过。", + #preflight.eligible, + preflight.skippedChanged + ), + "撤销", + "取消" + ) + if answer ~= "ok" then return end + + local progress = LrProgressScope { + title = string.format("正在恢复 %d 张照片", #preflight.eligible), + } + progress:setCancelable(true) + local result = UndoWriter.apply(state, preflight, { + catalog = catalog, + pluginId = _PLUGIN, + metadataFields = Constants.METADATA_FIELDS, + receiptPath = receiptPath, + receiptAdapter = Runtime.receipts, + undoID = Runtime.uniqueId("undo"), + progress = progress, + batchSize = 200, + yield = LrTasks.yield, + protectedCall = LrTasks.pcall, + }) + progress:done() + LrDialogs.message( + "RawGeoSync 撤销结果", + string.format( + "已恢复:%d 张\n已被修改而跳过:%d 张\n找不到而跳过:%d 张%s", + result.restored, + result.skippedChanged, + result.skippedMissing, + result.canceled and "\n状态:用户取消" or "" + ), + result.error and "warning" or "info" + ) +end + +LrTasks.startAsyncTask(function() + LrFunctionContext.callWithContext("RawGeoSyncUndo", run) +end) diff --git a/LightroomPlugin/RawGeoSync.lrplugin/UndoWriter.lua b/LightroomPlugin/RawGeoSync.lrplugin/UndoWriter.lua new file mode 100644 index 0000000..f626269 --- /dev/null +++ b/LightroomPlugin/RawGeoSync.lrplugin/UndoWriter.lua @@ -0,0 +1,159 @@ +local Geo = require "Geo" +local Json = require "JSON" +local Receipt = require "Receipt" + +local UndoWriter = {} + +local function fromNullable(value) + if value == Json.null then return nil end + return value +end + +local function locationFromReceipt(value) + if value == Json.null then return nil end + return { + latitude = value.latitude, + longitude = value.longitude, + altitude = fromNullable(value.altitude), + } +end + +local function setLocation(photo, location, altitude) + if location == nil then + photo:setRawMetadata("gps", nil) + else + photo:setRawMetadata("gps", { + latitude = location.latitude, + longitude = location.longitude, + }) + end + photo:setRawMetadata("gpsAltitude", altitude) +end + +function UndoWriter.preflight(state, options) + local result = { eligible = {}, skippedMissing = 0, skippedChanged = 0 } + local candidates = {} + for _, receiptItem in ipairs(state.items) do + local photo = options.catalog:findPhotoByUuid(receiptItem.uuid) + if not photo then + result.skippedMissing = result.skippedMissing + 1 + else + candidates[#candidates + 1] = { photo = photo, receipt = receiptItem } + end + end + local batchSize = options.batchSize or 500 + for startIndex = 1, #candidates, batchSize do + local endIndex = math.min(startIndex + batchSize - 1, #candidates) + local photos = {} + for index = startIndex, endIndex do photos[#photos + 1] = candidates[index].photo end + local rawByPhoto = options.catalog:batchGetRawMetadata(photos, { "gps", "gpsAltitude" }) + local propertiesByPhoto = options.catalog:batchGetPropertyForPlugin( + photos, + options.pluginId, + { "sourceToken" } + ) + for index = startIndex, endIndex do + local candidate = candidates[index] + local raw = rawByPhoto[candidate.photo] or {} + local properties = propertiesByPhoto[candidate.photo] or {} + local afterLocation = locationFromReceipt(candidate.receipt.after.location) + if properties.sourceToken ~= candidate.receipt.sourceToken + or not Geo.same(Geo.fromRaw(raw.gps, raw.gpsAltitude), afterLocation) then + result.skippedChanged = result.skippedChanged + 1 + else + result.eligible[#result.eligible + 1] = candidate + end + end + end + return result +end + +function UndoWriter.apply(state, preflight, options) + local result = { restored = 0, skippedChanged = preflight.skippedChanged, skippedMissing = preflight.skippedMissing, canceled = false } + local batchSize = options.batchSize or 200 + local startIndex = 1 + local batchNumber = 0 + local protectedCall = options.protectedCall or pcall + while startIndex <= #preflight.eligible do + if options.progress:isCanceled() then result.canceled = true break end + batchNumber = batchNumber + 1 + local endIndex = math.min(startIndex + batchSize - 1, #preflight.eligible) + local batchID = string.format("undo-%s-%06d", options.undoID, batchNumber) + Receipt.append(options.receiptPath, { + kind = "undoBatchPrepared", + transactionID = state.transactionID, + undoID = options.undoID, + batchID = batchID, + }, options.receiptAdapter) + + local restoredThisBatch = 0 + local ok, writeError = protectedCall(function() + local executed = false + options.catalog:withWriteAccessDo(options.actionName or "RawGeoSync:撤销 GPS 导入", function() + executed = true + for index = startIndex, endIndex do + if options.progress:isCanceled() then error("__RAWGEOSYNC_CANCEL__", 0) end + local candidate = preflight.eligible[index] + local receiptItem = candidate.receipt + local currentToken = candidate.photo:getPropertyForPlugin(options.pluginId, "sourceToken") + local afterLocation = locationFromReceipt(receiptItem.after.location) + if currentToken == receiptItem.sourceToken and Geo.same(Geo.read(candidate.photo), afterLocation) then + setLocation( + candidate.photo, + locationFromReceipt(receiptItem.before.location), + fromNullable(receiptItem.before.altitude) + ) + for _, field in ipairs(options.metadataFields) do + candidate.photo:setPropertyForPlugin( + options.pluginId, + field, + fromNullable(receiptItem.before.properties[field]) + ) + end + restoredThisBatch = restoredThisBatch + 1 + else + result.skippedChanged = result.skippedChanged + 1 + end + end + end, { timeout = options.writeAccessTimeoutSeconds or 30 }) + if not executed then error("等待 Lightroom 撤销写锁超时", 0) end + end) + if not ok then + Receipt.append(options.receiptPath, { + kind = "undoBatchAborted", + transactionID = state.transactionID, + undoID = options.undoID, + batchID = batchID, + reason = tostring(writeError), + }, options.receiptAdapter) + if tostring(writeError):find("__RAWGEOSYNC_CANCEL__", 1, true) then result.canceled = true + else result.error = tostring(writeError) end + break + end + result.restored = result.restored + restoredThisBatch + Receipt.append(options.receiptPath, { + kind = "undoBatchCommitted", + transactionID = state.transactionID, + undoID = options.undoID, + batchID = batchID, + restored = restoredThisBatch, + }, options.receiptAdapter) + for index = startIndex, endIndex do + options.progress:setPortionComplete(index, #preflight.eligible) + end + startIndex = endIndex + 1 + if options.yield then options.yield() end + end + Receipt.append(options.receiptPath, { + kind = "undoFinished", + transactionID = state.transactionID, + undoID = options.undoID, + restored = result.restored, + skippedChanged = result.skippedChanged, + skippedMissing = result.skippedMissing, + canceled = result.canceled, + }, options.receiptAdapter) + return result +end + +return UndoWriter diff --git a/LightroomPlugin/Tests/run.lua b/LightroomPlugin/Tests/run.lua new file mode 100644 index 0000000..67c0a9d --- /dev/null +++ b/LightroomPlugin/Tests/run.lua @@ -0,0 +1,686 @@ +local script = arg[0] +local testDirectory = script:match("^(.*)/[^/]+$") or "." +local pluginDirectory = testDirectory .. "/../RawGeoSync.lrplugin" +package.path = pluginDirectory .. "/?.lua;" .. package.path + +local Json = require "JSON" +local Manifest = require "Manifest" +local PathPolicy = require "PathPolicy" +local Planner = require "Planner" +local Receipt = require "Receipt" +local CatalogWriter = require "CatalogWriter" +local UndoWriter = require "UndoWriter" +local SHA256 = require "SHA256" + +local metadataDefinition = dofile(pluginDirectory .. "/MetadataDefinition.lua") + +local tests = {} +local passed = 0 + +local function test(name, body) + tests[#tests + 1] = { name = name, body = body } +end + +local function equal(actual, expected, message) + if actual ~= expected then + error(string.format("%s:期望 %s,实际 %s", message or "值不相等", tostring(expected), tostring(actual)), 0) + end +end + +local function truthy(value, message) + if not value then error(message or "期望真值", 0) end +end + +local function fails(body, pattern) + local ok, failure = pcall(body) + if ok then error("期望失败,实际成功", 0) end + if pattern and not tostring(failure):find(pattern, 1, true) then + error("失败信息不包含预期文本:" .. tostring(failure), 0) + end +end + +local function fakeSha(contents) + local accumulator = 2166136261 + for index = 1, #contents do + accumulator = (accumulator + contents:byte(index) * (index + 17)) % 4294967296 + end + return string.format("%08x", accumulator):rep(8) +end + +local function fakeShaNew() + local accumulator = 2166136261 + local position = 0 + return { + update = function(_, contents) + for index = 1, #contents do + accumulator = (accumulator + contents:byte(index) * (position + index + 17)) % 4294967296 + end + position = position + #contents + end, + finish = function() return string.format("%08x", accumulator):rep(8) end, + } +end + +local function memoryLineAdapter(contents) + return { + read = function() error("流式路径不应整体读取文件", 0) end, + parent = function() return "root" end, + openLines = function() + local position = 1 + return { + byteCount = #contents, + hasTrailingLF = contents:sub(-1) == "\n", + nextLine = function() + if position > #contents then return nil end + local newline = contents:find("\n", position, true) + local line = contents:sub(position, newline - 1) + position = newline + 1 + return line + end, + close = function() return true end, + } + end, + } +end + +local function uuid(number) + return string.format("00000000-0000-4000-8000-%012d", number) +end + +local function baseAsset(number, path, altitude) + local location = { + latitude = 10 + (number % 1000) / 10000, + longitude = 20 + (number % 1000) / 10000, + } + if altitude ~= nil then location.altitude = altitude end + return { + type = "asset", + recordID = uuid(number), + relativePath = path, + fileIdentity = { + byteCount = 1000 + number, + exifDateTimeOriginal = "2026:08:08 10:00:00", + }, + correctedCaptureTimeUTC = "2026-08-08T02:00:00Z", + location = location, + decision = { + confidence = "reliable", + method = "nearestTrackPoint", + granularity = "trackPoint", + verification = "automatic", + ruleVersion = "1.0", + }, + } +end + +local function manifestText(assetValues) + local lines = {} + local header = { + type = "header", + format = Manifest.FORMAT, + schemaVersion = { major = 1, minor = 0 }, + manifestID = uuid(900001), + activityID = uuid(900002), + revision = 1, + createdAtUTC = "2026-08-08T03:00:00Z", + appVersion = "0.3.0", + algorithmVersion = "2.0", + recordCount = #assetValues, + rootDisplayName = "activity", + } + lines[1] = Json.encode(header) + for _, asset in ipairs(assetValues) do + local unsigned = Json.encode(asset) + asset.recordDigestSHA256 = fakeSha(unsigned) + lines[#lines + 1] = Json.encode(asset) + end + local payload = table.concat(lines, "\n") .. "\n" + lines[#lines + 1] = Json.encode { + type = "trailer", + recordCount = #assetValues, + payloadSHA256 = fakeSha(payload), + } + return table.concat(lines, "\n") .. "\n" +end + +local function parsedManifest(assetValues) + return Manifest.parse(manifestText(assetValues), fakeSha) +end + +test("JSON 拒绝重复键与非规范空白", function() + fails(function() Json.decode('{"a":1,"a":2}') end, "重复键") + fails(function() Json.decode('{"a": 1}', { canonical = true }) end, "结构性空白") + equal(Json.decode('{"a":"\\ud83d\\ude00"}').a, "😀", "代理项解码") +end) + +test("JSON 规范编码按键排序", function() + equal(Json.encode { z = 1, a = true }, '{"a":true,"z":1}', "键排序") + equal(Json.encode(Json.array()), "[]", "空数组") +end) + +test("纯 Lua SHA-256 通过标准向量", function() + equal(SHA256.digest(""), "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "空字符串") + equal(SHA256.digest("abc"), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", "abc") +end) + +test("Lightroom metadata provider 注册全部私有字段", function() + equal(metadataDefinition.schemaVersion, 1, "schemaVersion") + truthy(type(metadataDefinition.metadataFieldsForPhotos) == "table", "必须使用官方 metadataFieldsForPhotos 键") + equal(#metadataDefinition.metadataFieldsForPhotos, 10, "私有字段数量") + equal(metadataDefinition.metadataFieldsForPhotos[1].id, "sourceToken", "来源字段") + equal(metadataDefinition.metadataFieldsForPhotos[1].title, nil, "字段保持私有") +end) + +test("路径策略拒绝逃逸、反斜杠、空段和控制字符", function() + for _, path in ipairs({ + "/absolute.nef", "../escape.nef", "a//b.nef", "a\\b.nef", + "a/./b.nef", "a\nb.nef", "Cafe\204\129/A.nef", + }) do + local segments = PathPolicy.validateRelative(path) + equal(segments, nil, "应拒绝路径 " .. path) + end + local resolved = PathPolicy.resolve("root", "camera/A.nef", { + child = function(parent, child) return parent .. "/" .. child end, + standardize = function(path) return path end, + }) + equal(resolved, "root/camera/A.nef", "精确路径解析") +end) + +test("清单校验 header、asset、trailer 与双层摘要", function() + local parsed = parsedManifest { baseAsset(1, "Z50/A.NEF") } + equal(#parsed.assets, 1, "记录数量") + equal(parsed.assets[1].relativePath, "Z50/A.NEF", "相对路径") + + local damaged = manifestText { baseAsset(2, "Z50/B.NEF") } + damaged = damaged:gsub('"latitude":10%.0002', '"latitude":10.0003', 1) + fails(function() Manifest.parse(damaged, fakeSha) end, "recordDigestSHA256") +end) + +test("Manifest.read 逐行验证且不整体读取文件", function() + local contents = manifestText { baseAsset(1, "Z50/A.NEF"), baseAsset(2, "Z50/B.NEF") } + local parsed = Manifest.read("root/RawGeoSync.locations.jsonl", memoryLineAdapter(contents), { + digest = fakeSha, + new = fakeShaNew, + }) + equal(#parsed.assets, 2, "流式记录数") + equal(parsed.root, "root", "清单根目录") +end) + +test("Swift 与 Lua 共享规范 golden 清单", function() + local goldenPath = testDirectory .. "/../../MetadataInfrastructure/Tests/Fixtures/RawGeoSync.locations.jsonl" + local file = assert(io.open(goldenPath, "rb")) + local contents = file:read("*a") + file:close() + local parsed = Manifest.parse(contents, SHA256.digest) + equal(#parsed.assets, 1, "golden 记录数") + equal(parsed.assets[1].relativePath, "Z50/SYNTHETIC.NEF", "golden 相对路径") + equal(parsed.trailer.payloadSHA256, "e0bb190c8dcf30f186b91028fe6e823f4adac4d98a1c0eb8f175922cc3df1b54", "golden payload") +end) + +test("清单拒绝重复路径和 CRLF", function() + fails(function() + Manifest.parse(manifestText { + baseAsset(1, "Z50/A.NEF"), + baseAsset(2, "Z50/A.NEF"), + }, fakeSha) + end, "relativePath 重复") + fails(function() + Manifest.parse(manifestText { baseAsset(1, "Z50/A.NEF") }:gsub("\n", "\r\n"), fakeSha) + end, "只允许 LF") +end) + +test("清单拒绝非法 UUID 与不可能日期", function() + local invalidUuid = manifestText { baseAsset(1, "Z50/A.NEF") } + invalidUuid = invalidUuid:gsub( + uuid(900001):gsub("%-", "%%-"), + "0000000-00000-4000-8000-000000900001", + 1 + ) + fails(function() Manifest.parse(invalidUuid, fakeSha) end, "UUID") + + local invalidDate = manifestText { baseAsset(1, "Z50/A.NEF") } + invalidDate = invalidDate:gsub("2026%-08%-08T03:00:00Z", "2026-02-30T03:00:00Z", 1) + fails(function() Manifest.parse(invalidDate, fakeSha) end, "有效的 UTC") +end) + +local Photo = {} +Photo.__index = Photo + +function Photo.new(identifier, gps, altitude) + return setmetatable({ + raw = { uuid = identifier, gps = gps, gpsAltitude = altitude }, + properties = {}, + }, Photo) +end + +function Photo:getRawMetadata(field) return self.raw[field] end + +function Photo:setRawMetadata(field, value) + if self.failNextWrite then + self.failNextWrite = false + error("injected photo write failure", 0) + end + if type(value) == "table" then + self.raw[field] = { latitude = value.latitude, longitude = value.longitude } + else + self.raw[field] = value + end +end + +function Photo:getPropertyForPlugin(_, field) return self.properties[field] end +function Photo:setPropertyForPlugin(_, field, value) self.properties[field] = value end + +local metadataFields = { + "sourceToken", "manifestID", "activityID", "revision", "recordID", + "source", "quality", "verification", "granularity", "appliedAtUTC", +} + +local function planFor(assets, photos, overwrite) + local manifest = parsedManifest(assets) + return Planner.build(manifest, { + resolveExact = function(record) + local photo = photos[record.relativePath] + if not photo then return nil end + return { + photo = photo, + path = "root/" .. record.relativePath, + available = photo.available ~= false, + byteCount = photo.byteCount or record.fileIdentity.byteCount, + uuid = photo.raw.uuid, + } + end, + }, { + pluginId = "plugin", + metadataFields = metadataFields, + overwriteDifferentGps = overwrite, + makeSourceToken = function(record) return "tx:" .. record.recordID end, + }) +end + +test("预检精确分类离线、身份不一致、冲突和缺失", function() + local assets = { + baseAsset(1, "offline.nef"), baseAsset(2, "changed.nef"), + baseAsset(3, "conflict.nef"), baseAsset(4, "missing.nef"), + } + local offline = Photo.new("offline", nil, nil); offline.available = false + local changed = Photo.new("changed", nil, nil); changed.byteCount = 999 + local conflict = Photo.new("conflict", { latitude = 1, longitude = 2 }, nil) + local plan = planFor(assets, { + ["offline.nef"] = offline, + ["changed.nef"] = changed, + ["conflict.nef"] = conflict, + }, false) + equal(plan.counts.offline, 1, "离线") + equal(plan.counts.identityMismatch, 1, "身份不一致") + equal(plan.counts.conflict, 1, "坐标冲突") + equal(plan.counts.notInCatalog, 1, "目录缺失") + equal(#plan.writable, 0, "默认不覆盖") +end) + +test("不同清单路径解析到同一目录路径时拒绝重复处理", function() + local assets = { baseAsset(1, "A.nef"), baseAsset(2, "a.nef") } + local first = Photo.new("same-photo", nil, nil) + local manifest = parsedManifest(assets) + local plan = Planner.build(manifest, { + resolveExact = function(record) + return { + photo = first, + path = "root/A.nef", + available = true, + byteCount = record.fileIdentity.byteCount, + uuid = "same-photo", + } + end, + }, { + pluginId = "plugin", + metadataFields = metadataFields, + overwriteDifferentGps = false, + makeSourceToken = function(record) return "tx:" .. record.recordID end, + }) + equal(#plan.writable, 1, "仅处理一次") + equal(plan.counts.duplicateResolvedPath, 1, "检测解析重复") +end) + +test("目录元数据按 500 张分批预取而非逐字段读取", function() + local previousImport = _G.import + _G.import = function(name) + if name == "LrFileUtils" then return {} end + if name == "LrPathUtils" then return {} end + error("unexpected import: " .. name) + end + package.loaded.CatalogAdapter = nil + local CatalogAdapter = require "CatalogAdapter" + _G.import = previousImport + + local adapter = { resolvedByRecord = {}, resolvedByPhoto = {} } + adapter.resolveExact = function(record) + local cached = adapter.resolvedByRecord[record] + if cached then return cached.result end + local resolved = { photo = record.photo, available = true, path = record.path } + adapter.resolvedByRecord[record] = { result = resolved } + adapter.resolvedByPhoto[record.photo] = { resolved } + return resolved + end + local records = {} + for index = 1, 1201 do + records[index] = { photo = Photo.new("photo-" .. index), path = "root/" .. index } + end + local rawCalls, propertyCalls = 0, 0 + local catalog = { + batchGetRawMetadata = function(_, photos) + rawCalls = rawCalls + 1 + local result = {} + for _, photo in ipairs(photos) do + result[photo] = { uuid = photo.raw.uuid } + end + return result + end, + batchGetPropertyForPlugin = function(_, photos) + propertyCalls = propertyCalls + 1 + local result = {} + for _, photo in ipairs(photos) do result[photo] = {} end + return result + end, + } + CatalogAdapter.prefetch(adapter, catalog, records, "plugin", metadataFields, 500) + equal(rawCalls, 3, "原始元数据批次数") + equal(propertyCalls, 3, "插件元数据批次数") + equal(adapter.resolvedByRecord[records[1201]].result.uuid, "photo-1201", "批量 UUID") +end) + +test("清单无海拔时保留照片现有海拔", function() + local asset = baseAsset(1, "A.nef") + local photo = Photo.new("photo-a", { latitude = 1, longitude = 2 }, 123.5) + local plan = planFor({ asset }, { ["A.nef"] = photo }, true) + equal(plan.writable[1].desired.altitude, 123.5, "有效海拔") +end) + +local function clone(value, seen) + if type(value) ~= "table" then return value end + seen = seen or {} + if seen[value] then return seen[value] end + local copy = {} + seen[value] = copy + for key, child in pairs(value) do copy[clone(key, seen)] = clone(child, seen) end + return copy +end + +local function fakeCatalog(photos) + return { + photos = photos, + withWriteAccessDo = function(self, _, body) + local snapshots = {} + for _, photo in pairs(self.photos) do + snapshots[photo] = { raw = clone(photo.raw), properties = clone(photo.properties) } + end + local ok, failure = pcall(body) + if not ok then + for photo, snapshot in pairs(snapshots) do + photo.raw = snapshot.raw + photo.properties = snapshot.properties + end + error(failure, 0) + end + end, + findPhotoByUuid = function(self, identifier) + for _, photo in pairs(self.photos) do + if photo.raw.uuid == identifier then return photo end + end + return nil + end, + batchGetRawMetadata = function(_, batch, fields) + local result = {} + for _, photo in ipairs(batch) do + result[photo] = {} + for _, field in ipairs(fields) do result[photo][field] = photo.raw[field] end + end + return result + end, + batchGetPropertyForPlugin = function(_, batch, _, fields) + local result = {} + for _, photo in ipairs(batch) do + result[photo] = {} + for _, field in ipairs(fields) do result[photo][field] = photo.properties[field] end + end + return result + end, + } +end + +local function memoryReceiptAdapter() + local storage = {} + return { + storage = storage, + append = function(path, contents) + storage[path] = (storage[path] or "") .. contents + return true + end, + } +end + +local function progress(cancelAt) + return { + calls = 0, + isCanceled = function(self) + self.calls = self.calls + 1 + return cancelAt ~= nil and self.calls >= cancelAt + end, + setPortionComplete = function() end, + } +end + +local function writerOptions(catalog, receiptAdapter, progressValue) + return { + catalog = catalog, + pluginId = "plugin", + metadataFields = metadataFields, + receiptPath = "receipt.jsonl", + receiptAdapter = receiptAdapter, + transactionID = "transaction-1", + catalogToken = "catalog-1", + appliedAtUTC = "2026-08-08T03:00:00Z", + progress = progressValue or progress(), + batchSize = 2, + } +end + +test("批量写入会写 GPS、来源令牌并复读验证", function() + local asset = baseAsset(1, "A.nef") + local photo = Photo.new("photo-a", nil, 88) + local plan = planFor({ asset }, { ["A.nef"] = photo }, false) + local receipts = memoryReceiptAdapter() + local result = CatalogWriter.apply(plan, writerOptions(fakeCatalog({ photo }), receipts, progress())) + equal(result.applied, 1, "写入数量") + equal(result.verified, 1, "验证数量") + equal(photo.raw.gpsAltitude, 88, "保留海拔") + truthy(photo.properties.sourceToken, "来源令牌") + truthy(receipts.storage["receipt.jsonl"]:find("batchCommitted", 1, true), "提交收据") +end) + +test("Lightroom 写入使用可让出保护调用并显式等待目录写锁", function() + local asset = baseAsset(1, "A.nef") + local photo = Photo.new("photo-a", nil, nil) + local plan = planFor({ asset }, { ["A.nef"] = photo }, false) + local catalog = fakeCatalog({ photo }) + local baseWrite = catalog.withWriteAccessDo + local timeoutSeen = nil + catalog.withWriteAccessDo = function(self, actionName, body, timeoutParameters) + timeoutSeen = timeoutParameters and timeoutParameters.timeout or nil + return baseWrite(self, actionName, body) + end + local protectedCalls = 0 + local options = writerOptions(catalog, memoryReceiptAdapter(), progress()) + options.protectedCall = function(body, ...) + protectedCalls = protectedCalls + 1 + return pcall(body, ...) + end + local result = CatalogWriter.apply(plan, options) + equal(result.verified, 1, "验证数量") + equal(timeoutSeen, 30, "写锁等待秒数") + truthy(protectedCalls >= 2, "写入和复读都使用可让出保护调用") +end) + +test("正常写入复读每批只调用两次 batch API", function() + local assets, photos, map = {}, {}, {} + for index = 1, 3 do + local path = string.format("%d.nef", index) + assets[index] = baseAsset(index, path) + photos[index] = Photo.new("photo-" .. index, nil, nil) + map[path] = photos[index] + end + local catalog = fakeCatalog(photos) + local rawCalls, propertyCalls = 0, 0 + local raw = catalog.batchGetRawMetadata + local properties = catalog.batchGetPropertyForPlugin + catalog.batchGetRawMetadata = function(...) + rawCalls = rawCalls + 1 + return raw(...) + end + catalog.batchGetPropertyForPlugin = function(...) + propertyCalls = propertyCalls + 1 + return properties(...) + end + local result = CatalogWriter.apply( + planFor(assets, map, false), + writerOptions(catalog, memoryReceiptAdapter(), progress()) + ) + equal(result.verified, 3, "验证数量") + equal(rawCalls, 2, "每个写入批次一次 raw batch") + equal(propertyCalls, 2, "每个写入批次一次 property batch") +end) + +test("后续批次失败会自动恢复此前成功批次", function() + local assets, photos = {}, {} + for index = 1, 3 do + assets[index] = baseAsset(index, string.format("%d.nef", index)) + photos[index] = Photo.new("photo-" .. index, nil, 50 + index) + end + local map = { ["1.nef"] = photos[1], ["2.nef"] = photos[2], ["3.nef"] = photos[3] } + local plan = planFor(assets, map, false) + photos[3].failNextWrite = true + local result = CatalogWriter.apply(plan, writerOptions(fakeCatalog(photos), memoryReceiptAdapter(), progress())) + equal(result.committed, 2, "此前提交") + equal(result.rolledBack, 2, "自动恢复") + equal(result.applied, 0, "最终零写入") + equal(photos[1].raw.gps, nil, "第一张恢复 GPS") + equal(photos[1].raw.gpsAltitude, 51, "第一张恢复海拔") + equal(photos[1].properties.sourceToken, nil, "第一张恢复来源") +end) + +test("用户取消会自动恢复此前成功批次", function() + local assets, photos, map = {}, {}, {} + for index = 1, 3 do + local path = string.format("%d.nef", index) + assets[index] = baseAsset(index, path) + photos[index] = Photo.new("photo-" .. index, nil, nil) + map[path] = photos[index] + end + local plan = planFor(assets, map, false) + local result = CatalogWriter.apply(plan, writerOptions(fakeCatalog(photos), memoryReceiptAdapter(), progress(5))) + truthy(result.canceled, "应识别取消") + equal(result.applied, 0, "取消后无残留写入") + equal(result.rolledBack, 2, "恢复第一批") +end) + +test("复读 API 失败会自动恢复当前与此前成功批次", function() + local asset = baseAsset(1, "A.nef") + local photo = Photo.new("photo-a", nil, 66) + local plan = planFor({ asset }, { ["A.nef"] = photo }, false) + local catalog = fakeCatalog({ photo }) + catalog.batchGetRawMetadata = function() error("injected batch read failure", 0) end + local result = CatalogWriter.apply(plan, writerOptions(catalog, memoryReceiptAdapter(), progress())) + equal(result.applied, 0, "复读失败后零残留") + equal(result.rolledBack, 1, "恢复已提交照片") + equal(photo.raw.gps, nil, "恢复 GPS") + equal(photo.raw.gpsAltitude, 66, "恢复海拔") +end) + +test("崩溃遗留的不完整收据尾行会被忽略", function() + local adapter = memoryReceiptAdapter() + Receipt.append("r", { + kind = "transactionPrepared", + transactionID = "tx", + catalogToken = "catalog", + manifestID = "manifest", + }, adapter) + adapter.storage.r = adapter.storage.r .. '{"kind":"batchPrepared"' + local events = Receipt.parse(adapter.storage.r) + equal(#events, 1, "仅采用完整物理行") +end) + +test("撤销仅恢复来源令牌与 after GPS 都未改变的照片", function() + local asset = baseAsset(1, "A.nef") + local photo = Photo.new("photo-a", nil, 75) + local plan = planFor({ asset }, { ["A.nef"] = photo }, false) + local receipts = memoryReceiptAdapter() + CatalogWriter.apply(plan, writerOptions(fakeCatalog({ photo }), receipts, progress())) + local state = Receipt.undoState(Receipt.parse(receipts.storage["receipt.jsonl"])) + local catalog = fakeCatalog({ photo }) + local preflight = UndoWriter.preflight(state, { catalog = catalog, pluginId = "plugin" }) + equal(#preflight.eligible, 1, "可撤销") + local result = UndoWriter.apply(state, preflight, { + catalog = catalog, + pluginId = "plugin", + metadataFields = metadataFields, + receiptPath = "receipt.jsonl", + receiptAdapter = receipts, + undoID = "undo-1", + progress = progress(), + batchSize = 200, + }) + equal(result.restored, 1, "恢复数量") + equal(photo.raw.gps, nil, "恢复空 GPS") + equal(photo.raw.gpsAltitude, 75, "恢复旧海拔") + equal(photo.properties.sourceToken, nil, "清除来源令牌") + + local changed = Photo.new("photo-a", { latitude = 1, longitude = 2 }, nil) + changed.properties.sourceToken = "different" + local unsafe = UndoWriter.preflight({ items = { state.items[1] } }, { + catalog = fakeCatalog({ changed }), pluginId = "plugin", + }) + equal(#unsafe.eligible, 0, "外部修改不可撤销") + equal(unsafe.skippedChanged, 1, "安全跳过") +end) + +for _, entry in ipairs(tests) do + local ok, failure = xpcall(entry.body, debug.traceback) + if ok then + passed = passed + 1 + io.write("PASS ", entry.name, "\n") + else + io.stderr:write("FAIL ", entry.name, "\n", tostring(failure), "\n") + end +end + +io.write(string.format("\n%d/%d tests passed\n", passed, #tests)) +if passed ~= #tests then os.exit(1) end + +if arg[1] == "--benchmark" then + local recordCount = tonumber(arg[2]) or 10000 + local assets = {} + for index = 1, recordCount do + assets[index] = baseAsset(index, string.format("Z50/%06d.NEF", index)) + end + local contents = manifestText(assets) + assets = nil + collectgarbage("collect") + local beforeKiB = collectgarbage("count") + local started = os.clock() + local parsed = Manifest.read("root/RawGeoSync.locations.jsonl", memoryLineAdapter(contents), { + digest = fakeSha, + new = fakeShaNew, + }) + local elapsed = os.clock() - started + collectgarbage("collect") + local afterKiB = collectgarbage("count") + io.write(string.format( + "BENCH records=%d bytes=%d luaKiBBefore=%.0f luaKiBAfter=%.0f retainedDeltaKiB=%.0f seconds=%.3f parsed=%d\n", + recordCount, + #contents, + beforeKiB, + afterKiB, + afterKiB - beforeKiB, + elapsed, + #parsed.assets + )) +end diff --git a/MetadataInfrastructure/Sources/MetadataInfrastructure/CatalogBridgeManifest.swift b/MetadataInfrastructure/Sources/MetadataInfrastructure/CatalogBridgeManifest.swift new file mode 100644 index 0000000..5c06e70 --- /dev/null +++ b/MetadataInfrastructure/Sources/MetadataInfrastructure/CatalogBridgeManifest.swift @@ -0,0 +1,732 @@ +import CryptoKit +import Darwin +import Foundation + +public enum CatalogBridgeManifestError: Error, LocalizedError, Equatable, Sendable { + case notAFile(URL) + case unsupportedSchema(major: Int, minor: Int) + case malformedLine(Int) + case unexpectedLine(type: String, line: Int) + case unsafeRelativePath(String) + case duplicateRelativePath(String) + case duplicateRecordID(UUID) + case invalidValue(String) + case recordDigestMismatch(line: Int) + case payloadDigestMismatch + case recordCountMismatch(expected: Int, actual: Int) + case damagedExistingTarget(URL) + + public var errorDescription: String? { + switch self { + case .notAFile(let url): "不是普通文件:\(url.path)" + case .unsupportedSchema(let major, let minor): "不支持的清单版本:\(major).\(minor)" + case .malformedLine(let line): "清单第 \(line) 行不是有效 JSON" + case .unexpectedLine(let type, let line): "清单第 \(line) 行类型不正确:\(type)" + case .unsafeRelativePath(let path): "照片相对路径不安全:\(path)" + case .duplicateRelativePath(let path): "照片相对路径重复:\(path)" + case .duplicateRecordID(let id): "照片记录 ID 重复:\(id.uuidString)" + case .invalidValue(let field): "清单字段无效:\(field)" + case .recordDigestMismatch(let line): "清单第 \(line) 行记录摘要不匹配" + case .payloadDigestMismatch: "清单整体摘要不匹配" + case .recordCountMismatch(let expected, let actual): + "清单记录数不匹配:声明 \(expected),实际 \(actual)" + case .damagedExistingTarget(let url): "已有清单损坏,已拒绝覆盖:\(url.path)" + } + } +} + +public struct CatalogBridgeSchemaVersion: Hashable, Codable, Sendable { + public static let current = CatalogBridgeSchemaVersion(major: 1, minor: 0) + public let major: Int + public let minor: Int + + public init(major: Int, minor: Int) { + self.major = major + self.minor = minor + } +} + +public struct CatalogBridgeManifestHeader: Hashable, Codable, Sendable { + public let type: String + public let format: String + public let schemaVersion: CatalogBridgeSchemaVersion + public let manifestID: UUID + public let activityID: UUID + public let revision: Int + public let createdAtUTC: Date + public let appVersion: String + public let algorithmVersion: String + public let recordCount: Int + public let skippedCount: Int + public let rootDisplayName: String + public let writeAltitude: Bool + public let existingGPSPolicy: String + public let priorPayloadSHA256: String? + + public init( + format: String = CatalogBridgeManifest.formatIdentifier, + schemaVersion: CatalogBridgeSchemaVersion = .current, + manifestID: UUID = UUID(), + activityID: UUID = UUID(), + revision: Int = 1, + createdAtUTC: Date = Date(), + appVersion: String, + algorithmVersion: String, + recordCount: Int, + skippedCount: Int = 0, + rootDisplayName: String, + writeAltitude: Bool = false, + existingGPSPolicy: String = "overwrite", + priorPayloadSHA256: String? = nil + ) { + self.type = "header" + self.format = format + self.schemaVersion = schemaVersion + self.manifestID = manifestID + self.activityID = activityID + self.revision = revision + self.createdAtUTC = createdAtUTC + self.appVersion = appVersion + self.algorithmVersion = algorithmVersion + self.recordCount = recordCount + self.skippedCount = skippedCount + self.rootDisplayName = rootDisplayName + self.writeAltitude = writeAltitude + self.existingGPSPolicy = existingGPSPolicy + self.priorPayloadSHA256 = priorPayloadSHA256 + } +} + +public struct CatalogBridgeFileIdentity: Hashable, Codable, Sendable { + public let byteCount: Int64 + public let exifDateTimeOriginal: String + public let subsecondTimeOriginal: String? + public let offsetTimeOriginal: String? + public let make: String? + public let model: String? + public let serialNumber: String? + public let internalSerialNumber: String? + public let shutterCount: Int? + + public init( + byteCount: Int64, + exifDateTimeOriginal: String, + subsecondTimeOriginal: String? = nil, + offsetTimeOriginal: String? = nil, + make: String? = nil, + model: String? = nil, + serialNumber: String? = nil, + internalSerialNumber: String? = nil, + shutterCount: Int? = nil + ) { + self.byteCount = byteCount + self.exifDateTimeOriginal = exifDateTimeOriginal + self.subsecondTimeOriginal = subsecondTimeOriginal + self.offsetTimeOriginal = offsetTimeOriginal + self.make = make + self.model = model + self.serialNumber = serialNumber + self.internalSerialNumber = internalSerialNumber + self.shutterCount = shutterCount + } +} + +public enum CatalogBridgeVerification: String, Hashable, Codable, Sendable { + case automatic + case userConfirmed + case manual +} + +public struct CatalogBridgeDecision: Hashable, Codable, Sendable { + public let confidence: String + public let method: String + public let granularity: String + public let verification: CatalogBridgeVerification + public let ruleVersion: String + public let estimatedRadiusMeters: Double? + public let temporalDistanceSeconds: Double? + public let evidenceSummary: String? + public let trackFileSHA256: String? + + public init( + confidence: String, + method: String, + granularity: String, + verification: CatalogBridgeVerification, + ruleVersion: String, + estimatedRadiusMeters: Double? = nil, + temporalDistanceSeconds: Double? = nil, + evidenceSummary: String? = nil, + trackFileSHA256: String? = nil + ) { + self.confidence = confidence + self.method = method + self.granularity = granularity + self.verification = verification + self.ruleVersion = ruleVersion + self.estimatedRadiusMeters = estimatedRadiusMeters + self.temporalDistanceSeconds = temporalDistanceSeconds + self.evidenceSummary = evidenceSummary + self.trackFileSHA256 = trackFileSHA256 + } +} + +public struct CatalogBridgeAssetRecord: Hashable, Codable, Sendable, Identifiable { + public let type: String + public let recordID: UUID + public let relativePath: String + public let assetKind: String + public let fileIdentity: CatalogBridgeFileIdentity + public let correctedCaptureTimeUTC: Date + public let location: GPSMetadata + public let decision: CatalogBridgeDecision + public let recordDigestSHA256: String? + + public var id: UUID { recordID } + + public init( + recordID: UUID = UUID(), + relativePath: String, + assetKind: String = "proprietaryRaw", + fileIdentity: CatalogBridgeFileIdentity, + correctedCaptureTimeUTC: Date, + location: GPSMetadata, + decision: CatalogBridgeDecision, + recordDigestSHA256: String? = nil + ) { + self.type = "asset" + self.recordID = recordID + self.relativePath = relativePath + self.assetKind = assetKind + self.fileIdentity = fileIdentity + self.correctedCaptureTimeUTC = correctedCaptureTimeUTC + self.location = location + self.decision = decision + self.recordDigestSHA256 = recordDigestSHA256 + } +} + +public struct CatalogBridgeManifest: Hashable, Sendable { + public static let fileName = "RawGeoSync.locations.jsonl" + public static let formatIdentifier = "com.sssimplec.rawgeosync.locations" + + public let header: CatalogBridgeManifestHeader + public let assets: [CatalogBridgeAssetRecord] + public let payloadSHA256: String + + public init( + header: CatalogBridgeManifestHeader, + assets: [CatalogBridgeAssetRecord], + payloadSHA256: String + ) { + self.header = header + self.assets = assets + self.payloadSHA256 = payloadSHA256 + } +} + +public enum CatalogBridgeWriteResult: Sendable { + case written(CatalogBridgeManifest) + case unchanged(CatalogBridgeManifest) + + public var manifest: CatalogBridgeManifest { + switch self { + case .written(let value), .unchanged(let value): value + } + } +} + +public struct CatalogBridgeExportRequest: Sendable { + public let rootDirectoryURL: URL + public let assets: [CatalogBridgeAssetRecord] + public let appVersion: String + public let algorithmVersion: String + public let skippedCount: Int + public let writeAltitude: Bool + + public init( + rootDirectoryURL: URL, + assets: [CatalogBridgeAssetRecord], + appVersion: String, + algorithmVersion: String, + skippedCount: Int = 0, + writeAltitude: Bool = false + ) { + self.rootDirectoryURL = rootDirectoryURL + self.assets = assets + self.appVersion = appVersion + self.algorithmVersion = algorithmVersion + self.skippedCount = skippedCount + self.writeAltitude = writeAltitude + } +} + +public enum CatalogBridgeExportDisposition: String, Codable, Equatable, Sendable { + case created + case replaced + case unchanged +} + +public struct CatalogBridgeExportResult: Sendable { + public let artifactURL: URL + public let manifest: CatalogBridgeManifest + public let disposition: CatalogBridgeExportDisposition + + public var recordCount: Int { manifest.assets.count } +} + +private struct CatalogBridgeTrailer: Codable { + let type: String + let recordCount: Int + let payloadSHA256: String +} + +private struct LineType: Decodable { let type: String } + +public struct CatalogBridgeManifestStore { + private let fileManager: FileManager + + public init(fileManager: FileManager = .default) { + self.fileManager = fileManager + } + + public static func stableRecordID( + relativePath: String, + fileIdentity: CatalogBridgeFileIdentity + ) throws -> UUID { + try validateRelativePath(relativePath) + var components: [String] = [ + relativePath, + String(fileIdentity.byteCount), + fileIdentity.exifDateTimeOriginal, + ] + components.append(fileIdentity.subsecondTimeOriginal ?? "") + components.append(fileIdentity.offsetTimeOriginal ?? "") + components.append(fileIdentity.make ?? "") + components.append(fileIdentity.model ?? "") + components.append(fileIdentity.serialNumber ?? fileIdentity.internalSerialNumber ?? "") + components.append(fileIdentity.shutterCount.map(String.init) ?? "") + let seed = components.joined(separator: "\u{0}") + var bytes = Array(SHA256.hash(data: Data(seed.utf8)).prefix(16)) + bytes[6] = (bytes[6] & 0x0F) | 0x50 + bytes[8] = (bytes[8] & 0x3F) | 0x80 + return UUID( + uuid: ( + bytes[0], bytes[1], bytes[2], bytes[3], + bytes[4], bytes[5], bytes[6], bytes[7], + bytes[8], bytes[9], bytes[10], bytes[11], + bytes[12], bytes[13], bytes[14], bytes[15] + )) + } + + public func export(_ request: CatalogBridgeExportRequest) throws -> CatalogBridgeExportResult { + let root = request.rootDirectoryURL.standardizedFileURL + let rootValues = try root.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]) + guard rootValues.isDirectory == true, rootValues.isSymbolicLink != true else { + throw CatalogBridgeManifestError.notAFile(root) + } + let target = root.appendingPathComponent(CatalogBridgeManifest.fileName) + let existed = fileManager.fileExists(atPath: target.path) + let header = CatalogBridgeManifestHeader( + appVersion: request.appVersion, + algorithmVersion: request.algorithmVersion, + recordCount: request.assets.count, + skippedCount: max(0, request.skippedCount), + rootDisplayName: root.lastPathComponent, + writeAltitude: request.writeAltitude, + existingGPSPolicy: "overwrite" + ) + for asset in request.assets { + try Self.validateRelativePath(asset.relativePath) + let candidate = asset.relativePath.split(separator: "/").reduce(root) { partial, component in + partial.appendingPathComponent(String(component)) + } + let values = try candidate.resourceValues(forKeys: [ + .isRegularFileKey, .isSymbolicLinkKey, .fileSizeKey, + ]) + guard values.isRegularFile == true, values.isSymbolicLink != true, + Int64(values.fileSize ?? -1) == asset.fileIdentity.byteCount, + candidate.resolvingSymlinksInPath().path.hasPrefix( + root.resolvingSymlinksInPath().path + "/" + ) + else { + throw CatalogBridgeManifestError.invalidValue("asset.fileIdentity") + } + } + let result = try write(header: header, assets: request.assets, to: target) + switch result { + case .unchanged(let manifest): + return CatalogBridgeExportResult( + artifactURL: target, + manifest: manifest, + disposition: .unchanged + ) + case .written(let manifest): + return CatalogBridgeExportResult( + artifactURL: target, + manifest: manifest, + disposition: existed ? .replaced : .created + ) + } + } + + /// Existing identifiers and revision history are maintained automatically. + /// A valid target with identical asset semantics is not rewritten. + public func write( + header requestedHeader: CatalogBridgeManifestHeader, + assets requestedAssets: [CatalogBridgeAssetRecord], + to targetURL: URL + ) throws -> CatalogBridgeWriteResult { + let target = targetURL.standardizedFileURL + guard target.isFileURL else { throw CatalogBridgeManifestError.notAFile(target) } + let existing: CatalogBridgeManifest? + if fileManager.fileExists(atPath: target.path) { + do { existing = try read(from: target) } catch { + throw CatalogBridgeManifestError.damagedExistingTarget(target) + } + } else { + existing = nil + } + + let assets = try normalizedAssets(requestedAssets) + if let existing, semanticAssets(existing.assets) == semanticAssets(assets) { + return .unchanged(existing) + } + + let header = CatalogBridgeManifestHeader( + manifestID: existing?.header.manifestID ?? requestedHeader.manifestID, + activityID: existing?.header.activityID ?? requestedHeader.activityID, + revision: existing.map { $0.header.revision + 1 } ?? 1, + createdAtUTC: requestedHeader.createdAtUTC, + appVersion: requestedHeader.appVersion, + algorithmVersion: requestedHeader.algorithmVersion, + recordCount: assets.count, + skippedCount: requestedHeader.skippedCount, + rootDisplayName: requestedHeader.rootDisplayName, + writeAltitude: requestedHeader.writeAltitude, + existingGPSPolicy: requestedHeader.existingGPSPolicy, + priorPayloadSHA256: existing?.payloadSHA256 + ) + let (data, manifest) = try encodedManifest(header: header, assets: assets) + let parent = target.deletingLastPathComponent() + let parentValues = try parent.resourceValues(forKeys: [.isDirectoryKey]) + guard parentValues.isDirectory == true else { + throw CatalogBridgeManifestError.notAFile(parent) + } + let temporary = parent.appendingPathComponent(".RawGeoSync-\(UUID().uuidString).tmp") + defer { try? fileManager.removeItem(at: temporary) } + guard fileManager.createFile(atPath: temporary.path, contents: nil) else { + throw CatalogBridgeManifestError.notAFile(temporary) + } + let handle = try FileHandle(forWritingTo: temporary) + do { + try handle.write(contentsOf: data) + try handle.synchronize() + try handle.close() + } catch { + try? handle.close() + throw error + } + try fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: temporary.path) + _ = try read(from: temporary) + if fileManager.fileExists(atPath: target.path) { + _ = try fileManager.replaceItemAt(target, withItemAt: temporary) + } else { + try fileManager.moveItem(at: temporary, to: target) + } + let parentDescriptor = open(parent.path, O_RDONLY) + if parentDescriptor >= 0 { + _ = fsync(parentDescriptor) + _ = close(parentDescriptor) + } + return .written(manifest) + } + + public func read(from url: URL) throws -> CatalogBridgeManifest { + let normalized = url.standardizedFileURL + let values = try normalized.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey]) + guard values.isRegularFile == true, values.isSymbolicLink != true else { + throw CatalogBridgeManifestError.notAFile(normalized) + } + let handle = try FileHandle(forReadingFrom: normalized) + defer { try? handle.close() } + var iterator = LineIterator(handle: handle) + guard let first = try iterator.next() else { throw CatalogBridgeManifestError.malformedLine(1) } + let decoder = Self.decoder() + let header: CatalogBridgeManifestHeader + do { header = try decoder.decode(CatalogBridgeManifestHeader.self, from: first) } catch { + throw CatalogBridgeManifestError.malformedLine(1) + } + try validate(header: header) + var payloadHasher = SHA256() + payloadHasher.update(data: first) + payloadHasher.update(data: Data([0x0A])) + var assets: [CatalogBridgeAssetRecord] = [] + assets.reserveCapacity(header.recordCount) + var paths = Set() + var recordIDs = Set() + var lineNumber = 1 + var trailer: CatalogBridgeTrailer? + while let line = try iterator.next() { + lineNumber += 1 + let type: LineType + do { type = try decoder.decode(LineType.self, from: line) } catch { + throw CatalogBridgeManifestError.malformedLine(lineNumber) + } + switch type.type { + case "asset": + guard trailer == nil else { + throw CatalogBridgeManifestError.unexpectedLine(type: type.type, line: lineNumber) + } + let record: CatalogBridgeAssetRecord + do { record = try decoder.decode(CatalogBridgeAssetRecord.self, from: line) } catch { + throw CatalogBridgeManifestError.malformedLine(lineNumber) + } + try validate(record: record, line: lineNumber) + guard paths.insert(record.relativePath).inserted else { + throw CatalogBridgeManifestError.duplicateRelativePath(record.relativePath) + } + guard recordIDs.insert(record.recordID).inserted else { + throw CatalogBridgeManifestError.duplicateRecordID(record.recordID) + } + payloadHasher.update(data: line) + payloadHasher.update(data: Data([0x0A])) + assets.append(record) + case "trailer": + guard trailer == nil else { + throw CatalogBridgeManifestError.unexpectedLine(type: type.type, line: lineNumber) + } + do { trailer = try decoder.decode(CatalogBridgeTrailer.self, from: line) } catch { + throw CatalogBridgeManifestError.malformedLine(lineNumber) + } + default: + throw CatalogBridgeManifestError.unexpectedLine(type: type.type, line: lineNumber) + } + } + guard let trailer else { throw CatalogBridgeManifestError.malformedLine(lineNumber + 1) } + guard header.recordCount == assets.count else { + throw CatalogBridgeManifestError.recordCountMismatch( + expected: header.recordCount, actual: assets.count) + } + guard trailer.recordCount == assets.count else { + throw CatalogBridgeManifestError.recordCountMismatch( + expected: trailer.recordCount, actual: assets.count) + } + let digest = Self.hex(payloadHasher.finalize()) + guard Self.isDigest(trailer.payloadSHA256), digest == trailer.payloadSHA256 else { + throw CatalogBridgeManifestError.payloadDigestMismatch + } + return CatalogBridgeManifest(header: header, assets: assets, payloadSHA256: digest) + } + + private func encodedManifest( + header: CatalogBridgeManifestHeader, + assets: [CatalogBridgeAssetRecord] + ) throws -> (Data, CatalogBridgeManifest) { + let encoder = Self.encoder() + var payload = Data() + func appendLine(_ data: Data) { + payload.append(data) + payload.append(0x0A) + } + appendLine(try encoder.encode(header)) + var encodedAssets: [CatalogBridgeAssetRecord] = [] + encodedAssets.reserveCapacity(assets.count) + for asset in assets { + let record = try withDigest(asset) + appendLine(try encoder.encode(record)) + encodedAssets.append(record) + } + let digest = Self.hex(SHA256.hash(data: payload)) + var output = payload + append( + to: &output, + line: try encoder.encode( + CatalogBridgeTrailer( + type: "trailer", recordCount: encodedAssets.count, payloadSHA256: digest) + )) + return ( + output, + CatalogBridgeManifest(header: header, assets: encodedAssets, payloadSHA256: digest) + ) + } + + private func normalizedAssets(_ assets: [CatalogBridgeAssetRecord]) throws + -> [CatalogBridgeAssetRecord] + { + var paths = Set() + var recordIDs = Set() + var result: [CatalogBridgeAssetRecord] = [] + result.reserveCapacity(assets.count) + for asset in assets { + try validateRecordValues(asset) + guard paths.insert(asset.relativePath).inserted else { + throw CatalogBridgeManifestError.duplicateRelativePath(asset.relativePath) + } + guard recordIDs.insert(asset.recordID).inserted else { + throw CatalogBridgeManifestError.duplicateRecordID(asset.recordID) + } + result.append(try withDigest(asset)) + } + return result.sorted { $0.relativePath < $1.relativePath } + } + + private func withDigest(_ asset: CatalogBridgeAssetRecord) throws -> CatalogBridgeAssetRecord { + let unsigned = CatalogBridgeAssetRecord( + recordID: asset.recordID, + relativePath: asset.relativePath, + assetKind: asset.assetKind, + fileIdentity: asset.fileIdentity, + correctedCaptureTimeUTC: asset.correctedCaptureTimeUTC, + location: asset.location, + decision: asset.decision, + recordDigestSHA256: nil + ) + let digest = Self.hex(SHA256.hash(data: try Self.encoder().encode(unsigned))) + return CatalogBridgeAssetRecord( + recordID: unsigned.recordID, + relativePath: unsigned.relativePath, + assetKind: unsigned.assetKind, + fileIdentity: unsigned.fileIdentity, + correctedCaptureTimeUTC: unsigned.correctedCaptureTimeUTC, + location: unsigned.location, + decision: unsigned.decision, + recordDigestSHA256: digest + ) + } + + private func validate(header: CatalogBridgeManifestHeader) throws { + guard header.type == "header", header.format == CatalogBridgeManifest.formatIdentifier else { + throw CatalogBridgeManifestError.invalidValue("header.format") + } + guard header.schemaVersion.major == 1, header.schemaVersion.minor <= 0 else { + throw CatalogBridgeManifestError.unsupportedSchema( + major: header.schemaVersion.major, minor: header.schemaVersion.minor) + } + guard header.revision >= 1, header.recordCount >= 0, + !header.appVersion.isEmpty, !header.algorithmVersion.isEmpty, + !header.rootDisplayName.isEmpty + else { throw CatalogBridgeManifestError.invalidValue("header") } + if let prior = header.priorPayloadSHA256, !Self.isDigest(prior) { + throw CatalogBridgeManifestError.invalidValue("priorPayloadSHA256") + } + } + + private func validate(record: CatalogBridgeAssetRecord, line: Int) throws { + try validateRecordValues(record) + guard let expected = record.recordDigestSHA256, Self.isDigest(expected) else { + throw CatalogBridgeManifestError.recordDigestMismatch(line: line) + } + let actual = try withDigest(record).recordDigestSHA256 + guard actual == expected else { + throw CatalogBridgeManifestError.recordDigestMismatch(line: line) + } + } + + private func validateRecordValues(_ record: CatalogBridgeAssetRecord) throws { + guard record.type == "asset" else { + throw CatalogBridgeManifestError.invalidValue("asset.type") + } + try Self.validateRelativePath(record.relativePath) + guard record.fileIdentity.byteCount > 0, + !record.fileIdentity.exifDateTimeOriginal.isEmpty, + !record.assetKind.isEmpty, + record.location.latitude.isFinite, (-90...90).contains(record.location.latitude), + record.location.longitude.isFinite, (-180...180).contains(record.location.longitude), + record.location.altitude?.isFinite != false, + record.decision.estimatedRadiusMeters.map({ $0.isFinite && $0 >= 0 }) != false, + record.decision.temporalDistanceSeconds?.isFinite != false, + !record.decision.confidence.isEmpty, !record.decision.method.isEmpty, + !record.decision.granularity.isEmpty, !record.decision.ruleVersion.isEmpty + else { throw CatalogBridgeManifestError.invalidValue("asset") } + if let digest = record.decision.trackFileSHA256, !Self.isDigest(digest) { + throw CatalogBridgeManifestError.invalidValue("trackFileSHA256") + } + } + + public static func validateRelativePath(_ path: String) throws { + let normalized = path.precomposedStringWithCanonicalMapping + guard path == normalized, !path.isEmpty, !path.hasPrefix("/"), !path.contains("\\"), + !path.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains) + else { throw CatalogBridgeManifestError.unsafeRelativePath(path) } + let components = path.split(separator: "/", omittingEmptySubsequences: false) + guard !components.isEmpty, + components.allSatisfy({ !$0.isEmpty && $0 != "." && $0 != ".." }) + else { throw CatalogBridgeManifestError.unsafeRelativePath(path) } + } + + private struct SemanticAsset: Equatable { + let relativePath: String + let assetKind: String + let fileIdentity: CatalogBridgeFileIdentity + let correctedCaptureTimeUTC: Date + let location: GPSMetadata + let decision: CatalogBridgeDecision + } + + private func semanticAssets(_ assets: [CatalogBridgeAssetRecord]) -> [SemanticAsset] { + assets.sorted { $0.relativePath < $1.relativePath }.map { asset in + SemanticAsset( + relativePath: asset.relativePath, + assetKind: asset.assetKind, + fileIdentity: asset.fileIdentity, + correctedCaptureTimeUTC: asset.correctedCaptureTimeUTC, + location: asset.location, + decision: asset.decision + ) + } + } + + private static func encoder() -> JSONEncoder { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + encoder.dateEncodingStrategy = .iso8601 + return encoder + } + + private static func decoder() -> JSONDecoder { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return decoder + } + + private static func isDigest(_ value: String) -> Bool { + value.count == 64 + && value.unicodeScalars.allSatisfy { + (48...57).contains($0.value) || (97...102).contains($0.value) + } + } + + private static func hex(_ digest: D) -> String where D.Element == UInt8 { + digest.map { String(format: "%02x", $0) }.joined() + } + + private func append(to output: inout Data, line: Data) { + output.append(line) + output.append(0x0A) + } +} + +private struct LineIterator { + let handle: FileHandle + var buffer = Data() + var reachedEOF = false + + mutating func next() throws -> Data? { + while true { + if let newline = buffer.firstIndex(of: 0x0A) { + let line = Data(buffer[.. CatalogBridgeExportRequest { + CatalogBridgeExportRequest( + rootDirectoryURL: root, + assets: try paths.map(record(path:)), + appVersion: "0.3.0-test", + algorithmVersion: "2.0-test", + skippedCount: 2, + writeAltitude: false + ) + } + + func record(path: String) throws -> CatalogBridgeAssetRecord { + let url = root.appendingPathComponent(path) + let size = (try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize).map(Int64.init) ?? 1 + let identity = CatalogBridgeFileIdentity( + byteCount: size, + exifDateTimeOriginal: "2030:01:02 03:04:05", + subsecondTimeOriginal: "12", + offsetTimeOriginal: "+08:00", + make: "Example", + model: "Camera", + serialNumber: "fixture", + shutterCount: 10 + ) + return CatalogBridgeAssetRecord( + recordID: try CatalogBridgeManifestStore.stableRecordID( + relativePath: path, + fileIdentity: identity + ), + relativePath: path, + fileIdentity: identity, + correctedCaptureTimeUTC: Date(timeIntervalSince1970: 1_893_456_000), + location: try GPSMetadata(latitude: 12.25, longitude: 34.5), + decision: CatalogBridgeDecision( + confidence: "reliable", + method: "interpolatedTrack", + granularity: "track", + verification: .automatic, + ruleVersion: "test" + ) + ) + } +} diff --git a/README.md b/README.md index 6dcb99f..1d386d6 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,27 @@ # RawGeoSync -RawGeoSync 是一款离线 macOS 应用。它把相机照片的拍摄时间与 GPX 轨迹进行匹配,在用户预览和确认后,将 GPS 写入 Lightroom 可读取的 XMP sidecar。应用永远不修改相机 RAW 文件。 +RawGeoSync 是一款离线 macOS 应用。它把相机照片的拍摄时间与 GPX 轨迹进行匹配,在用户预览和确认后生成一份位置清单,再由配套的 Lightroom Classic 插件批量写入 Catalog。兼容模式仍可写入 Lightroom 可读取的 XMP sidecar;两种模式都永远不修改相机 RAW 文件。 ## 核心能力 -- 读取“一生足迹”导出的 GPX 1.0/1.1 轨迹。 +- 支持选择或拖入单个 GPX 文件,也可递归读取目录内“一生足迹”导出的 GPX 1.0/1.1 轨迹。 - 读取 Nikon Z50 NEF 的原始拍摄时间,并对常见 RAW、DNG、JPEG、TIFF 提供实验性扫描。 - 支持 IANA 时区和相机时钟秒级偏移。 - 区分可靠匹配、待确认匹配、停留候选和缺轨。 - 在表格与地图上批量复核位置。 -- 原子创建或合并同名 XMP,保留 Lightroom 已有编辑。 -- 支持幂等写入、冲突检测、事务记录和安全撤销。 +- 默认每个照片根目录只生成一个 `RawGeoSync.locations.jsonl`,避免逐照片 sidecar 带来的文件数量翻倍。 +- 配套 Lightroom Classic 插件支持预检、批量写入、复读验证和跨重启整批撤销。 +- 兼容模式继续原子创建或合并同名 XMP,保留 Lightroom 已有编辑。 v0.2 的设计在此基础上引入可追溯的多来源证据链:照片自带 GPS、GPX、相机定位、同一拍摄 burst、照片序列、跨相机锚点和活动区都可以提供候选。弱候选只补足缺失,不能覆盖强候选;冲突、传播跳数和用户确认会保留在本地事务记录中。详细规则见 [ADR 0002](Docs/Decisions/0002-matching-v2.md)。 ## 数据安全原则 -- RAW 只读,写入接口只接受 XMP sidecar 目标。 +- RAW 只读;默认只写单一桥接清单和 Lightroom Catalog,兼容接口只接受 XMP sidecar 目标。 - 分析默认为 dry run;只有用户确认后才写入。 - 不上传照片、轨迹、坐标或日志,不做反向地理编码。 - MapKit 地图仅在地图可见时连接 Apple 获取地图瓦片。 -- 真实 GPX、RAW 和本地测试副本都被 Git 忽略。 +- 真实 GPX、RAW、位置清单和本地测试副本都被 Git 忽略。 ## 开发环境 @@ -28,6 +29,7 @@ v0.2 的设计在此基础上引入可追溯的多来源证据链:照片自带 - Xcode 26.3+ - Swift 6(严格并发检查) - ExifTool 固定随应用资源分发,不要求用户安装 Homebrew +- Lightroom Classic 15.4.1+(使用默认 Catalog Bridge 时) 首次构建前,确保活动开发目录为: @@ -64,7 +66,17 @@ sudo xcode-select -s /Applications/Xcode.app/Contents/Developer ## 使用建议 -推荐在 Lightroom 导入或修改 sidecar 前执行 RawGeoSync。若照片或 XMP 在预览后发生变化,应用会把它标记为冲突并跳过。已有不同 GPS 默认不会覆盖。 +默认工作流: + +1. 在 RawGeoSync 选择 GPX 和照片根目录,分析并勾选要应用位置的照片。 +2. 生成照片根目录中的 `RawGeoSync.locations.jsonl`。 +3. 先把这些照片导入 Lightroom Classic。 +4. 安装随 App 提供的 RawGeoSync 插件,在“图库 → 插件增效工具”中导入清单。 +5. 检查插件预览后应用;不同的现有 GPS 会按本次清单覆盖,离线照片会跳过。 + +Lightroom 开启“自动将更改写入 XMP”时,Lightroom 自己仍可能创建 sidecar;插件无法通过公开 SDK 可靠关闭或检测此设置。要保持每个照片目录只有一份清单,请在 Lightroom 中关闭该选项。 + +传统 XMP 模式建议在 Lightroom 导入或修改 sidecar 前执行。若照片或 XMP 在预览后发生变化,应用会把它标记为冲突并跳过。 分析完成后,只有“可靠”结果默认勾选写入;停留候选、最近点和其他待确认结果必须按区间复核并主动勾选。写入前应用会展示创建、更新、已应用与冲突数量。撤销仅在 sidecar 未被 Lightroom 等程序继续修改时执行,避免抹掉后续编辑。 @@ -92,6 +104,8 @@ RawGeoSync 使用 MIT License。内置 ExifTool 及其 Perl 库遵循上游各 - [安全策略](SECURITY.md) - [隐私说明](Docs/PRIVACY.md) - [测试与验收](Docs/TESTING.md) +- [Lightroom Catalog Bridge 使用指南](Docs/LIGHTROOM_BRIDGE.md) - [发布清单](Docs/RELEASE.md) - [匹配 v2 决策](Docs/Decisions/0002-matching-v2.md) +- [Lightroom Catalog Bridge 决策](Docs/Decisions/0003-lightroom-catalog-bridge.md) - [更新日志](CHANGELOG.md) diff --git a/RawGeoSync.xcodeproj/project.pbxproj b/RawGeoSync.xcodeproj/project.pbxproj index ba6c952..c4e180a 100644 --- a/RawGeoSync.xcodeproj/project.pbxproj +++ b/RawGeoSync.xcodeproj/project.pbxproj @@ -28,6 +28,8 @@ A10000000000000000000119 /* RawGeoCore in Frameworks */ = {isa = PBXBuildFile; productRef = A10000000000000000000601 /* RawGeoCore */; }; A10000000000000000000120 /* MetadataInfrastructure in Frameworks */ = {isa = PBXBuildFile; productRef = A10000000000000000000602 /* MetadataInfrastructure */; }; A10000000000000000000121 /* WorkspaceSelectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000217 /* WorkspaceSelectionTests.swift */; }; + A10000000000000000000122 /* LightroomPluginInstaller.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000219 /* LightroomPluginInstaller.swift */; }; + A10000000000000000000123 /* RawGeoSync.lrplugin in Resources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000220 /* RawGeoSync.lrplugin */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -58,6 +60,8 @@ A10000000000000000000216 /* RawGeoSyncSmoke */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = RawGeoSyncSmoke; sourceTree = BUILT_PRODUCTS_DIR; }; A10000000000000000000217 /* WorkspaceSelectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceSelectionTests.swift; sourceTree = ""; }; A10000000000000000000218 /* RawGeoSyncAppTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RawGeoSyncAppTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + A10000000000000000000219 /* LightroomPluginInstaller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LightroomPluginInstaller.swift; sourceTree = ""; }; + A10000000000000000000220 /* RawGeoSync.lrplugin */ = {isa = PBXFileReference; lastKnownFileType = folder; name = RawGeoSync.lrplugin; path = LightroomPlugin/RawGeoSync.lrplugin; sourceTree = SOURCE_ROOT; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -96,6 +100,7 @@ A10000000000000000000402 /* RawGeoSyncApp */, A10000000000000000000410 /* Tools */, A10000000000000000000412 /* RawGeoSyncAppTests */, + A10000000000000000000220 /* RawGeoSync.lrplugin */, A10000000000000000000214 /* ExifTool */, A10000000000000000000407 /* Frameworks */, A10000000000000000000408 /* Products */, @@ -127,6 +132,7 @@ children = ( A10000000000000000000203 /* GeoWorkflowService.swift */, A10000000000000000000213 /* LiveGeoWorkflowService.swift */, + A10000000000000000000219 /* LightroomPluginInstaller.swift */, ); path = Services; sourceTree = ""; @@ -312,6 +318,7 @@ buildActionMask = 2147483647; files = ( A10000000000000000000114 /* ExifTool in Resources */, + A10000000000000000000123 /* RawGeoSync.lrplugin in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -343,6 +350,7 @@ A10000000000000000000108 /* AnalysisWorkspaceView.swift in Sources */, A10000000000000000000109 /* ApplyResultView.swift in Sources */, A10000000000000000000113 /* LiveGeoWorkflowService.swift in Sources */, + A10000000000000000000122 /* LightroomPluginInstaller.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -484,7 +492,7 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_TEAM = ""; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; @@ -495,7 +503,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 0.2.0; + MARKETING_VERSION = 0.3.0; PRODUCT_BUNDLE_IDENTIFIER = com.sssimplec.RawGeoSync; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = macosx; @@ -510,7 +518,7 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_TEAM = ""; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; @@ -521,7 +529,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 0.2.0; + MARKETING_VERSION = 0.3.0; PRODUCT_BUNDLE_IDENTIFIER = com.sssimplec.RawGeoSync; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = macosx; diff --git a/RawGeoSyncApp/Models/WorkflowModels.swift b/RawGeoSyncApp/Models/WorkflowModels.swift index 69ad47f..5e4eb12 100644 --- a/RawGeoSyncApp/Models/WorkflowModels.swift +++ b/RawGeoSyncApp/Models/WorkflowModels.swift @@ -20,7 +20,7 @@ enum WorkflowStage: Int, CaseIterable, Identifiable, Sendable { switch self { case .sources: "选择轨迹与照片" case .analysis: "预览并修正匹配" - case .results: "验证写入结果" + case .results: "查看输出结果" } } @@ -34,16 +34,16 @@ enum WorkflowStage: Int, CaseIterable, Identifiable, Sendable { } struct SourceConfiguration: Equatable, Sendable { - var gpxDirectoryURL: URL? + var gpxSourceURL: URL? var photoDirectoryURL: URL? var timeZoneIdentifier = "Asia/Shanghai" var cameraClockOffsetSeconds = 0 var cameraClockOffsetsByID: [String: Int] = [:] var writeAltitude = false var matchingStrategy: MatchingStrategy = .coverage - var outputMode: OutputMode = .xmpSidecar + var outputMode: OutputMode = .lightroomCatalogBridge - var isReady: Bool { gpxDirectoryURL != nil && photoDirectoryURL != nil } + var isReady: Bool { gpxSourceURL != nil && photoDirectoryURL != nil } var timeZone: TimeZone { TimeZone(identifier: timeZoneIdentifier) ?? TimeZone(secondsFromGMT: 0)! @@ -78,10 +78,33 @@ enum MatchingStrategy: String, CaseIterable, Identifiable, Sendable { } enum OutputMode: String, CaseIterable, Identifiable, Sendable { + case lightroomCatalogBridge case xmpSidecar var id: String { rawValue } - var title: String { "专有 RAW 的 XMP Sidecar" } + + var title: String { + switch self { + case .lightroomCatalogBridge: "Lightroom Classic 单清单" + case .xmpSidecar: "XMP Sidecar(兼容模式)" + } + } + + var detail: String { + switch self { + case .lightroomCatalogBridge: + "在照片目录只生成一份位置清单,再由 Lightroom Classic 插件批量写入目录" + case .xmpSidecar: + "为每张专有 RAW 创建或更新同名 XMP;适合不依赖 Lightroom 目录的工作流" + } + } + + var actionTitle: String { + switch self { + case .lightroomCatalogBridge: "导出清单" + case .xmpSidecar: "写入 XMP" + } + } } struct GeoCoordinate: Hashable, Codable, Sendable { @@ -209,6 +232,7 @@ enum MatchMethod: String, Sendable { enum VerificationState: String, Sendable { case pending + case exported case verified case skipped case failed @@ -217,6 +241,7 @@ enum VerificationState: String, Sendable { var title: String { switch self { case .pending: "等待应用" + case .exported: "已写入清单" case .verified: "已复读验证" case .skipped: "已跳过" case .failed: "失败" @@ -225,6 +250,19 @@ enum VerificationState: String, Sendable { } } +struct PhotoIdentity: Hashable, Sendable { + var relativePath: String + var fileSize: Int64? + var exifDateTimeOriginal: String + var subsecondTimeOriginal: String? + var offsetTimeOriginal: String? + var cameraMake: String? + var cameraModel: String? + var cameraSerialNumber: String? + var cameraInternalSerialNumber: String? + var shutterCount: Int? +} + enum SourceLocationAccuracy: Hashable, Sendable { case meters(Double) case notProvided @@ -247,6 +285,7 @@ enum SourceLocationAccuracy: Hashable, Sendable { struct PhotoMatch: Identifiable, Hashable, Sendable { let id: String var fileURL: URL + var identity: PhotoIdentity? = nil var capturedAt: Date var previousTrackPoint: GeoCoordinate? var nextTrackPoint: GeoCoordinate? @@ -342,7 +381,11 @@ struct ApplicationReport: Sendable { var skippedCount: Int var failedCount: Int var outputDirectoryURL: URL? + var outputMode: OutputMode = .xmpSidecar + var artifactURL: URL? = nil var isUndone = false + + var canUndo: Bool { outputMode == .xmpSidecar && transactionID != nil && !isUndone } } struct WritePreview: Identifiable, Sendable { @@ -353,12 +396,34 @@ struct WritePreview: Identifiable, Sendable { var alreadyAppliedCount: Int var conflictCount: Int var conflictFileURLs: Set = [] + var outputMode: OutputMode = .xmpSidecar + var artifactURL: URL? = nil var writableCount: Int { createCount + updateCount } + var title: String { + switch outputMode { + case .lightroomCatalogBridge: "确认单清单导出计划" + case .xmpSidecar: "确认 XMP 写入计划" + } + } + + var confirmTitle: String { + switch outputMode { + case .lightroomCatalogBridge: "确认导出" + case .xmpSidecar: "确认写入" + } + } + var message: String { - "将新建 \(createCount) 个、更新 \(updateCount) 个 XMP;" - + "\(alreadyAppliedCount) 个已包含相同位置,\(conflictCount) 个冲突将跳过。" + switch outputMode { + case .lightroomCatalogBridge: + "将把 \(writableCount) 张已勾选照片写入一份 Lightroom Classic 位置清单;" + + "\(alreadyAppliedCount) 张内容未变化,\(conflictCount) 张因身份冲突将跳过。" + case .xmpSidecar: + "将新建 \(createCount) 个、更新 \(updateCount) 个 XMP;" + + "\(alreadyAppliedCount) 个已包含相同位置,\(conflictCount) 个冲突将跳过。" + } } } diff --git a/RawGeoSyncApp/Services/GeoWorkflowService.swift b/RawGeoSyncApp/Services/GeoWorkflowService.swift index c2d6f94..54034d4 100644 --- a/RawGeoSyncApp/Services/GeoWorkflowService.swift +++ b/RawGeoSyncApp/Services/GeoWorkflowService.swift @@ -62,7 +62,11 @@ struct DemoGeoWorkflowService: GeoWorkflowServicing { createCount: selected, updateCount: 0, alreadyAppliedCount: 0, - conflictCount: 0 + conflictCount: 0, + outputMode: configuration.outputMode, + artifactURL: configuration.outputMode == .lightroomCatalogBridge + ? configuration.photoDirectoryURL?.appendingPathComponent("RawGeoSync.locations.jsonl") + : nil ) } @@ -83,11 +87,13 @@ struct DemoGeoWorkflowService: GeoWorkflowServicing { continuation.yield( .progress( fraction: fraction, - message: "验证 \(match.fileName) 的 XMP…" + message: configuration.outputMode == .lightroomCatalogBridge + ? "整理 \(match.fileName) 的清单记录…" : "验证 \(match.fileName) 的 XMP…" ) ) if let targetIndex = updated.firstIndex(where: { $0.id == match.id }) { - updated[targetIndex].verification = .verified + updated[targetIndex].verification = + configuration.outputMode == .lightroomCatalogBridge ? .exported : .verified } try await Task.sleep(for: .milliseconds(22)) } @@ -105,7 +111,12 @@ struct DemoGeoWorkflowService: GeoWorkflowServicing { verifiedCount: eligible.count, skippedCount: matches.count - eligible.count, failedCount: 0, - outputDirectoryURL: configuration.photoDirectoryURL + outputDirectoryURL: configuration.photoDirectoryURL, + outputMode: configuration.outputMode, + artifactURL: configuration.outputMode == .lightroomCatalogBridge + ? configuration.photoDirectoryURL?.appendingPathComponent( + "RawGeoSync.locations.jsonl" + ) : nil ) continuation.yield(.completed(matches: updated, report: report)) continuation.finish() diff --git a/RawGeoSyncApp/Services/LightroomPluginInstaller.swift b/RawGeoSyncApp/Services/LightroomPluginInstaller.swift new file mode 100644 index 0000000..8bc72e1 --- /dev/null +++ b/RawGeoSyncApp/Services/LightroomPluginInstaller.swift @@ -0,0 +1,184 @@ +import Foundation + +enum LightroomPluginInstallationStatus: Equatable, Sendable { + case checking + case unavailable + case notInstalled + case updateAvailable + case installed + + var title: String { + switch self { + case .checking: "正在检查插件…" + case .unavailable: "当前构建未包含插件" + case .notInstalled: "插件尚未安装" + case .updateAvailable: "插件可更新" + case .installed: "插件已安装" + } + } + + var actionTitle: String? { + switch self { + case .notInstalled: "安装插件" + case .updateAvailable: "更新插件" + case .checking, .unavailable, .installed: nil + } + } +} + +struct LightroomPluginInstaller: Sendable { + static let pluginDirectoryName = "RawGeoSync.lrplugin" + static let toolkitIdentifier = "com.sssimplec.rawgeosync.lightroom" + + let bundledPluginURL: URL? + let modulesDirectoryURL: URL + + init( + bundle: Bundle = .main, + applicationSupportURL: URL? = nil + ) { + bundledPluginURL = + bundle.url(forResource: "RawGeoSync", withExtension: "lrplugin") + ?? bundle.resourceURL?.appendingPathComponent(Self.pluginDirectoryName, isDirectory: true) + let root = + applicationSupportURL + ?? FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent( + "Library/Application Support", + isDirectory: true + ) + modulesDirectoryURL = + root + .appendingPathComponent("Adobe/Lightroom/Modules", isDirectory: true) + } + + init(bundledPluginURL: URL?, modulesDirectoryURL: URL) { + self.bundledPluginURL = bundledPluginURL?.standardizedFileURL + self.modulesDirectoryURL = modulesDirectoryURL.standardizedFileURL + } + + var installedPluginURL: URL { + modulesDirectoryURL.appendingPathComponent(Self.pluginDirectoryName, isDirectory: true) + } + + func status() -> LightroomPluginInstallationStatus { + guard let source = validBundledPluginURL() else { return .unavailable } + guard FileManager.default.fileExists(atPath: installedPluginURL.path) else { + return .notInstalled + } + guard validInstalledPluginURL() != nil else { return .updateAvailable } + if let sourceVersion = pluginVersion(at: source), + let installedVersion = pluginVersion(at: installedPluginURL) + { + return sourceVersion == installedVersion ? .installed : .updateAvailable + } + let sourceInfo = try? Data(contentsOf: source.appendingPathComponent("Info.lua")) + let installedInfo = try? Data(contentsOf: installedPluginURL.appendingPathComponent("Info.lua")) + return sourceInfo != nil && sourceInfo == installedInfo ? .installed : .updateAvailable + } + + @discardableResult + func installOrUpdate() throws -> URL { + guard let source = validBundledPluginURL() else { + throw WorkflowFailure(message: "当前 RawGeoSync 构建中没有可安装的 Lightroom Classic 插件。") + } + + let fileManager = FileManager.default + try fileManager.createDirectory(at: modulesDirectoryURL, withIntermediateDirectories: true) + let modulesValues = try modulesDirectoryURL.resourceValues(forKeys: [ + .isDirectoryKey, .isSymbolicLinkKey, + ]) + guard modulesValues.isDirectory == true, modulesValues.isSymbolicLink != true else { + throw WorkflowFailure(message: "Lightroom Modules 路径不是安全的本地目录,已停止安装。") + } + + if fileManager.fileExists(atPath: installedPluginURL.path) { + guard validInstalledPluginURL() != nil else { + throw WorkflowFailure(message: "目标插件路径已被未知文件或符号链接占用,已停止覆盖。") + } + } + + let stagingURL = modulesDirectoryURL.appendingPathComponent( + ".RawGeoSync-install-\(UUID().uuidString).lrplugin", + isDirectory: true + ) + let backupURL = modulesDirectoryURL.appendingPathComponent( + ".RawGeoSync-backup-\(UUID().uuidString).lrplugin", + isDirectory: true + ) + var movedExistingToBackup = false + defer { + try? fileManager.removeItem(at: stagingURL) + if fileManager.fileExists(atPath: backupURL.path) { + try? fileManager.removeItem(at: backupURL) + } + } + + try fileManager.copyItem(at: source, to: stagingURL) + guard validPluginDirectory(at: stagingURL) else { + throw WorkflowFailure(message: "内置插件缺少有效的 Info.lua,已停止安装。") + } + + do { + if fileManager.fileExists(atPath: installedPluginURL.path) { + try fileManager.moveItem(at: installedPluginURL, to: backupURL) + movedExistingToBackup = true + } + try fileManager.moveItem(at: stagingURL, to: installedPluginURL) + if movedExistingToBackup { + try fileManager.removeItem(at: backupURL) + } + return installedPluginURL + } catch { + if movedExistingToBackup, + !fileManager.fileExists(atPath: installedPluginURL.path), + fileManager.fileExists(atPath: backupURL.path) + { + try? fileManager.moveItem(at: backupURL, to: installedPluginURL) + } + throw WorkflowFailure(message: "Lightroom Classic 插件安装失败:\(error.localizedDescription)") + } + } + + private func validBundledPluginURL() -> URL? { + guard let bundledPluginURL, validPluginDirectory(at: bundledPluginURL) else { return nil } + return bundledPluginURL + } + + private func validInstalledPluginURL() -> URL? { + validPluginDirectory(at: installedPluginURL) ? installedPluginURL : nil + } + + private func validPluginDirectory(at url: URL) -> Bool { + guard let values = try? url.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]), + values.isDirectory == true, + values.isSymbolicLink != true + else { return false } + let infoURL = url.appendingPathComponent("Info.lua", isDirectory: false) + guard + let infoValues = try? infoURL.resourceValues( + forKeys: [.isRegularFileKey, .isSymbolicLinkKey] + ) + else { return false } + guard infoValues.isRegularFile == true, infoValues.isSymbolicLink != true, + let info = try? String(contentsOf: infoURL, encoding: .utf8) + else { return false } + return info.contains(Self.toolkitIdentifier) + } + + private func pluginVersion(at pluginURL: URL) -> String? { + let infoURL = pluginURL.appendingPathComponent("Info.lua", isDirectory: false) + guard let contents = try? String(contentsOf: infoURL, encoding: .utf8) else { return nil } + let patterns = ["VERSION.major", "VERSION.minor", "VERSION.revision", "VERSION.build"] + let values = patterns.map { key -> String in + guard let range = contents.range(of: key), + let equals = contents[range.upperBound...].firstIndex(of: "=") + else { return "" } + let suffix = contents[contents.index(after: equals)...] + .trimmingCharacters(in: .whitespaces) + return suffix.prefix { $0.isNumber }.description + } + let joined = values.joined(separator: ".") + return joined == "..." ? nil : joined + } +} diff --git a/RawGeoSyncApp/Services/LiveGeoWorkflowService.swift b/RawGeoSyncApp/Services/LiveGeoWorkflowService.swift index bca2b6c..c67e536 100644 --- a/RawGeoSyncApp/Services/LiveGeoWorkflowService.swift +++ b/RawGeoSyncApp/Services/LiveGeoWorkflowService.swift @@ -24,10 +24,10 @@ struct LiveGeoWorkflowService: GeoWorkflowServicing { AsyncThrowingStream { continuation in let task = Task.detached(priority: .userInitiated) { do { - guard let gpxDirectoryURL = configuration.gpxDirectoryURL, + guard let gpxSourceURL = configuration.gpxSourceURL, let photoDirectoryURL = configuration.photoDirectoryURL else { - throw WorkflowFailure(message: "请先选择 GPX 目录和照片目录。") + throw WorkflowFailure(message: "请先选择 GPX 文件或目录,以及照片目录。") } continuation.yield(.progress(fraction: 0.05, message: "检查内置 ExifTool…")) @@ -69,14 +69,14 @@ struct LiveGeoWorkflowService: GeoWorkflowServicing { } let writableAssets = assetBuild.assets.filter { $0.rawFile != nil } guard !writableAssets.isEmpty else { - throw WorkflowFailure(message: "没有发现可生成 XMP sidecar 的专有 RAW(NEF、ARW 等)。") + throw WorkflowFailure(message: "没有发现可输出地理信息的专有 RAW(NEF、ARW 等)。") } continuation.yield(.progress(fraction: 0.44, message: "流式读取并独立规范化 GPX 来源…")) try Task.checkCancellation() - let gpxFiles = try Self.gpxFiles(in: gpxDirectoryURL) + let gpxFiles = try Self.gpxFiles(at: gpxSourceURL) guard !gpxFiles.isEmpty else { - throw WorkflowFailure(message: "所选目录中没有 GPX 文件。") + throw WorkflowFailure(message: "所选来源中没有 GPX 文件。") } let trackBuild = try Self.makeTrajectorySources(gpxFiles: gpxFiles) @@ -131,6 +131,7 @@ struct LiveGeoWorkflowService: GeoWorkflowServicing { prepared: prepared, file: rawFile, strategy: configuration.matchingStrategy, + outputMode: configuration.outputMode, writeAltitude: configuration.writeAltitude, trackSourceDigests: trackBuild.sourceDigests ) @@ -170,6 +171,27 @@ struct LiveGeoWorkflowService: GeoWorkflowServicing { func previewWrite(matches: [PhotoMatch], configuration: SourceConfiguration) async throws -> WritePreview { + if configuration.outputMode == .lightroomCatalogBridge { + let assets = try Self.makeCatalogBridgeAssets(matches: matches, configuration: configuration) + let artifactURL = try Self.catalogBridgeArtifactURL(configuration: configuration) + let alreadyAppliedCount: Int + let targetExists = FileManager.default.fileExists(atPath: artifactURL.path) + if targetExists { + let existing = try CatalogBridgeManifestStore().read(from: artifactURL) + alreadyAppliedCount = Self.hasSameCatalogBridgeAssets(existing, assets) ? assets.count : 0 + } else { + alreadyAppliedCount = 0 + } + return WritePreview( + selectedCount: assets.count, + createCount: targetExists ? 0 : assets.count, + updateCount: targetExists && alreadyAppliedCount == 0 ? assets.count : 0, + alreadyAppliedCount: alreadyAppliedCount, + conflictCount: 0, + outputMode: .lightroomCatalogBridge, + artifactURL: artifactURL + ) + } let requests = try Self.makeWriteRequests(matches: matches, configuration: configuration) guard !requests.isEmpty else { return WritePreview( @@ -201,7 +223,8 @@ struct LiveGeoWorkflowService: GeoWorkflowServicing { updateCount: updateCount, alreadyAppliedCount: alreadyAppliedCount, conflictCount: conflictURLs.count, - conflictFileURLs: conflictURLs + conflictFileURLs: conflictURLs, + outputMode: .xmpSidecar ) } @@ -213,6 +236,61 @@ struct LiveGeoWorkflowService: GeoWorkflowServicing { let task = Task.detached(priority: .userInitiated) { let startedAt = Date() do { + if configuration.outputMode == .lightroomCatalogBridge { + let assets = try Self.makeCatalogBridgeAssets( + matches: matches, + configuration: configuration + ) + guard !assets.isEmpty else { + throw WorkflowFailure(message: "没有已确认且可导出到 Lightroom Classic 清单的照片。") + } + continuation.yield(.progress(fraction: 0.25, message: "验证照片身份与相对路径…")) + let appVersion = + Bundle.main.object( + forInfoDictionaryKey: "CFBundleShortVersionString" + ) as? String ?? "development" + let algorithmVersion = Set(assets.map(\.decision.ruleVersion)).sorted().joined( + separator: "+" + ) + let result = try CatalogBridgeManifestStore().export( + CatalogBridgeExportRequest( + rootDirectoryURL: configuration.photoDirectoryURL!, + assets: assets, + appVersion: appVersion, + algorithmVersion: algorithmVersion, + skippedCount: max(0, matches.count - assets.count), + writeAltitude: configuration.writeAltitude + ) + ) + continuation.yield(.progress(fraction: 0.9, message: "复读验证单清单完整性…")) + let verified = try CatalogBridgeManifestStore().read(from: result.artifactURL) + guard verified.assets.count == result.recordCount else { + throw WorkflowFailure(message: "位置清单复读记录数不一致。") + } + var updated = matches + let exportedPaths = Set(verified.assets.map(\.relativePath)) + for index in updated.indices { + updated[index].verification = + updated[index].identity.map { exportedPaths.contains($0.relativePath) } == true + ? .exported : .skipped + } + let report = ApplicationReport( + transactionID: nil, + startedAt: startedAt, + finishedAt: Date(), + appliedCount: result.recordCount, + verifiedCount: 0, + skippedCount: max(0, matches.count - result.recordCount), + failedCount: 0, + outputDirectoryURL: configuration.photoDirectoryURL, + outputMode: .lightroomCatalogBridge, + artifactURL: result.artifactURL + ) + continuation.yield(.progress(fraction: 1, message: "Lightroom Classic 单清单已生成")) + continuation.yield(.completed(matches: updated, report: report)) + continuation.finish() + return + } let requests = try Self.makeWriteRequests(matches: matches, configuration: configuration) guard !requests.isEmpty else { throw WorkflowFailure(message: "没有已确认且可写入的照片。") @@ -256,7 +334,8 @@ struct LiveGeoWorkflowService: GeoWorkflowServicing { verifiedCount: applyReport.appliedCount, skippedCount: skippedCount, failedCount: applyReport.failedCount, - outputDirectoryURL: configuration.photoDirectoryURL + outputDirectoryURL: configuration.photoDirectoryURL, + outputMode: .xmpSidecar ) continuation.yield(.progress(fraction: 1, message: "写入与复读验证完成")) continuation.yield(.completed(matches: updated, report: report)) @@ -399,10 +478,33 @@ struct LiveGeoWorkflowService: GeoWorkflowServicing { } } - private static func gpxFiles(in directory: URL) throws -> [URL] { + static func gpxFiles(at source: URL) throws -> [URL] { + let standardizedSource = source.standardizedFileURL + let sourceValues: URLResourceValues + do { + sourceValues = try standardizedSource.resourceValues( + forKeys: [.isDirectoryKey, .isRegularFileKey, .isSymbolicLinkKey] + ) + } catch { + throw WorkflowFailure(message: "无法读取所选 GPX 文件或目录。") + } + + if sourceValues.isRegularFile == true { + guard sourceValues.isSymbolicLink != true, + standardizedSource.pathExtension.caseInsensitiveCompare("gpx") == .orderedSame + else { + throw WorkflowFailure(message: "请选择扩展名为 .gpx 的轨迹文件。") + } + return [standardizedSource] + } + + guard sourceValues.isDirectory == true else { + throw WorkflowFailure(message: "请选择 GPX 文件或包含 GPX 的目录。") + } + guard let enumerator = FileManager.default.enumerator( - at: directory, + at: standardizedSource, includingPropertiesForKeys: [.isRegularFileKey, .isSymbolicLinkKey, .isPackageKey], options: [.skipsHiddenFiles, .skipsPackageDescendants] ) @@ -1144,6 +1246,7 @@ struct LiveGeoWorkflowService: GeoWorkflowServicing { prepared: PreparedAsset, file: ReadOnlyRawFile, strategy: MatchingStrategy, + outputMode: OutputMode, writeAltitude: Bool, trackSourceDigests: [String: String] ) -> PhotoMatch { @@ -1189,7 +1292,8 @@ struct LiveGeoWorkflowService: GeoWorkflowServicing { let hasAdjacentXMP = FileManager.default.fileExists(atPath: xmpURL.path) let hasExistingGPS = prepared.metadata.gps != nil || hasAdjacentXMP let shouldAutomaticallyCheck = - confidence == .reliable && coordinate != nil && !hasExistingGPS + confidence == .reliable && coordinate != nil + && (outputMode == .lightroomCatalogBridge || !hasExistingGPS) let evidenceSummary = candidate.map { selected in let kinds = Set(selected.evidence.map(\.kind.rawValue)).sorted().joined(separator: "+") @@ -1205,6 +1309,18 @@ struct LiveGeoWorkflowService: GeoWorkflowServicing { return PhotoMatch( id: prepared.asset.id.rawValue, fileURL: file.url, + identity: PhotoIdentity( + relativePath: prepared.asset.relativePath, + fileSize: prepared.metadata.fileSize, + exifDateTimeOriginal: prepared.metadata.dateTimeOriginal ?? "", + subsecondTimeOriginal: prepared.metadata.subsecondTimeOriginal, + offsetTimeOriginal: prepared.metadata.offsetTimeOriginal, + cameraMake: prepared.asset.camera?.make, + cameraModel: prepared.asset.camera?.model, + cameraSerialNumber: prepared.asset.camera?.serialNumber, + cameraInternalSerialNumber: prepared.asset.camera?.internalSerialNumber, + shutterCount: prepared.asset.shutterCount + ), capturedAt: prepared.asset.captureTimeUTC, previousTrackPoint: previous, nextTrackPoint: next, @@ -1225,6 +1341,7 @@ struct LiveGeoWorkflowService: GeoWorkflowServicing { resolution: resolution, candidate: candidate, usesConflictRegionFallback: usesConflictRegionFallback, + outputMode: outputMode, hasExistingGPS: hasExistingGPS, hasAdjacentXMP: hasAdjacentXMP ), @@ -1287,6 +1404,7 @@ struct LiveGeoWorkflowService: GeoWorkflowServicing { resolution: LocationResolution, candidate: LocationCandidate?, usesConflictRegionFallback: Bool = false, + outputMode: OutputMode, hasExistingGPS: Bool, hasAdjacentXMP: Bool ) -> String { @@ -1306,7 +1424,9 @@ struct LiveGeoWorkflowService: GeoWorkflowServicing { if let radius = candidate.estimatedRadiusMeters { components.append("证据覆盖范围约 \(Int(radius.rounded())) 米(不是传感器精度)") } - if hasAdjacentXMP { + if outputMode == .lightroomCatalogBridge, hasExistingGPS { + components.append("已有位置将在 Lightroom 插件预览后按本次清单覆盖") + } else if hasAdjacentXMP { components.append("相邻 XMP 将在写入前做来源与摘要保护") } else if hasExistingGPS { components.append("文件已有 GPS,默认不替换") @@ -1477,6 +1597,97 @@ struct LiveGeoWorkflowService: GeoWorkflowServicing { } } + private static func catalogBridgeArtifactURL(configuration: SourceConfiguration) throws -> URL { + guard let root = configuration.photoDirectoryURL else { + throw WorkflowFailure(message: "未设置照片根目录。") + } + return root.appendingPathComponent(CatalogBridgeManifest.fileName, isDirectory: false) + } + + private static func makeCatalogBridgeAssets( + matches: [PhotoMatch], + configuration: SourceConfiguration + ) throws -> [CatalogBridgeAssetRecord] { + try matches.compactMap { match in + guard match.isSelectedForWrite, match.isWritableTarget, let coordinate = match.coordinate + else { + return nil + } + guard let identity = match.identity, let byteCount = identity.fileSize, + byteCount > 0, !identity.exifDateTimeOriginal.isEmpty + else { + throw WorkflowFailure(message: "\(match.fileName) 缺少生成单清单所需的稳定照片身份。") + } + let relativePath = identity.relativePath.precomposedStringWithCanonicalMapping + let fileIdentity = CatalogBridgeFileIdentity( + byteCount: byteCount, + exifDateTimeOriginal: identity.exifDateTimeOriginal, + subsecondTimeOriginal: identity.subsecondTimeOriginal, + offsetTimeOriginal: identity.offsetTimeOriginal, + make: identity.cameraMake, + model: identity.cameraModel, + serialNumber: identity.cameraSerialNumber, + internalSerialNumber: identity.cameraInternalSerialNumber, + shutterCount: identity.shutterCount + ) + return CatalogBridgeAssetRecord( + recordID: try CatalogBridgeManifestStore.stableRecordID( + relativePath: relativePath, + fileIdentity: fileIdentity + ), + relativePath: relativePath, + assetKind: "proprietaryRaw", + fileIdentity: fileIdentity, + // 清单 v1 的 ISO-8601 日期编码到秒;亚秒仍由文件身份单独保存。 + correctedCaptureTimeUTC: Date( + timeIntervalSince1970: match.capturedAt.timeIntervalSince1970.rounded(.down) + ), + location: try GPSMetadata( + latitude: coordinate.latitude, + longitude: coordinate.longitude, + altitude: configuration.writeAltitude ? coordinate.altitude : nil + ), + decision: CatalogBridgeDecision( + confidence: match.confidence.rawValue, + method: match.method.rawValue, + granularity: match.granularity.rawValue, + verification: catalogBridgeVerification(for: match), + ruleVersion: match.ruleVersion, + estimatedRadiusMeters: match.supportSpreadMeters, + temporalDistanceSeconds: match.temporalDistanceSeconds, + evidenceSummary: match.evidenceSummary, + trackFileSHA256: match.trackFileSHA256 + ) + ) + } + } + + private static func catalogBridgeVerification(for match: PhotoMatch) + -> CatalogBridgeVerification + { + if match.method == .manual { return .manual } + return match.replacementExplicitlyAuthorized || match.confidence != .reliable + ? .userConfirmed : .automatic + } + + private static func hasSameCatalogBridgeAssets( + _ existing: CatalogBridgeManifest, + _ requested: [CatalogBridgeAssetRecord] + ) -> Bool { + guard existing.assets.count == requested.count else { return false } + let existingByPath = Dictionary( + uniqueKeysWithValues: existing.assets.map { ($0.relativePath, $0) }) + return requested.allSatisfy { input in + guard let asset = existingByPath[input.relativePath] else { return false } + return asset.recordID == input.recordID + && asset.assetKind == input.assetKind + && asset.fileIdentity == input.fileIdentity + && asset.correctedCaptureTimeUTC == input.correctedCaptureTimeUTC + && asset.location == input.location + && asset.decision == input.decision + } + } + private static func provenanceSource(for method: MatchMethod) -> MatchProvenanceSource { switch method { case .manual: .manual diff --git a/RawGeoSyncApp/ViewModels/WorkspaceViewModel.swift b/RawGeoSyncApp/ViewModels/WorkspaceViewModel.swift index 3def530..b76cce5 100644 --- a/RawGeoSyncApp/ViewModels/WorkspaceViewModel.swift +++ b/RawGeoSyncApp/ViewModels/WorkspaceViewModel.swift @@ -22,12 +22,20 @@ final class WorkspaceViewModel: ObservableObject { @Published var writePreview: WritePreview? @Published var errorMessage: String? @Published var recoveryMessage: String? + @Published var pluginInstallationStatus: LightroomPluginInstallationStatus = .checking + @Published var pluginInstallationMessage: String? let service: any GeoWorkflowServicing + let pluginInstaller: LightroomPluginInstaller private var operationTask: Task? - init(service: any GeoWorkflowServicing) { + init( + service: any GeoWorkflowServicing, + pluginInstaller: LightroomPluginInstaller = LightroomPluginInstaller() + ) { self.service = service + self.pluginInstaller = pluginInstaller + pluginInstallationStatus = pluginInstaller.status() Task { [weak self] in guard let self else { return } do { @@ -58,7 +66,7 @@ final class WorkspaceViewModel: ObservableObject { var writableCount: Int { matches.count(where: { $0.isSelectedForWrite && $0.coordinate != nil && $0.isWritableTarget - && !$0.hasProtectedExternalXMP + && (configuration.outputMode == .lightroomCatalogBridge || !$0.hasProtectedExternalXMP) }) } @@ -83,7 +91,7 @@ final class WorkspaceViewModel: ObservableObject { func analyze() { guard configuration.isReady else { - errorMessage = "请先选择 GPX 目录和照片目录。" + errorMessage = "请先选择 GPX 文件或目录,以及照片目录。" return } @@ -127,7 +135,9 @@ final class WorkspaceViewModel: ObservableObject { operationTask?.cancel() errorMessage = nil isPreparingWrite = true - progressMessage = "检查文件摘要与现有 XMP…" + progressMessage = + configuration.outputMode == .lightroomCatalogBridge + ? "检查照片身份与单清单目标…" : "检查文件摘要与现有 XMP…" operationTask = Task { [weak self] in guard let self else { return } do { @@ -135,12 +145,14 @@ final class WorkspaceViewModel: ObservableObject { matches: matches, configuration: configuration ) - for index in matches.indices - where preview.conflictFileURLs.contains(matches[index].fileURL.standardizedFileURL) { - matches[index].hasExistingGPS = true - matches[index].isSelectedForWrite = false - matches[index].confidence = .review - matches[index].note = "检测到已有不同 GPS,已取消选择;重新勾选表示明确授权替换" + if configuration.outputMode == .xmpSidecar { + for index in matches.indices + where preview.conflictFileURLs.contains(matches[index].fileURL.standardizedFileURL) { + matches[index].hasExistingGPS = true + matches[index].isSelectedForWrite = false + matches[index].confidence = .review + matches[index].note = "检测到已有不同 GPS,已取消选择;重新勾选表示明确授权替换" + } } writePreview = preview } catch is CancellationError { @@ -164,7 +176,9 @@ final class WorkspaceViewModel: ObservableObject { errorMessage = nil isApplying = true progressFraction = 0 - progressMessage = "准备生成 XMP…" + progressMessage = + configuration.outputMode == .lightroomCatalogBridge + ? "准备生成 Lightroom Classic 位置清单…" : "准备生成 XMP…" operationTask = Task { [weak self] in guard let self else { return } @@ -192,7 +206,7 @@ final class WorkspaceViewModel: ObservableObject { } func undo() { - guard var currentReport = report, !currentReport.isUndone else { return } + guard var currentReport = report, currentReport.canUndo else { return } operationTask?.cancel() isUndoing = true errorMessage = nil @@ -321,6 +335,22 @@ final class WorkspaceViewModel: ObservableObject { operationTask?.cancel() } + func refreshPluginInstallationStatus() { + pluginInstallationStatus = pluginInstaller.status() + } + + func installOrUpdateLightroomPlugin() { + do { + let installedURL = try pluginInstaller.installOrUpdate() + pluginInstallationStatus = .installed + pluginInstallationMessage = + "插件已安装到 \(installedURL.path(percentEncoded: false))。如果 Lightroom Classic 正在运行,请重新启动后再导入位置清单。" + } catch { + errorMessage = error.localizedDescription + pluginInstallationStatus = pluginInstaller.status() + } + } + func returnToSources() { cancelCurrentOperation() stage = .sources @@ -338,6 +368,7 @@ final class WorkspaceViewModel: ObservableObject { report = nil writePreview = nil errorMessage = nil + pluginInstallationMessage = nil progressFraction = 0 progressMessage = "" isAnalyzing = false diff --git a/RawGeoSyncApp/Views/AnalysisWorkspaceView.swift b/RawGeoSyncApp/Views/AnalysisWorkspaceView.swift index f1cfe70..ec0df3b 100644 --- a/RawGeoSyncApp/Views/AnalysisWorkspaceView.swift +++ b/RawGeoSyncApp/Views/AnalysisWorkspaceView.swift @@ -64,7 +64,7 @@ struct AnalysisWorkspaceView: View { .overlay { if workspace.isPreparingWrite || workspace.isApplying { ProgressOverlay( - title: workspace.isPreparingWrite ? "正在生成只读写入计划" : "正在创建并复读验证 XMP", + title: progressTitle, message: workspace.progressMessage, fraction: workspace.progressFraction, cancel: workspace.cancelCurrentOperation @@ -73,11 +73,11 @@ struct AnalysisWorkspaceView: View { } .alert(item: $workspace.writePreview) { preview in Alert( - title: Text("确认 XMP 写入计划"), + title: Text(preview.title), message: Text(preview.message), primaryButton: .cancel(Text("返回复核")), secondaryButton: .default( - Text(preview.writableCount > 0 ? "确认写入" : "没有可写项目"), + Text(preview.writableCount > 0 ? preview.confirmTitle : "没有可输出项目"), action: { if preview.writableCount > 0 { workspace.confirmApply() @@ -197,7 +197,7 @@ struct AnalysisWorkspaceView: View { .font(.caption) .foregroundStyle(.secondary) Spacer() - Text("已勾选 \(workspace.checkedPhotoCount) 张 · 可写 \(workspace.writableCount) 张") + Text("已勾选 \(workspace.checkedPhotoCount) 张 · 可输出 \(workspace.writableCount) 张") .font(.caption.weight(.medium)) .foregroundStyle(.green) if !workspace.selectedMatches.isEmpty { @@ -212,7 +212,7 @@ struct AnalysisWorkspaceView: View { Divider() Table(workspace.filteredMatches, selection: $workspace.selectedMatches) { - TableColumn("写入") { match in + TableColumn("输出") { match in Toggle( "写入 \(match.fileName)", isOn: Binding( @@ -222,7 +222,7 @@ struct AnalysisWorkspaceView: View { ) .labelsHidden() .disabled(!match.isWritableTarget) - .help(match.coordinate == nil ? "已勾选;获得坐标前会安全跳过" : "勾选后纳入写入计划") + .help(match.coordinate == nil ? "已勾选;获得坐标前会安全跳过" : "勾选后纳入输出计划") } .width(42) @@ -303,8 +303,11 @@ struct AnalysisWorkspaceView: View { Button { workspace.prepareApply() } label: { - Label("预检并应用 \(workspace.writableCount) 张", systemImage: "checkmark.shield") - .frame(minWidth: 150) + Label( + "预检并\(workspace.configuration.outputMode.actionTitle) \(workspace.writableCount) 张", + systemImage: "checkmark.shield" + ) + .frame(minWidth: 150) } .buttonStyle(.borderedProminent) .controlSize(.large) @@ -323,6 +326,14 @@ struct AnalysisWorkspaceView: View { formatter.dateFormat = "MM-dd HH:mm:ss" return formatter.string(from: date) } + + private var progressTitle: String { + if workspace.isPreparingWrite { return "正在生成只读输出计划" } + return switch workspace.configuration.outputMode { + case .lightroomCatalogBridge: "正在生成 Lightroom Classic 单清单" + case .xmpSidecar: "正在创建并复读验证 XMP" + } + } } private struct MatchMapView: View { diff --git a/RawGeoSyncApp/Views/AppShellView.swift b/RawGeoSyncApp/Views/AppShellView.swift index e3f683c..caf4b68 100644 --- a/RawGeoSyncApp/Views/AppShellView.swift +++ b/RawGeoSyncApp/Views/AppShellView.swift @@ -52,6 +52,21 @@ struct AppShellView: View { Text(workspace.recoveryMessage ?? "") } ) + .alert( + "Lightroom Classic 插件", + isPresented: Binding( + get: { workspace.pluginInstallationMessage != nil }, + set: { if !$0 { workspace.pluginInstallationMessage = nil } } + ), + actions: { + Button("好", role: .cancel) { + workspace.pluginInstallationMessage = nil + } + }, + message: { + Text(workspace.pluginInstallationMessage ?? "") + } + ) } } @@ -67,7 +82,7 @@ private struct WorkflowHeader: View { VStack(alignment: .leading, spacing: 1) { Text("RawGeoSync") .font(.headline) - Text("RAW 地理信息预检与写入") + Text("RAW 地理信息预检与交付") .font(.caption) .foregroundStyle(.secondary) } diff --git a/RawGeoSyncApp/Views/ApplyResultView.swift b/RawGeoSyncApp/Views/ApplyResultView.swift index a636347..1465f8c 100644 --- a/RawGeoSyncApp/Views/ApplyResultView.swift +++ b/RawGeoSyncApp/Views/ApplyResultView.swift @@ -1,3 +1,4 @@ +import AppKit import SwiftUI struct ApplyResultView: View { @@ -16,15 +17,17 @@ struct ApplyResultView: View { HStack(spacing: 12) { MetricCard( - title: "已应用", + title: report?.outputMode == .lightroomCatalogBridge ? "清单记录" : "已应用", value: "\(report?.appliedCount ?? 0)", systemImage: "square.and.arrow.down", tint: .cyan ) MetricCard( - title: "复读验证通过", - value: "\(report?.verifiedCount ?? 0)", - systemImage: "checkmark.seal.fill", + title: report?.outputMode == .lightroomCatalogBridge ? "单清单文件" : "复读验证通过", + value: report?.outputMode == .lightroomCatalogBridge + ? (report?.artifactURL == nil ? "0" : "1") : "\(report?.verifiedCount ?? 0)", + systemImage: report?.outputMode == .lightroomCatalogBridge + ? "doc.text.fill" : "checkmark.seal.fill", tint: .green ) MetricCard( @@ -41,7 +44,7 @@ struct ApplyResultView: View { ) } - GroupBox("验证详情") { + GroupBox(report?.outputMode == .lightroomCatalogBridge ? "清单详情" : "验证详情") { VStack(spacing: 0) { ForEach(workspace.matches) { match in HStack(spacing: 12) { @@ -69,9 +72,9 @@ struct ApplyResultView: View { HStack { VStack(alignment: .leading, spacing: 3) { - Text(report?.isUndone == true ? "本次应用已撤销" : "下一步:导入 Lightroom Classic") + Text(nextStepTitle) .font(.headline) - Text(report?.outputDirectoryURL?.path(percentEncoded: false) ?? "未记录输出目录") + Text(outputPath) .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) @@ -83,15 +86,40 @@ struct ApplyResultView: View { workspace.stage = .analysis } - Button { - workspace.undo() - } label: { - Label( - workspace.isUndoing ? "正在撤销…" : "撤销本次应用", - systemImage: "arrow.uturn.backward.circle" - ) + if report?.outputMode == .lightroomCatalogBridge { + if let actionTitle = workspace.pluginInstallationStatus.actionTitle { + Button(actionTitle) { + workspace.installOrUpdateLightroomPlugin() + } + } + Button { + if let artifactURL = report?.artifactURL { + NSWorkspace.shared.activateFileViewerSelecting([artifactURL]) + } + } label: { + Label("在访达中显示清单", systemImage: "folder") + } + .disabled(report?.artifactURL == nil) + Button("打开 Lightroom Classic") { + if let appURL = NSWorkspace.shared.urlForApplication( + withBundleIdentifier: "com.adobe.LightroomClassicCC7" + ) { + NSWorkspace.shared.open(appURL) + } else { + workspace.errorMessage = "未找到 Adobe Lightroom Classic。" + } + } + } else { + Button { + workspace.undo() + } label: { + Label( + workspace.isUndoing ? "正在撤销…" : "撤销本次应用", + systemImage: "arrow.uturn.backward.circle" + ) + } + .disabled(report?.canUndo != true || workspace.isBusy) } - .disabled(report?.isUndone != false || workspace.isBusy) Button { workspace.reset() @@ -115,15 +143,14 @@ struct ApplyResultView: View { .fill((report?.isUndone == true ? Color.orange : Color.green).opacity(0.13)) .frame(width: 76, height: 76) Image( - systemName: report?.isUndone == true - ? "arrow.uturn.backward.circle.fill" : "checkmark.seal.fill" + systemName: statusIcon ) .font(.system(size: 42)) .foregroundStyle(report?.isUndone == true ? .orange : .green) } - Text(report?.isUndone == true ? "已安全撤销" : "地理信息已应用并验证") + Text(statusTitle) .font(.largeTitle.weight(.semibold)) - Text(report?.isUndone == true ? "已恢复应用前的 sidecar 状态。" : "原始 RAW 未被修改;所有成功项均已复读确认。") + Text(statusDetail) .font(.title3) .foregroundStyle(.secondary) } @@ -131,6 +158,7 @@ struct ApplyResultView: View { private func color(for state: VerificationState) -> Color { switch state { + case .exported: .cyan case .verified: .green case .skipped: .orange case .failed: .red @@ -138,6 +166,41 @@ struct ApplyResultView: View { case .pending: .secondary } } + + private var nextStepTitle: String { + if report?.isUndone == true { return "本次应用已撤销" } + if report?.outputMode == .lightroomCatalogBridge { + return "下一步:在 Lightroom Classic 中运行 RawGeoSync 插件" + } + return "下一步:导入 Lightroom Classic" + } + + private var statusTitle: String { + if report?.isUndone == true { return "已安全撤销" } + return report?.outputMode == .lightroomCatalogBridge + ? "Lightroom Classic 位置清单已生成" : "地理信息已应用并验证" + } + + private var statusIcon: String { + if report?.isUndone == true { return "arrow.uturn.backward.circle.fill" } + return report?.outputMode == .lightroomCatalogBridge + ? "doc.badge.checkmark" : "checkmark.seal.fill" + } + + private var outputPath: String { + if let artifactURL = report?.artifactURL { + return artifactURL.path(percentEncoded: false) + } + return report?.outputDirectoryURL?.path(percentEncoded: false) ?? "未记录输出位置" + } + + private var statusDetail: String { + if report?.isUndone == true { return "已恢复应用前的 sidecar 状态。" } + if report?.outputMode == .lightroomCatalogBridge { + return "原始 RAW 未被修改;清单仍需由插件写入当前 Lightroom 目录,应用后可整批撤销。" + } + return "原始 RAW 未被修改;所有成功项均已复读确认。" + } } private struct VerificationIcon: View { @@ -151,6 +214,7 @@ private struct VerificationIcon: View { private var icon: String { switch state { + case .exported: "doc.text.fill" case .verified: "checkmark.circle.fill" case .skipped: "forward.end.circle.fill" case .failed: "xmark.circle.fill" @@ -161,6 +225,7 @@ private struct VerificationIcon: View { private var color: Color { switch state { + case .exported: .cyan case .verified: .green case .skipped: .orange case .failed: .red diff --git a/RawGeoSyncApp/Views/SourceSetupView.swift b/RawGeoSyncApp/Views/SourceSetupView.swift index 31996ea..974901c 100644 --- a/RawGeoSyncApp/Views/SourceSetupView.swift +++ b/RawGeoSyncApp/Views/SourceSetupView.swift @@ -1,5 +1,6 @@ import AppKit import SwiftUI +import UniformTypeIdentifiers struct SourceSetupView: View { @EnvironmentObject private var workspace: WorkspaceViewModel @@ -19,7 +20,7 @@ struct SourceSetupView: View { VStack(alignment: .leading, spacing: 7) { Text("让每张 RAW 回到拍摄地") .font(.largeTitle.weight(.semibold)) - Text("先选择手机轨迹与相机文件夹。RawGeoSync 会在写入前展示每张照片的匹配依据和风险。") + Text("先选择手机轨迹文件(或目录)与相机文件夹。RawGeoSync 会在写入前展示每张照片的匹配依据和风险。") .font(.title3) .foregroundStyle(.secondary) } @@ -30,13 +31,13 @@ struct SourceSetupView: View { HStack(spacing: 16) { SourcePickerCard( - title: "GPX 轨迹目录", - description: "自动读取目录内与照片时间窗口相关的全部 GPX。", + title: "GPX 轨迹", + description: "可选择单个 GPX 文件,或自动读取目录内与照片时间窗口相关的全部 GPX。", systemImage: "point.topleft.down.to.point.bottomright.curvepath", - url: workspace.configuration.gpxDirectoryURL, - actionTitle: "选择 GPX 目录…", - action: chooseGPXFolder, - onDropURL: setGPXFolderURL + url: workspace.configuration.gpxSourceURL, + actionTitle: "选择 GPX 文件或目录…", + action: chooseGPXSource, + onDropURL: setGPXSourceURL ) SourcePickerCard( title: "照片活动或 RAW 目录", @@ -50,7 +51,7 @@ struct SourceSetupView: View { } .frame(minHeight: 190) - GroupBox("匹配、时间与写入策略") { + GroupBox("匹配、时间与输出策略") { Grid(alignment: .leading, horizontalSpacing: 24, verticalSpacing: 16) { GridRow { SettingLabel( @@ -116,7 +117,7 @@ struct SourceSetupView: View { GridRow { SettingLabel( title: "输出方式", - detail: "仅为 NEF、ARW 等专有 RAW 创建同名 sidecar", + detail: workspace.configuration.outputMode.detail, systemImage: "doc.badge.gearshape" ) Picker("", selection: $workspace.configuration.outputMode) { @@ -130,6 +131,39 @@ struct SourceSetupView: View { Divider().gridCellUnsizedAxes(.horizontal) + if workspace.configuration.outputMode == .lightroomCatalogBridge { + GridRow { + SettingLabel( + title: "Lightroom Classic 插件", + detail: "插件读取单清单,并在当前 Lightroom 目录中批量应用 GPS", + systemImage: "puzzlepiece.extension" + ) + HStack(spacing: 10) { + Label( + workspace.pluginInstallationStatus.title, + systemImage: workspace.pluginInstallationStatus == .installed + ? "checkmark.circle.fill" : "puzzlepiece.extension" + ) + .foregroundStyle( + workspace.pluginInstallationStatus == .installed ? .green : .secondary + ) + if let actionTitle = workspace.pluginInstallationStatus.actionTitle { + Button(actionTitle) { + workspace.installOrUpdateLightroomPlugin() + } + .buttonStyle(.bordered) + } + Button("重新检查") { + workspace.refreshPluginInstallationStatus() + } + .buttonStyle(.link) + } + .frame(maxWidth: 330, alignment: .leading) + } + + Divider().gridCellUnsizedAxes(.horizontal) + } + GridRow { SettingLabel( title: "写入海拔", @@ -145,17 +179,31 @@ struct SourceSetupView: View { GridRow { SettingLabel( title: "已有坐标", - detail: "新来源可证明更强时自动采用;未知外部 XMP 仍受保护", + detail: workspace.configuration.outputMode == .lightroomCatalogBridge + ? "插件会在预览中明确列出冲突;执行后以本次清单位置覆盖" + : "新来源可证明更强时自动采用;未知外部 XMP 仍受保护", systemImage: "shield.checkered" ) - Text("强来源优先,未知来源保护") - .foregroundStyle(.secondary) - .frame(maxWidth: 270, alignment: .leading) + Text( + workspace.configuration.outputMode == .lightroomCatalogBridge + ? "本次清单覆盖,可在插件中整批撤销" : "强来源优先,未知来源保护" + ) + .foregroundStyle(.secondary) + .frame(maxWidth: 270, alignment: .leading) } } .padding(.top, 8) } + if workspace.configuration.outputMode == .lightroomCatalogBridge { + Label( + "如果 Lightroom Classic 已开启“自动将更改写入 XMP”,应用目录位置后仍可能由 Lightroom 自行创建 sidecar。", + systemImage: "exclamationmark.triangle" + ) + .font(.caption) + .foregroundStyle(.orange) + } + HStack { Label("分析阶段不会写入任何文件", systemImage: "lock.shield") .font(.caption) @@ -187,19 +235,23 @@ struct SourceSetupView: View { ) } } + .onAppear { + workspace.refreshPluginInstallationStatus() + } } - private func chooseGPXFolder() { + private func chooseGPXSource() { let panel = NSOpenPanel() - panel.title = "选择存放 GPX 轨迹的目录" - panel.prompt = "选择目录" + panel.title = "选择 GPX 轨迹文件或目录" + panel.prompt = "选择" panel.allowsMultipleSelection = false - panel.canChooseFiles = false + panel.canChooseFiles = true panel.canChooseDirectories = true panel.canCreateDirectories = false panel.resolvesAliases = true + panel.allowedContentTypes = [UTType(filenameExtension: "gpx") ?? .xml] guard panel.runModal() == .OK, let url = panel.url else { return } - setGPXFolderURL(url) + setGPXSourceURL(url) } private func choosePhotoFolder() { @@ -215,26 +267,34 @@ struct SourceSetupView: View { setPhotoFolderURL(url) } - private func setGPXFolderURL(_ url: URL) { - var isDirectory: ObjCBool = false - guard FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory), - isDirectory.boolValue - else { - workspace.errorMessage = "请选择包含 GPX 文件的目录。" - return + @discardableResult + private func setGPXSourceURL(_ url: URL) -> Bool { + do { + guard !(try LiveGeoWorkflowService.gpxFiles(at: url)).isEmpty else { + workspace.errorMessage = "所选来源中没有 GPX 文件。" + return false + } + workspace.configuration.gpxSourceURL = url.standardizedFileURL + workspace.errorMessage = nil + return true + } catch { + workspace.errorMessage = error.localizedDescription + return false } - workspace.configuration.gpxDirectoryURL = url } - private func setPhotoFolderURL(_ url: URL) { + @discardableResult + private func setPhotoFolderURL(_ url: URL) -> Bool { var isDirectory: ObjCBool = false guard FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory), isDirectory.boolValue else { workspace.errorMessage = "请选择包含 RAW 文件的文件夹。" - return + return false } - workspace.configuration.photoDirectoryURL = url + workspace.configuration.photoDirectoryURL = url.standardizedFileURL + workspace.errorMessage = nil + return true } } @@ -267,7 +327,7 @@ private struct SourcePickerCard: View { let url: URL? let actionTitle: String let action: () -> Void - let onDropURL: (URL) -> Void + let onDropURL: (URL) -> Bool @State private var isDropTarget = false var body: some View { @@ -330,8 +390,7 @@ private struct SourcePickerCard: View { } .dropDestination(for: URL.self) { items, _ in guard let first = items.first else { return false } - onDropURL(first) - return true + return onDropURL(first) } isTargeted: { isTargeted in isDropTarget = isTargeted } diff --git a/RawGeoSyncAppTests/WorkspaceSelectionTests.swift b/RawGeoSyncAppTests/WorkspaceSelectionTests.swift index 8774c0e..b407576 100644 --- a/RawGeoSyncAppTests/WorkspaceSelectionTests.swift +++ b/RawGeoSyncAppTests/WorkspaceSelectionTests.swift @@ -4,6 +4,188 @@ import XCTest @MainActor final class WorkspaceSelectionTests: XCTestCase { + func testCatalogBridgeIsTheDefaultOutput() { + XCTAssertEqual(SourceConfiguration().outputMode, .lightroomCatalogBridge) + XCTAssertEqual(OutputMode.allCases, [.lightroomCatalogBridge, .xmpSidecar]) + } + + func testCatalogBridgeDoesNotLetExternalXMPSuppressManifestExport() { + let workspace = WorkspaceViewModel(service: DemoGeoWorkflowService()) + var match = makeMatch(id: "external-xmp", confidence: .reliable) + match.isSelectedForWrite = true + match.hasProtectedExternalXMP = true + workspace.matches = [match] + + workspace.configuration.outputMode = .lightroomCatalogBridge + XCTAssertEqual(workspace.writableCount, 1) + + workspace.configuration.outputMode = .xmpSidecar + XCTAssertEqual(workspace.writableCount, 0) + } + + func testLiveBridgeExportsOneManifestWithoutXMP() async throws { + let fixture = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: fixture) } + let rawURL = fixture.appendingPathComponent("Z50/DSC_0001.NEF") + try FileManager.default.createDirectory( + at: rawURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try Data([0x4E, 0x45, 0x46, 0x00]).write(to: rawURL) + var match = makeMatch(id: rawURL.path, confidence: .reliable) + match.fileURL = rawURL + match.identity = PhotoIdentity( + relativePath: "Z50/DSC_0001.NEF", + fileSize: 4, + exifDateTimeOriginal: "2026:08:08 14:14:02", + subsecondTimeOriginal: nil, + offsetTimeOriginal: "+08:00", + cameraMake: "NIKON CORPORATION", + cameraModel: "NIKON Z 50", + cameraSerialNumber: nil, + cameraInternalSerialNumber: "synthetic-camera", + shutterCount: 1 + ) + match.isSelectedForWrite = true + var configuration = SourceConfiguration() + configuration.photoDirectoryURL = fixture + configuration.outputMode = .lightroomCatalogBridge + let service = LiveGeoWorkflowService() + + let preview = try await service.previewWrite(matches: [match], configuration: configuration) + XCTAssertEqual(preview.outputMode, .lightroomCatalogBridge) + XCTAssertEqual(preview.createCount, 1) + + var report: ApplicationReport? + for try await event in service.applyEvents(matches: [match], configuration: configuration) { + if case .completed(_, let completed) = event { report = completed } + } + + let manifestURL = fixture.appendingPathComponent("RawGeoSync.locations.jsonl") + XCTAssertEqual(report?.artifactURL, manifestURL) + XCTAssertEqual(report?.appliedCount, 1) + XCTAssertTrue(FileManager.default.fileExists(atPath: manifestURL.path)) + XCTAssertFalse( + FileManager.default.fileExists( + atPath: rawURL.deletingPathExtension().appendingPathExtension("xmp").path + ) + ) + let contents = try String(contentsOf: manifestURL, encoding: .utf8) + XCTAssertTrue(contents.contains("Z50/DSC_0001.NEF")) + XCTAssertFalse(contents.contains(fixture.path)) + } + + func testBridgeReportCannotBeUndoneByTheApp() { + let report = ApplicationReport( + transactionID: UUID(), + startedAt: Date(), + finishedAt: Date(), + appliedCount: 1, + verifiedCount: 0, + skippedCount: 0, + failedCount: 0, + outputDirectoryURL: nil, + outputMode: .lightroomCatalogBridge + ) + XCTAssertFalse(report.canUndo) + } + + func testPluginInstallerCopiesBundledPluginIntoLightroomModules() throws { + let fixture = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: fixture) } + let source = fixture.appendingPathComponent("Bundled.lrplugin", isDirectory: true) + let modules = fixture.appendingPathComponent("Modules", isDirectory: true) + try FileManager.default.createDirectory(at: source, withIntermediateDirectories: false) + try Data("return { LrToolkitIdentifier = 'com.sssimplec.rawgeosync.lightroom' }".utf8).write( + to: source.appendingPathComponent("Info.lua") + ) + let installer = LightroomPluginInstaller( + bundledPluginURL: source, + modulesDirectoryURL: modules + ) + + XCTAssertEqual(installer.status(), .notInstalled) + let installed = try installer.installOrUpdate() + + XCTAssertEqual(installed, modules.appendingPathComponent("RawGeoSync.lrplugin")) + XCTAssertTrue( + FileManager.default.fileExists(atPath: installed.appendingPathComponent("Info.lua").path)) + XCTAssertEqual(installer.status(), .installed) + } + + func testAppBundleContainsLightroomPlugin() { + XCTAssertNotEqual(LightroomPluginInstaller().status(), .unavailable) + } + + func testPluginInstallerRefusesUnknownOccupiedTarget() throws { + let fixture = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: fixture) } + let source = fixture.appendingPathComponent("Bundled.lrplugin", isDirectory: true) + let modules = fixture.appendingPathComponent("Modules", isDirectory: true) + try FileManager.default.createDirectory(at: source, withIntermediateDirectories: false) + try Data("return { LrToolkitIdentifier = 'com.sssimplec.rawgeosync.lightroom' }".utf8).write( + to: source.appendingPathComponent("Info.lua") + ) + try FileManager.default.createDirectory(at: modules, withIntermediateDirectories: false) + try Data("occupied".utf8).write( + to: modules.appendingPathComponent("RawGeoSync.lrplugin") + ) + let installer = LightroomPluginInstaller( + bundledPluginURL: source, + modulesDirectoryURL: modules + ) + + XCTAssertThrowsError(try installer.installOrUpdate()) + } + + func testSingleGPXFileIsAcceptedAsSource() throws { + let fixture = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: fixture) } + let gpxFile = fixture.appendingPathComponent("single-track.GPX") + try Data("".utf8).write(to: gpxFile) + + XCTAssertEqual( + try LiveGeoWorkflowService.gpxFiles(at: gpxFile), + [gpxFile.standardizedFileURL] + ) + + var configuration = SourceConfiguration() + configuration.gpxSourceURL = gpxFile + configuration.photoDirectoryURL = fixture + XCTAssertTrue(configuration.isReady) + } + + func testGPXDirectoryRecursivelyCollectsOnlyGPXFiles() throws { + let fixture = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: fixture) } + let nested = fixture.appendingPathComponent("nested", isDirectory: true) + try FileManager.default.createDirectory( + at: nested, + withIntermediateDirectories: true + ) + let first = fixture.appendingPathComponent("2025.gpx") + let second = nested.appendingPathComponent("2026.GPX") + try Data("".utf8).write(to: first) + try Data("".utf8).write(to: second) + try Data("not a track".utf8).write(to: fixture.appendingPathComponent("notes.txt")) + + XCTAssertEqual( + try LiveGeoWorkflowService.gpxFiles(at: fixture), + [first.standardizedFileURL, second.standardizedFileURL] + ) + } + + func testNonGPXFileIsRejectedAsSource() throws { + let fixture = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: fixture) } + let textFile = fixture.appendingPathComponent("track.txt") + try Data("not a track".utf8).write(to: textFile) + + XCTAssertThrowsError(try LiveGeoWorkflowService.gpxFiles(at: textFile)) { error in + XCTAssertEqual(error.localizedDescription, "请选择扩展名为 .gpx 的轨迹文件。") + } + } + func testFilteredSelectAllTogglesPhotoCheckmarks() { let workspace = WorkspaceViewModel(service: DemoGeoWorkflowService()) workspace.matches = [ @@ -83,4 +265,14 @@ final class WorkspaceSelectionTests: XCTestCase { hasProtectedExternalXMP: false ) } + + private func makeTemporaryDirectory() throws -> URL { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("RawGeoSync-GPXSourceTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: false + ) + return directory + } } diff --git a/SECURITY.md b/SECURITY.md index 55bd865..c4749a8 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,7 +2,7 @@ ## 当前支持范围 -当前项目处于个人自用 MVP 阶段,主要支持 macOS 15+、Nikon NEF、GPX 1.0/1.1,以及随应用分发的 ExifTool 13.59。项目不提供网络服务,也不接收用户照片或轨迹上传。 +当前项目处于个人自用阶段,主要支持 macOS 15+、Lightroom Classic 15.4.1+、常见相机 RAW、GPX 1.0/1.1,以及随应用分发的 ExifTool 13.59。项目不提供网络服务,也不接收用户照片或轨迹上传。 ## 报告安全问题 @@ -20,4 +20,4 @@ RawGeoSync 不会要求你上传原始照片或完整轨迹。需要样本时, ## 用户侧安全边界 -应用设计上只读 RAW,在写入前执行文件摘要预检,使用临时 XMP、原子替换和事务备份。请始终在 Lightroom 导入前运行,并保留原始照片备份。 +应用设计上只读 RAW。默认模式原子生成一份位置清单,由 Lightroom 插件精确核验路径和身份后写入 Catalog,并保留可安全撤销的事务收据;兼容模式仍使用临时 XMP、原子替换和事务备份。位置清单和撤销收据都含敏感位置数据,请勿上传或公开分享,并保留原始照片与 Lightroom Catalog 备份。 diff --git a/Scripts/ci.sh b/Scripts/ci.sh index d180cea..a3fe952 100755 --- a/Scripts/ci.sh +++ b/Scripts/ci.sh @@ -43,6 +43,8 @@ stage "检查仓库隐私与文件策略" "$PROJECT_ROOT/Scripts/repository-policy-check.sh" stage "校验内置ExifTool" "$PROJECT_ROOT/Scripts/verify-vendor.sh" +stage "校验Lightroom插件契约" +"$PROJECT_ROOT/Scripts/verify-lightroom-plugin.sh" stage "检查Swift格式" "$PROJECT_ROOT/Scripts/format-check.sh" stage "运行Debug测试与应用构建" diff --git a/Scripts/package-release.sh b/Scripts/package-release.sh new file mode 100755 index 0000000..c38bee8 --- /dev/null +++ b/Scripts/package-release.sh @@ -0,0 +1,74 @@ +#!/bin/zsh +set -euo pipefail + +PROJECT_ROOT="${0:A:h:h}" +export DEVELOPER_DIR="${DEVELOPER_DIR:-/Applications/Xcode.app/Contents/Developer}" +TEMP_ROOT="${TMPDIR:-/tmp}" +DERIVED_DATA="$(mktemp -d "${TEMP_ROOT%/}/RawGeoSync-Package.XXXXXX")" +VERSION="${1:-0.3.0}" +OUTPUT_ROOT="$PROJECT_ROOT/.local/release/v$VERSION" + +cleanup() { + case "$DERIVED_DATA" in + "${TEMP_ROOT%/}"/RawGeoSync-Package.*) + find "$DERIVED_DATA" -depth -delete 2>/dev/null || true + ;; + esac +} +trap cleanup EXIT INT TERM + +[[ "$VERSION" == <->.<->.<-> ]] || { + print -u2 "版本必须采用 x.y.z 格式" + exit 1 +} + +"$PROJECT_ROOT/Scripts/verify-lightroom-plugin.sh" +mkdir -p "$OUTPUT_ROOT" + +xcodebuild \ + -project "$PROJECT_ROOT/RawGeoSync.xcodeproj" \ + -scheme RawGeoSync \ + -configuration Release \ + -destination 'platform=macOS,arch=arm64' \ + -derivedDataPath "$DERIVED_DATA" \ + CODE_SIGNING_ALLOWED=NO \ + build + +APP="$DERIVED_DATA/Build/Products/Release/RawGeoSync.app" +APP_VERSION="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$APP/Contents/Info.plist")" +[[ "$APP_VERSION" == "$VERSION" ]] || { + print -u2 "App 版本 $APP_VERSION 与发布版本 $VERSION 不一致" + exit 1 +} + +PLUGIN_VERSION="$({ + awk '/VERSION.major =/ { major=$3 } /VERSION.minor =/ { minor=$3 } /VERSION.revision =/ { revision=$3 } END { print major "." minor "." revision }' \ + "$PROJECT_ROOT/LightroomPlugin/RawGeoSync.lrplugin/Info.lua" +})" +[[ "$PLUGIN_VERSION" == "$VERSION" ]] || { + print -u2 "插件版本 $PLUGIN_VERSION 与发布版本 $VERSION 不一致" + exit 1 +} + +diff -qr \ + "$PROJECT_ROOT/LightroomPlugin/RawGeoSync.lrplugin" \ + "$APP/Contents/Resources/RawGeoSync.lrplugin" + +APP_ARCHIVE="$OUTPUT_ROOT/RawGeoSync-$VERSION-macOS-arm64.zip" +PLUGIN_ARCHIVE="$OUTPUT_ROOT/RawGeoSync-Lightroom-Bridge-$VERSION.zip" +CHECKSUMS="$OUTPUT_ROOT/SHA256SUMS.txt" + +ditto -c -k --keepParent --sequesterRsrc "$APP" "$APP_ARCHIVE" +ditto -c -k --keepParent \ + "$PROJECT_ROOT/LightroomPlugin/RawGeoSync.lrplugin" \ + "$PLUGIN_ARCHIVE" +cp "$PROJECT_ROOT/Docs/LIGHTROOM_BRIDGE.md" "$OUTPUT_ROOT/Lightroom-Bridge-使用指南.md" +( + cd "$OUTPUT_ROOT" + shasum -a 256 \ + "${APP_ARCHIVE:t}" \ + "${PLUGIN_ARCHIVE:t}" \ + 'Lightroom-Bridge-使用指南.md' > "${CHECKSUMS:t}" +) + +print "发布物已生成:$OUTPUT_ROOT" diff --git a/Scripts/repository-policy-check.sh b/Scripts/repository-policy-check.sh index 6d75cde..7cf883c 100755 --- a/Scripts/repository-policy-check.sh +++ b/Scripts/repository-policy-check.sh @@ -22,7 +22,7 @@ while IFS= read -r repository_file; do REPOSITORY_FILES+=("$repository_file") case "$repository_file" in Vendor/*) ;; - *.md | *.sh | *.swift | *.yml | *.yaml | *.json | *.jsonl | *.log | *.txt | *.plist \ + *.md | *.sh | *.swift | *.lua | *.yml | *.yaml | *.json | *.jsonl | *.log | *.txt | *.plist \ | *.xml | *.pbxproj | *.xcscheme | *.xcconfig) SCAN_FILES+=("$repository_file") ;; @@ -58,7 +58,7 @@ print "检查高精度坐标" SENSITIVE_TEXT_FILES=() for scan_file in "${SCAN_FILES[@]}"; do case "$scan_file" in - *.md | *.sh | *.yml | *.yaml | *.json | *.jsonl | *.log | *.txt | *.plist | *.xml \ + *.md | *.sh | *.lua | *.yml | *.yaml | *.json | *.jsonl | *.log | *.txt | *.plist | *.xml \ | *.pbxproj | *.xcscheme | *.xcconfig) SENSITIVE_TEXT_FILES+=("$scan_file") ;; diff --git a/Scripts/test-lua.sh b/Scripts/test-lua.sh new file mode 100755 index 0000000..e4d8973 --- /dev/null +++ b/Scripts/test-lua.sh @@ -0,0 +1,25 @@ +#!/bin/zsh +set -euo pipefail + +PROJECT_ROOT="${0:A:h:h}" + +if [[ -n "${RAWGEOSYNC_LUA:-}" ]]; then + LUA_EXECUTABLE="$RAWGEOSYNC_LUA" +elif [[ -x "$PROJECT_ROOT/.local/conda-lua51/bin/lua" ]]; then + LUA_EXECUTABLE="$PROJECT_ROOT/.local/conda-lua51/bin/lua" +elif [[ -x "$PROJECT_ROOT/.local/conda-lua54/bin/lua" ]]; then + LUA_EXECUTABLE="$PROJECT_ROOT/.local/conda-lua54/bin/lua" +elif command -v lua >/dev/null 2>&1; then + LUA_EXECUTABLE="$(command -v lua)" +else + print -u2 "找不到 Lua。请执行:conda create -y -p \"$PROJECT_ROOT/.local/conda-lua51\" -c conda-forge lua=5.1" + exit 1 +fi + +"$LUA_EXECUTABLE" -v +for source_file in "$PROJECT_ROOT"/LightroomPlugin/RawGeoSync.lrplugin/*.lua \ + "$PROJECT_ROOT"/LightroomPlugin/Tests/*.lua; do + "${LUA_EXECUTABLE:h}/luac" -p "$source_file" +done + +"$LUA_EXECUTABLE" "$PROJECT_ROOT/LightroomPlugin/Tests/run.lua" diff --git a/Scripts/test.sh b/Scripts/test.sh index d5b2d90..73f704d 100755 --- a/Scripts/test.sh +++ b/Scripts/test.sh @@ -16,6 +16,7 @@ cleanup() { trap cleanup EXIT INT TERM "$PROJECT_ROOT/Scripts/verify-vendor.sh" +"$PROJECT_ROOT/Scripts/test-lua.sh" swift test \ --package-path "$PROJECT_ROOT/RawGeoCore" \ --scratch-path "$DERIVED_DATA/SwiftPM/RawGeoCore" @@ -30,3 +31,15 @@ xcodebuild \ -derivedDataPath "$DERIVED_DATA" \ CODE_SIGNING_ALLOWED=NO \ test + +xcodebuild \ + -project "$PROJECT_ROOT/RawGeoSync.xcodeproj" \ + -target RawGeoSyncSmoke \ + -configuration Debug \ + -destination 'platform=macOS,arch=arm64' \ + ARCHS=arm64 \ + ONLY_ACTIVE_ARCH=YES \ + SYMROOT="$DERIVED_DATA/Smoke/Products" \ + OBJROOT="$DERIVED_DATA/Smoke/Intermediates" \ + CODE_SIGNING_ALLOWED=NO \ + build diff --git a/Scripts/verify-lightroom-plugin.sh b/Scripts/verify-lightroom-plugin.sh new file mode 100755 index 0000000..09680af --- /dev/null +++ b/Scripts/verify-lightroom-plugin.sh @@ -0,0 +1,25 @@ +#!/bin/zsh +set -euo pipefail + +PROJECT_ROOT="${0:A:h:h}" +PLUGIN_ROOT="$PROJECT_ROOT/LightroomPlugin/RawGeoSync.lrplugin" + +[[ -f "$PLUGIN_ROOT/Info.lua" ]] || { + print -u2 "缺少 Lightroom 插件 Info.lua" + exit 1 +} +[[ -f "$PLUGIN_ROOT/MetadataDefinition.lua" ]] || { + print -u2 "缺少 Lightroom 元数据声明" + exit 1 +} + +grep -q 'LrToolkitIdentifier = "com.sssimplec.rawgeosync.lightroom"' "$PLUGIN_ROOT/Info.lua" +grep -q 'VERSION.major = 0' "$PLUGIN_ROOT/Info.lua" +grep -q 'VERSION.minor = 3' "$PLUGIN_ROOT/Info.lua" +grep -q 'VERSION.revision = 0' "$PLUGIN_ROOT/Info.lua" +grep -q 'metadataFieldsForPhotos' "$PLUGIN_ROOT/MetadataDefinition.lua" +grep -q 'Manifest.FORMAT = "com.sssimplec.rawgeosync.locations"' "$PLUGIN_ROOT/Manifest.lua" +grep -q 'Manifest.SCHEMA_MAJOR = 1' "$PLUGIN_ROOT/Manifest.lua" +grep -q 'Manifest.SCHEMA_MINOR = 0' "$PLUGIN_ROOT/Manifest.lua" + +print "Lightroom 插件版本、标识和 schema 校验通过" diff --git a/Tools/RawGeoSmoke/SmokeMain.swift b/Tools/RawGeoSmoke/SmokeMain.swift index 8d00841..b539b13 100644 --- a/Tools/RawGeoSmoke/SmokeMain.swift +++ b/Tools/RawGeoSmoke/SmokeMain.swift @@ -26,10 +26,14 @@ enum RawGeoSmokeMain { private static func capabilities() -> [String: Any] { [ "schemaVersion": 1, - "features": ["fullCorpusDryRun": true], + "features": [ + "fullCorpusDryRun": true, + "defaultOutputMode": OutputMode.lightroomCatalogBridge.rawValue, + "outputModes": OutputMode.allCases.map(\.rawValue), + ], "guarantees": [ "readOnlySourceDirectories": true, - "writeTargets": "proprietary-raw-xmp-sidecar-only", + "writeTargets": "single-catalog-bridge-manifest-or-proprietary-raw-xmp-sidecar", ], "matchingRuleVersion": "2.0", ] @@ -53,7 +57,7 @@ enum RawGeoSmokeMain { } let configuration = SourceConfiguration( - gpxDirectoryURL: gpxDirectory, + gpxSourceURL: gpxDirectory, photoDirectoryURL: photoDirectory, matchingStrategy: .coverage )