OmniStacksStaticModules 2.0.0
Loading...
Searching...
No Matches
ProjectSync.cppm
Go to the documentation of this file.
1
17module;
18
19#include <unistd.h>
20
21#include <chrono>
22#include <cstdint>
23#include <ctime>
24#include <filesystem>
25#include <fstream>
26#include <set>
27#include <string>
28#include <string_view>
29#include <system_error>
30#include <vector>
31
32#include "json.hpp"
33
34export module ProjectSync;
35
36import std;
37import InterfaceLog;
38
39export namespace ProjectSync {
40
41// ---- 公共数据契约 ---------------------------------------------------------
42
47struct DbPaths {
48 std::filesystem::path project_db{"data/Project.json"};
49 std::filesystem::path stack_db{"data/Stack.json"};
50};
51
60
63 std::size_t project_count = 0;
64 std::size_t stack_count = 0;
65 std::vector<std::filesystem::path> library_paths;
66 std::vector<std::filesystem::path> failed_projects;
67};
68
76std::expected<SyncSummary, SyncError> sync_all(const std::filesystem::path& search_path,
77 const DbPaths& paths = {});
78
85template <std::invocable<const std::filesystem::directory_entry&> Visitor>
86void for_each_project_file(const std::filesystem::path& directory, Visitor&& visitor) {
87 if (!std::filesystem::exists(directory) || !std::filesystem::is_directory(directory)) {
88 return;
89 }
90
91 std::error_code error;
92 std::filesystem::recursive_directory_iterator iterator(
93 directory, std::filesystem::directory_options::skip_permission_denied, error);
94 const std::filesystem::recursive_directory_iterator end;
95 while (!error && iterator != end) {
96 if (iterator->is_regular_file(error) && iterator->path().extension() == ".project") {
97 std::invoke(visitor, *iterator);
98 }
99 iterator.increment(error);
100 }
101}
102
104std::string_view sync_error_message(SyncError error) noexcept;
105
107std::string format_file_time(std::filesystem::file_time_type file_time);
108
109} // namespace ProjectSync
110
111namespace ProjectSync {
112
113namespace {
114
115namespace fs = std::filesystem;
116using json = nlohmann::json;
117
118std::string now_timestamp() {
119 // Project/Stack 索引的 updateTime 使用本地秒级时间,和 Stacks 文本界面一致。
120 return std::format("{:%Y-%m-%d %H:%M:%S}", std::chrono::system_clock::now());
121}
122
127fs::path normalized_path(const fs::path& value) {
128 std::error_code error;
129 const fs::path canonical = fs::weakly_canonical(value, error);
130 if (!error) {
131 return canonical;
132 }
133 return fs::absolute(value, error).lexically_normal();
134}
135
140std::expected<std::set<std::string>, SyncError> load_library_paths(const fs::path& project_db) {
141 std::set<std::string> result;
142 if (!fs::exists(project_db)) {
143 return result;
144 }
145
146 std::ifstream input(project_db);
147 try {
148 json db;
149 if (!(input >> db) || !db.is_object()) {
150 return std::unexpected(SyncError::project_db_unreadable);
151 }
152 if (db.contains("library_paths") && db["library_paths"].is_array()) {
153 for (const auto& item : db["library_paths"]) {
154 if (item.is_string() && !item.get<std::string>().empty()) {
155 result.insert(normalized_path(item.get<std::string>()).string());
156 }
157 }
158 }
159 } catch (const std::exception&) {
160 return std::unexpected(SyncError::project_db_unreadable);
161 }
162 return result;
163}
164
170bool atomic_write_json(const fs::path& target, const json& value) {
171 std::error_code error;
172 if (target.has_parent_path()) {
173 fs::create_directories(target.parent_path(), error);
174 if (error) {
175 return false;
176 }
177 }
178
179 const fs::path temporary =
180 target.parent_path() /
181 std::format(".{}.tmp.{}.{}", target.filename().string(), ::getpid(),
182 std::chrono::steady_clock::now().time_since_epoch().count());
183 {
184 // 关闭 ofstream 后再 rename;保证目标文件看到的是完整 JSON。
185 std::ofstream output(temporary, std::ios::binary | std::ios::trunc);
186 if (!output) {
187 return false;
188 }
189 output << value.dump(4) << '\n';
190 output.flush();
191 if (!output) {
192 fs::remove(temporary, error);
193 return false;
194 }
195 }
196
197 fs::rename(temporary, target, error);
198 if (error) {
199 fs::remove(temporary, error);
200 return false;
201 }
202 return true;
203}
204
211std::string safe_string(const json& object, std::string_view key, std::string fallback = {}) {
212 if (object.is_object() && object.contains(key) && object[key].is_string()) {
213 return object[key].get<std::string>();
214 }
215 return fallback;
216}
217
218} // namespace
219
220std::string format_file_time(fs::file_time_type file_time) {
221 // file_clock 的纪元没有跨平台保证,通过“当前时钟差值”平移到 system_clock。
222 const auto system_time = std::chrono::time_point_cast<std::chrono::system_clock::duration>(
223 file_time - fs::file_time_type::clock::now() + std::chrono::system_clock::now());
224 const std::time_t seconds = std::chrono::system_clock::to_time_t(system_time);
225
226 // Homebrew libc++ 可能没有 tzdb;localtime_r + strftime 是当前平台的稳定回退。
227 std::tm local{};
228 if (localtime_r(&seconds, &local) == nullptr) {
229 return {};
230 }
231 char buffer[32]{};
232 if (std::strftime(buffer, sizeof(buffer), "%Y-%m-%d %H:%M:%S", &local) == 0) {
233 return {};
234 }
235 return buffer;
236}
237
238std::expected<SyncSummary, SyncError> sync_all(const fs::path& search_path, const DbPaths& paths) {
239 // 接口日志先保存业务入参,输出在 operation 完成后统一记录。
240 const std::string input =
241 std::format("search_path={} project_db={} stack_db={}", search_path.string(),
242 paths.project_db.string(), paths.stack_db.string());
243 SyncSummary summary;
244 std::optional<SyncError> first_error;
245
246 // 整段同步收进立即调用 lambda:每个错误分支直接 return,避免深嵌套。
247 const auto operation = [&]() -> std::expected<std::monostate, SyncError> {
248 // 阶段 1:恢复历史路径,并校验/追加本次新路径。
249 auto library_paths = load_library_paths(paths.project_db);
250 if (!library_paths) {
251 return std::unexpected(library_paths.error());
252 }
253 if (!search_path.empty()) {
254 if (!fs::exists(search_path)) {
255 return std::unexpected(SyncError::search_path_missing);
256 }
257 if (!fs::is_directory(search_path)) {
258 return std::unexpected(SyncError::search_path_not_directory);
259 }
260 library_paths->insert(normalized_path(search_path).string());
261 }
262
263 // 阶段 2:遍历工作区,生成 Project 索引与扁平 Project/Stack 索引。
264 json project_nodes = json::array();
265 json stack_nodes = json::array();
266 std::set<std::string> project_ids;
267
268 for (const auto& library_path_string : *library_paths) {
269 const fs::path library_path(library_path_string);
270 if (!fs::exists(library_path) || !fs::is_directory(library_path)) {
271 continue;
272 }
273 summary.library_paths.push_back(library_path);
274
275 // 先收集后排序,消除 recursive_directory_iterator 的平台相关顺序。
276 std::vector<fs::directory_entry> project_files;
277 try {
278 for_each_project_file(library_path, [&](const fs::directory_entry& entry) {
279 project_files.push_back(entry);
280 });
281 } catch (const std::exception&) {
282 if (!first_error) {
283 first_error = SyncError::io_failure;
284 }
285 continue;
286 }
287 std::ranges::sort(project_files, {},
288 [](const auto& entry) { return entry.path().lexically_normal().string(); });
289
290 for (const auto& entry : project_files) {
291 try {
292 // 单个项目损坏时记录路径并继续,不牺牲同一工作区的其他项目。
293 std::ifstream project_file(entry.path(), std::ios::binary);
294 json content;
295 if (!(project_file >> content) || !content.is_object()) {
296 summary.failed_projects.push_back(entry.path());
297 if (!first_error) {
299 }
300 continue;
301 }
302
303 const std::string project_id = safe_string(content, "id");
304 // 空 ID/重复 ID 不能进入索引,否则 Alfred action token 无法稳定寻址。
305 if (project_id.empty() || !project_ids.insert(project_id).second) {
306 summary.failed_projects.push_back(entry.path());
307 if (!first_error) {
309 }
310 continue;
311 }
312 const std::string project_title = safe_string(content, "title", "Untitled");
313 const std::string project_path = normalized_path(entry.path()).string();
314 const std::string modified = format_file_time(entry.last_write_time());
315
316 // 项目自身同时出现在 Project 与 Stack 索引,允许项目级直接创建任务。
317 project_nodes.push_back({
318 {"id", project_id},
319 {"title", project_title},
320 {"path", project_path},
321 {"library_path", library_path.string()},
322 {"mtime", modified},
323 });
324 stack_nodes.push_back({
325 {"id", project_id},
326 {"projectid", project_id},
327 {"title", project_title},
328 {"kind", "project"},
329 {"project_path", project_path},
330 {"library_path", library_path.string()},
331 });
332
333 if (!content.contains("stacks") || !content["stacks"].is_array()) {
334 continue;
335 }
336 // 清单节点继承 projectid,WorkflowUI 可据此显示“项目 → 清单”三级路径。
337 for (const auto& stack : content["stacks"]) {
338 if (!stack.is_object()) {
339 continue;
340 }
341 const std::string stack_id = safe_string(stack, "id");
342 if (stack_id.empty()) {
343 continue;
344 }
345 stack_nodes.push_back({
346 {"id", stack_id},
347 {"projectid", project_id},
348 {"title", safe_string(stack, "title", "Untitled")},
349 {"kind", "stack"},
350 {"project_path", project_path},
351 {"library_path", library_path.string()},
352 });
353 }
354 } catch (const std::exception&) {
355 summary.failed_projects.push_back(entry.path());
356 if (!first_error) {
358 }
359 }
360 }
361 }
362
363 summary.project_count = project_nodes.size();
364 summary.stack_count = stack_nodes.size();
365
366 // 阶段 3:先完整构造两个 JSON,再分别做原子替换。
367 const json project_db = {
368 {"library_paths", json(*library_paths)},
369 {"nodes", std::move(project_nodes)},
370 {"updateTime", now_timestamp()},
371 };
372 const json stack_db = {
373 {"nodes", std::move(stack_nodes)},
374 {"updateTime", now_timestamp()},
375 };
376 if (!atomic_write_json(paths.project_db, project_db) ||
377 !atomic_write_json(paths.stack_db, stack_db)) {
378 return std::unexpected(SyncError::io_failure);
379 }
380 // 部分项目损坏仍完成索引刷新,但调用方会收到明确的分类错误。
381 if (first_error) {
382 return std::unexpected(*first_error);
383 }
384 return {};
385 }();
386
387 // 所有可恢复错误都通过 expected 返回;日志记录稳定的摘要而非异常栈。
388 const bool ok = operation.has_value();
389 const std::string output =
390 std::format("ok={} projects={} stacks={} failed_projects={} error={}", ok,
391 summary.project_count, summary.stack_count, summary.failed_projects.size(),
392 ok ? std::string{} : sync_error_message(operation.error()));
393 interface_log::record(interface_log::ApiName("ProjectSync::sync_all"),
396 if (!operation) {
397 return std::unexpected(operation.error());
398 }
399 return summary;
400}
401
402std::string_view sync_error_message(SyncError error) noexcept {
403 // 错误文案是 Alfred/Debug trace 的对外契约,集中在此维护。
404 switch (error) {
406 return "search path does not exist";
408 return "search path is not a directory";
410 return "cannot parse project database";
412 return "one or more project files cannot be parsed";
414 return "failed to write database";
415 }
416 return "unknown sync error";
417}
418
419} // namespace ProjectSync
接口输入/输出日志模块(STATIC 模块库)。
Stacks 2 项目/看板索引模块。
fs::path normalized_path(const fs::path &value)
解析为绝对规范路径;canonical 失败时退化为 lexically_normal。
std::string safe_string(const json &object, std::string_view key, std::string fallback={})
防御性读取 JSON 对象中的字符串字段。
bool atomic_write_json(const fs::path &target, const json &value)
通过同目录临时文件 + rename 原子写入 JSON。
std::expected< std::set< std::string >, SyncError > load_library_paths(const fs::path &project_db)
从现有 Project.json 读取并规范化 library_paths。
void for_each_project_file(const std::filesystem::path &directory, Visitor &&visitor)
递归遍历工作区中的 .project 文件。
std::expected< SyncSummary, SyncError > sync_all(const std::filesystem::path &search_path, const DbPaths &paths={})
同步新增路径与全部已记忆路径,并原子重建两个索引。
std::string format_file_time(std::filesystem::file_time_type file_time)
把 filesystem 写入时间转换为本地 YYYY-MM-DD HH:MM:SS。
std::string_view sync_error_message(SyncError error) noexcept
SyncError 到稳定、可读的 CLI/Alfred 错误文本。
SyncError
工作区同步的可恢复错误分类。
@ project_db_unreadable
现有 Project.json 无法解析。
@ project_file_unreadable
一个或多个 .project 文件损坏。
@ search_path_missing
新路径不存在。
@ search_path_not_directory
新路径是文件而不是目录。
@ io_failure
目录遍历或索引写入失败。
void record(ApiName api, LogText input, LogText output, ApiStatus status)
记录一次接口调用。
@ failed
调用失败(错误已记录在 output 文本中)
项目索引数据库路径。
std::filesystem::path stack_db
Project/Stack 扁平索引。
std::filesystem::path project_db
Project 索引。
同步结果摘要;用于 CLI、Alfred 反馈和接口日志。
std::vector< std::filesystem::path > library_paths
实际扫描的工作区。
std::size_t stack_count
项目 + 清单节点数。
std::vector< std::filesystem::path > failed_projects
无法解析的项目文件。
std::size_t project_count
有效项目数。
接口名强类型。 语义:调用方必须显式构造 ApiName,避免把任意字符串误当接口名。
接口输入/输出文本强类型。 语义:与 ApiName 一样显式构造,参数顺序在编译期被类型约束。