diff --git a/data/io.github.sam_fic.filecollector.metainfo.xml b/data/io.github.sam_fic.filecollector.metainfo.xml
index e37d98b..4d74e7f 100644
--- a/data/io.github.sam_fic.filecollector.metainfo.xml
+++ b/data/io.github.sam_fic.filecollector.metainfo.xml
@@ -32,7 +32,7 @@
Choosing Chinese or English in preferences now takes effect on the Windows portable package: gettext ignored the LANGUAGE variable whenever the locale fell back to "C" (the default when no LANG/LC_* variable is set), so the setting appeared to do nothing; the app now sets LANG alongside LANGUAGE
"Follow System" now actually follows the Windows user locale (e.g. zh-CN): the bundled gettext build cannot see the Windows locale on its own, so the app resolves it via g_win32_getlocale and feeds it to LANG
The resolved locale survives application restarts (previously the locale env left behind by a restart chain could pin the old language), while a deliberately set external LANG is still respected
- Windows packaging CI now runs a runtime i18n smoke test (CLI --help must come out in Chinese), so a broken translation chain fails the build instead of reaching users
+ Windows packaging CI now runs a runtime i18n smoke test that checks the packaged binary can load the zh_CN catalog for CLI --help; it validates catalog reachability, not the preference or follow-system language paths (that coverage lands in the following patch release)
@@ -40,7 +40,7 @@
Project file compatibility fixes:
- - Project files saved by the Flet edition now open without losing the checked state: the loader accepts both checked_files and checked_entries field names
+ - Project files saved by the Flet edition no longer lose checked state when entries are path strings: the loader accepts both checked_files and checked_entries field names, and falls back when checked_files is missing or is not an array
- Checked paths are normalized on load (forward slashes and work-dir-relative paths), so tree checkmarks still match when the two editions serialize paths differently
- Malformed or non-string entries in the checked list are skipped instead of crashing the loader
diff --git a/docs/I18N.md b/docs/I18N.md
index 77ab607..98609f2 100644
--- a/docs/I18N.md
+++ b/docs/I18N.md
@@ -114,7 +114,13 @@ sudo meson install
| 新增含 `_()` 的源文件 | 将路径添加到 `POTFILES` → 重新生成 `.pot` → 合并 → 翻译 |
| 新增 `.blp` 文件中的字符串 | 先编译 Blueprint → 重新生成 `.pot` → 合并 → 翻译 |
| 新增界面语言 | 在 `LINGUAS` 添加语言代码 → 创建 `.po` → 在 `meson.build` 的 `languages` 数组添加 → 翻译 |
-| 临时切换运行语言 | Linux/macOS:`LANGUAGE=en filecollector`,对 GUI 和 CLI 均生效;**Windows 下 `LANGUAGE` 会被 gettext 忽略**(无 `LANG`/`LC_*` 变量时 locale 落回 "C",这是 GNU gettext 的既定行为),须改用 `LANG=zh_CN.UTF-8 filecollector` 或 `LANG=en filecollector` |
+| 临时切换运行语言 | Linux/macOS:`LANGUAGE=en filecollector`,对 GUI 和 CLI 均生效;Windows:GNU gettext **仅在当前 locale 为 "C" 时**忽略 `LANGUAGE`(无 `LANG`/`LC_*` 变量时 locale 落回 "C"),故临时切换用 `LANG=zh_CN.UTF-8 filecollector` 或 `LANG=en filecollector` |
+
+**语言环境的生效范围**:偏好设置里的「中文 / English / 跟随系统」在 `main()` 里、`app.run()` 之前统一应用,GUI、纯 CLI(`--help` / `--load` / `--export`)和二次实例走同一条链路。Windows 上:
+
+- 显式选择中文或英文时,应用会同时设置 `LANGUAGE` / `LANG` 并清掉外部的 `LC_ALL` / `LC_MESSAGES`(它们的优先级高于 `LANG`,残留 `LC_ALL=C` 会让设置失效)。
+- 「跟随系统」通过 `g_win32_getlocale()` 读取 Windows 用户 locale;若外部明确设置了真实语言(`LC_ALL` / `LC_MESSAGES` / `LANG` 之一),则尊重外部设置,仅在变量为空或为 `C` / `C.*` / `POSIX` 时才接管。
+- Windows 打包脚本的 i18n 冒烟测试使用干净环境与临时 `XDG_CONFIG_HOME`,由应用自己解析设置,不再靠外部注入 `LANG` 来「绕过」这段代码。
---
diff --git a/src/cli.vala b/src/cli.vala
index 8ca98d5..438704a 100644
--- a/src/cli.vala
+++ b/src/cli.vala
@@ -501,32 +501,18 @@ public class CliController : GLib.Object {
checked_paths.clear ();
checked_dirs.clear ();
- // 字段名兼容: 本版写 checked_files, Flet 版写 checked_entries. 优先读自己的,
- // 缺失时回退到另一个, 两个版本互相打开项目时勾选状态不丢.
- Json.Array? checked_arr = null;
- if (root.has_member ("checked_files")) {
- checked_arr = root.get_array_member ("checked_files");
- } else if (root.has_member ("checked_entries")) {
- checked_arr = root.get_array_member ("checked_entries");
- }
- if (checked_arr != null) {
- for (int i = 0; i < checked_arr.get_length (); i++) {
- var p = checked_arr.get_string_element (i);
- // Flet 旧文件可能是对象数组等非字符串元素, get_string_element
- // 对其返回 null, 跳过而不是把 null 收进集合
- if (p == null || p == "") continue;
- var canonical = ProjectManager.canonicalize_checked_path (p, work_dir);
- if (canonical != null) {
- checked_paths.add (canonical);
- }
+ // 与 ProjectManager.load_project_file 共用字段兼容 / null 防御 / 路径
+ // 归一化规则, 并同样按存在性过滤, 保证 GUI 与 CLI 加载结果一致,
+ // 也不会把不存在的路径经 --save 写回项目文件。
+ foreach (var p in ProjectManager.read_string_array (root, "checked_files", "checked_entries")) {
+ var canonical = ProjectManager.canonicalize_checked_path (p, work_dir);
+ if (canonical != null && File.new_for_path (canonical).query_exists ()) {
+ checked_paths.add (canonical);
}
}
- var checked_dirs_arr = root.has_member ("checked_dirs") ? root.get_array_member ("checked_dirs") : null;
- if (checked_dirs_arr != null) {
- for (int i = 0; i < checked_dirs_arr.get_length (); i++) {
- checked_dirs.add (checked_dirs_arr.get_string_element (i));
- }
+ foreach (var d in ProjectManager.read_string_array (root, "checked_dirs", "")) {
+ checked_dirs.add (d);
}
var items_arr = root.has_member ("items") ? root.get_array_member ("items") : null;
@@ -571,11 +557,8 @@ public class CliController : GLib.Object {
}
common_phrases.clear ();
- var phrases_arr = root.has_member ("common_phrases") ? root.get_array_member ("common_phrases") : null;
- if (phrases_arr != null) {
- for (int i = 0; i < phrases_arr.get_length (); i++) {
- common_phrases.add (phrases_arr.get_string_element (i));
- }
+ foreach (var phrase in ProjectManager.read_string_array (root, "common_phrases", "")) {
+ common_phrases.add (phrase);
}
stdout.printf (_("✓ Loaded from project file: %s (%d items)\n"), path, items.size);
diff --git a/src/main.vala b/src/main.vala
index 434a998..2da6f73 100644
--- a/src/main.vala
+++ b/src/main.vala
@@ -78,6 +78,93 @@ public class FileCollectorApp : Adw.Application {
setup_app_icon_resource ();
}
+ // 语言环境必须在 GApplication 启动之前统一准备: startup() 早于
+ // command_line(), 而纯 CLI 分支会在 command_line() 里直接 return。
+ // 把设置留在 command_line() 会让 --help / --load / --export 全部跳过它。
+ private static void apply_language_environment () {
+ var lang_setting = FileCollectorWindow.load_settings_language ();
+ if (lang_setting == "en") {
+ GLib.Environment.set_variable ("LANGUAGE", "en", true);
+#if WINDOWS
+ // Windows 的 gettext 只在当前 locale 非 "C" 时才读取 LANGUAGE
+ // (GNU gettext 既定行为); 无 LANG/LC_* 时 locale 落回 "C",
+ // LANGUAGE 被整体忽略 -> 语言设置永远只有英文。
+ // LC_ALL / LC_MESSAGES 优先级高于 LANG, 必须一并摘掉, 否则从
+ // Git Bash / MSYS2 (常见 LC_ALL=C) 启动时设置依旧失效。
+ GLib.Environment.set_variable ("LANG", "en", true);
+ GLib.Environment.unset_variable ("LC_ALL");
+ GLib.Environment.unset_variable ("LC_MESSAGES");
+ GLib.Environment.set_variable ("FILECOLLECTOR_LANG_MANAGED", "1", true);
+#endif
+ } else if (lang_setting == "zh") {
+ GLib.Environment.set_variable ("LANGUAGE", "zh_CN", true);
+#if WINDOWS
+ GLib.Environment.set_variable ("LANG", "zh_CN.UTF-8", true);
+ GLib.Environment.unset_variable ("LC_ALL");
+ GLib.Environment.unset_variable ("LC_MESSAGES");
+ GLib.Environment.set_variable ("FILECOLLECTOR_LANG_MANAGED", "1", true);
+#endif
+ } else {
+ GLib.Environment.unset_variable ("LANGUAGE");
+#if WINDOWS
+ follow_windows_system_locale ();
+#endif
+ }
+
+ Intl.setlocale (LocaleCategory.ALL, "");
+ setup_i18n_default ();
+ }
+
+#if WINDOWS
+ // "C" / "C.*" / "POSIX" / 空都不表达语言偏好, 可由应用覆盖。
+ private static bool is_placeholder_locale (string? value) {
+ string v = value ?? "";
+ return v == "" || v == "C" || v == "POSIX" || v.has_prefix ("C.");
+ }
+
+ // g_win32_getlocale() 无环境变量时返回 "xx_YY" 或 "xx_YY@Latn"。
+ // POSIX locale 名里 codeset 必须排在 @modifier 之前, 直接拼
+ // "xx_YY@Latn" + ".UTF-8" 会得到 UCRT 无法解析的垃圾值。
+ private static string build_windows_lang (string sys_locale) {
+ string base_name = sys_locale;
+ string modifier = "";
+ int at = base_name.index_of ("@");
+ if (at > 0) {
+ modifier = base_name.substring (at);
+ base_name = base_name.substring (0, at);
+ }
+ if (base_name.contains (".")) return base_name + modifier;
+ return base_name + ".UTF-8" + modifier;
+ }
+
+ private static void follow_windows_system_locale () {
+ // g_win32_getlocale() 按 LC_ALL -> LC_MESSAGES -> LANG 原样返回首个
+ // 非空值, 只有三者全空才查 GetThreadLocale。重启子进程带着上一会话
+ // 写入的 LANG (再拼 .UTF-8 会得到垃圾值), Git Bash 还会塞
+ // LANG=C.UTF-8, 所以取系统 locale 前必须把三个变量全部摘掉。
+ bool lang_is_ours = GLib.Environment.get_variable ("FILECOLLECTOR_LANG_MANAGED") != null;
+ bool external_override =
+ !is_placeholder_locale (GLib.Environment.get_variable ("LC_ALL")) ||
+ !is_placeholder_locale (GLib.Environment.get_variable ("LC_MESSAGES")) ||
+ (!lang_is_ours && !is_placeholder_locale (GLib.Environment.get_variable ("LANG")));
+ if (external_override) return; // 用户在 shell 里明确设了真实语言, 尊重
+
+ GLib.Environment.unset_variable ("LC_ALL");
+ GLib.Environment.unset_variable ("LC_MESSAGES");
+ GLib.Environment.unset_variable ("LANG");
+ // 不恢复被摘掉的变量: 能走到这里说明它们要么由本应用写入, 要么是
+ // "C"/空; 恢复 C 会反过来压过 LANG, 让 gettext 再次忽略 LANGUAGE。
+ var sys_locale = GLib.Win32.getlocale ();
+ if (sys_locale != null && sys_locale.length > 0 && sys_locale != "C") {
+ GLib.Environment.set_variable ("LANG", build_windows_lang (sys_locale), true);
+ GLib.Environment.set_variable ("FILECOLLECTOR_LANG_MANAGED", "1", true);
+ } else {
+ GLib.Environment.unset_variable ("LANG");
+ GLib.Environment.unset_variable ("FILECOLLECTOR_LANG_MANAGED");
+ }
+ }
+#endif
+
protected override int command_line (ApplicationCommandLine command_line) {
var args = command_line.get_arguments ();
@@ -124,85 +211,12 @@ public class FileCollectorApp : Adw.Application {
return 0;
}
+ // 语言环境已在 main() 里统一准备, GUI / CLI / 二次实例走同一条链路。
if (!force_gui && has_cli_args) {
- Intl.setlocale (LocaleCategory.ALL, "");
- setup_i18n_default ();
var cli = new CliController ();
return cli.run (filtered_args);
}
- var lang_setting = FileCollectorWindow.load_settings_language ();
- if (lang_setting == "en") {
- GLib.Environment.set_variable ("LANGUAGE", "en", true);
-#if WINDOWS
- // Windows 的 gettext 只在当前 locale 非 "C" 时才读取 LANGUAGE
- // (GNU gettext 的既定行为); 而此环境在无 LANG/LC_* 变量时 locale
- // 落回 "C", LANGUAGE 被整体忽略 -> 语言设置永远只有英文。
- // 实测须同时设 LANG 让 locale 脱离 "C":
- // 仅 LANGUAGE=zh_CN -> 英文
- // LANGUAGE=zh_CN + LANG=zh_CN.UTF-8 -> 中文
- // zh 用 "zh_CN.UTF-8" 是为了与 locale/ 下的目录名一致 (gettext
- // 按 LANG 值逐级尝试 zh_CN -> zh 查找 .mo), 且 UCRT 可解析。
- GLib.Environment.set_variable ("LANG", "en", true);
- GLib.Environment.set_variable ("FILECOLLECTOR_LANG_MANAGED", "1", true);
-#endif
- } else if (lang_setting == "zh") {
- GLib.Environment.set_variable ("LANGUAGE", "zh_CN", true);
-#if WINDOWS
- GLib.Environment.set_variable ("LANG", "zh_CN.UTF-8", true);
- GLib.Environment.set_variable ("FILECOLLECTOR_LANG_MANAGED", "1", true);
-#endif
- } else {
- GLib.Environment.unset_variable ("LANGUAGE");
-#if WINDOWS
- // 跟随系统: 此 gettext 构建在没有 LANG/LC_* 变量时拿不到 Windows
- // 的用户语言 (locale 落回 "C" -> 英文, 见上方注释), 因此把 Windows
- // 用户 locale 翻成 Unix 风格名字 (zh-CN -> "zh_CN") 喂给 LANG。
- //
- // g_win32_getlocale() 的实现是: 有 LANG/LC_* 环境变量时原样返回
- // 其值, 只有全空才查 Windows (GetThreadLocale)。重启子进程总带着
- // 上一会话写入的 LANG (拼 .UTF-8 会得到垃圾值), Git Bash 还会塞
- // LANG=C.UTF-8 —— 所以先临时摘掉这三个变量, 强制走 Win32 路径。
- //
- // FILECOLLECTOR_LANG_MANAGED 标记 LANG 是否由本应用写入:
- // - 有标记 (重启链路) -> 无条件覆盖, 保证 zh/en 切回"跟随系统"
- // 时不会残留旧语言;
- // - 无标记但 LANG 是 "C"/"C.*"/"POSIX" (Git Bash 默认, 不表达
- // 语言偏好) -> 视同未设置, 覆盖为系统 locale;
- // - 无标记且 LANG 有真实语言 (用户在 shell 里故意设的) -> 尊重。
- bool lang_is_ours = GLib.Environment.get_variable ("FILECOLLECTOR_LANG_MANAGED") != null;
- string inherited = GLib.Environment.get_variable ("LANG") ?? "";
- bool lang_is_c = inherited == "C" || inherited.has_prefix ("C.") || inherited == "POSIX";
- if (lang_is_ours || inherited.length == 0 || lang_is_c) {
- string? saved_lc_all = GLib.Environment.get_variable ("LC_ALL");
- string? saved_lc_msg = GLib.Environment.get_variable ("LC_MESSAGES");
- string? saved_lang = GLib.Environment.get_variable ("LANG");
- GLib.Environment.unset_variable ("LC_ALL");
- GLib.Environment.unset_variable ("LC_MESSAGES");
- GLib.Environment.unset_variable ("LANG");
- var sys_locale = GLib.Win32.getlocale ();
- if (saved_lc_all != null) {
- GLib.Environment.set_variable ("LC_ALL", saved_lc_all, true);
- }
- if (saved_lc_msg != null) {
- GLib.Environment.set_variable ("LC_MESSAGES", saved_lc_msg, true);
- }
- if (saved_lang != null) {
- GLib.Environment.set_variable ("LANG", saved_lang, true);
- }
- if (sys_locale != null && sys_locale.length > 0 && !sys_locale.has_prefix ("C")) {
- GLib.Environment.set_variable ("LANG", sys_locale + ".UTF-8", true);
- GLib.Environment.set_variable ("FILECOLLECTOR_LANG_MANAGED", "1", true);
- } else {
- GLib.Environment.unset_variable ("LANG");
- }
- }
-#endif
- }
-
- Intl.setlocale (LocaleCategory.ALL, "");
- setup_i18n_default ();
-
activate ();
if (force_gui && has_cli_args) {
@@ -512,6 +526,9 @@ public class FileCollectorApp : Adw.Application {
// 先检测并接管父进程控制台 (管道环境下为空操作)
win32_console_init ();
#endif
+ // 必须在 app.run() 之前: startup() 与纯 CLI 分支都早于 command_line()
+ // 的自定义逻辑, 语言设置放在那里会漏掉 CLI 入口。
+ apply_language_environment ();
var app = new FileCollectorApp ();
int ret = app.run (args);
// 清理 BinaryConverter 复用的临时基目录, 防止 /tmp 下泄漏
diff --git a/src/services/project_manager.vala b/src/services/project_manager.vala
index 7414c86..d490154 100644
--- a/src/services/project_manager.vala
+++ b/src/services/project_manager.vala
@@ -3,20 +3,53 @@ using Gee;
public class ProjectManager : GLib.Object {
// 项目文件里勾选路径的归一化: Flet 版可能写正斜杠或相对 work_dir 的路径,
- // 而本版勾选匹配 (path in checked_files, CheckStateModel) 是字符串精确比较,
- // 必须转成 File.get_path () 的规范形态才能命中; 无法解析时原样返回。
+ // 而本版勾选匹配 (path in checked_files) 是字符串精确比较, 必须转成
+ // File.get_path () 的规范形态才能命中。
+ //
+ // 关键边界: File.new_for_path () 内部会 g_canonicalize_filename (path, NULL),
+ // 相对路径在 relative_to == NULL 时会被绑到当前工作目录。work_dir 缺失时
+ // 绝不能让它按 CWD 解析 —— 便携包双击启动的 CWD 是任意的, 会勾错同名文件,
+ // 还会被 CLI 的 --save 写回项目文件。此时返回 null, 由调用方丢弃该条目。
public static string? canonicalize_checked_path (string path, File? work_dir) {
if (path == "") return null;
try {
- var f = File.new_for_path (path);
- if (!GLib.Path.is_absolute (path) && work_dir != null) {
+ File f;
+ if (GLib.Path.is_absolute (path)) {
+ f = File.new_for_path (path);
+ } else if (work_dir != null) {
f = work_dir.resolve_relative_path (path);
+ } else {
+ return null;
}
var canonical = f.get_path ();
- return (canonical != null && canonical != "") ? canonical : path;
+ return (canonical != null && canonical != "") ? canonical : null;
} catch (Error e) {
- return path;
+ return null;
+ }
+ }
+
+ // 字符串数组读取的统一规则, GUI (load_project_file) 与 CLI (load_project)
+ // 共用: 取到 Json.Array 才算命中, 命中失败继续回退到 fallback 字段;
+ // 非字符串元素会被 get_string_element 返回 null, 一律跳过, 不能塞进集合。
+ // fallback 传 "" 表示无回退字段。
+ public static Gee.ArrayList read_string_array (
+ Json.Object obj, string primary, string fallback
+ ) {
+ var result = new Gee.ArrayList ();
+ Json.Array? arr = null;
+ if (obj.has_member (primary)) {
+ arr = obj.get_array_member (primary);
+ }
+ if (arr == null && fallback != "" && obj.has_member (fallback)) {
+ arr = obj.get_array_member (fallback);
}
+ if (arr == null) return result;
+ for (int i = 0; i < arr.get_length (); i++) {
+ var value = arr.get_string_element (i);
+ if (value == null || value == "") continue;
+ result.add (value);
+ }
+ return result;
}
public static void load_project_file (
@@ -68,32 +101,18 @@ public class ProjectManager : GLib.Object {
use_absolute = root.get_boolean_member_with_default ("use_absolute", false);
show_header = root.get_boolean_member_with_default ("show_header", false);
- // 字段名兼容: 本版写 checked_files, Flet 版写 checked_entries. 优先读自己的,
- // 缺失时回退到另一个, 两个版本互相打开项目时勾选状态不丢.
- Json.Array? checked_arr = null;
- if (root.has_member ("checked_files")) {
- checked_arr = root.get_array_member ("checked_files");
- } else if (root.has_member ("checked_entries")) {
- checked_arr = root.get_array_member ("checked_entries");
- }
- if (checked_arr != null) {
- for (int i = 0; i < checked_arr.get_length (); i++) {
- var p = checked_arr.get_string_element (i);
- // Flet 旧文件可能是对象数组等非字符串元素, get_string_element
- // 对其返回 null, 跳过而不是把 null 收进集合
- if (p == null || p == "") continue;
- var canonical = canonicalize_checked_path (p, work_dir);
- if (canonical != null && File.new_for_path (canonical).query_exists ()) {
- checked_paths.add (canonical);
- }
+ // checked_files / checked_entries 兼容 + 路径归一化; GUI 与 CLI 走同一套规则。
+ foreach (var p in read_string_array (root, "checked_files", "checked_entries")) {
+ var canonical = canonicalize_checked_path (p, work_dir);
+ if (canonical != null && File.new_for_path (canonical).query_exists ()) {
+ checked_paths.add (canonical);
}
}
- var checked_dirs_arr = root.has_member ("checked_dirs") ? root.get_array_member ("checked_dirs") : null;
- if (checked_dirs_arr != null) {
- for (int i = 0; i < checked_dirs_arr.get_length (); i++) {
- checked_dirs.add (checked_dirs_arr.get_string_element (i));
- }
+ // 目录勾选只做 null / 类型防御, 不改路径形态: checked_dirs 在 UI 里
+ // 与 checked_files 的生成路径不同, 贸然归一化会改变既有树状态语义。
+ foreach (var d in read_string_array (root, "checked_dirs", "")) {
+ checked_dirs.add (d);
}
var items_arr = root.has_member ("items") ? root.get_array_member ("items") : null;
@@ -127,11 +146,8 @@ public class ProjectManager : GLib.Object {
}
common_phrases.clear ();
- var phrases_arr = root.has_member ("common_phrases") ? root.get_array_member ("common_phrases") : null;
- if (phrases_arr != null) {
- for (int i = 0; i < phrases_arr.get_length (); i++) {
- common_phrases.add (phrases_arr.get_string_element (i));
- }
+ foreach (var phrase in read_string_array (root, "common_phrases", "")) {
+ common_phrases.add (phrase);
}
// 工作区快照(向后兼容:旧工程文件无 snapshots 字段时为空)
@@ -179,17 +195,16 @@ public class ProjectManager : GLib.Object {
}
}
- var scp = so.has_member ("checked_files") ? so.get_array_member ("checked_files") : null;
- if (scp != null) {
- for (int j = 0; j < scp.get_length (); j++) snap.checked_paths.add (scp.get_string_element (j));
+ // 快照同样兼容 checked_entries 并做 null 防御; 路径不做归一化,
+ // 保留保存时的原样, 避免恢复快照时改变既有勾选语义。
+ foreach (var p in read_string_array (so, "checked_files", "checked_entries")) {
+ snap.checked_paths.add (p);
}
- var scd = so.has_member ("checked_dirs") ? so.get_array_member ("checked_dirs") : null;
- if (scd != null) {
- for (int j = 0; j < scd.get_length (); j++) snap.checked_dirs.add (scd.get_string_element (j));
+ foreach (var d in read_string_array (so, "checked_dirs", "")) {
+ snap.checked_dirs.add (d);
}
- var sph = so.has_member ("common_phrases") ? so.get_array_member ("common_phrases") : null;
- if (sph != null) {
- for (int j = 0; j < sph.get_length (); j++) snap.common_phrases.add (sph.get_string_element (j));
+ foreach (var ph in read_string_array (so, "common_phrases", "")) {
+ snap.common_phrases.add (ph);
}
snap.ai_mode = so.get_string_member_with_default ("ai_mode", "default");
diff --git a/tests/meson.build b/tests/meson.build
index 3b5d2e5..ef3871c 100644
--- a/tests/meson.build
+++ b/tests/meson.build
@@ -183,3 +183,20 @@ test_office_text_extractor_exe = executable('test_office_text_extractor',
dependencies: [glib_dep, gio_dep, gee_dep],
)
test('test_office_text_extractor', test_office_text_extractor_exe)
+
+# ─── 13. ProjectManager 项目文件加载 (字段兼容 / 路径归一化 / null 防御) ───
+# load_project_file 依赖 ItemData / WorkspaceSnapshot / ConfigManager.atomic_write_json,
+# 后者由 stubs.vala 提供; 源文件闭包与 test_app_state 保持一致。
+project_manager_src = files(join_paths(src_dir, 'services', 'project_manager.vala'))
+test_project_manager_exe = executable('test_project_manager',
+ 'test_project_manager.vala',
+ project_manager_src,
+ app_state_src,
+ item_data_src,
+ token_estimator_src,
+ workspace_snapshot_src,
+ directory_item_src,
+ stubs_src,
+ dependencies: [glib_dep, gio_dep, gee_dep, json_dep, m_dep],
+)
+test('test_project_manager', test_project_manager_exe)
diff --git a/tests/test_project_manager.vala b/tests/test_project_manager.vala
new file mode 100644
index 0000000..4cfb3ed
--- /dev/null
+++ b/tests/test_project_manager.vala
@@ -0,0 +1,264 @@
+// ProjectManager 单元测试
+// 覆盖 4.10.1 / 4.10.2 的项目文件兼容性修复:
+// - checked_files / checked_entries 字段回退 (primary 非数组也要回退)
+// - 非字符串 / null / 空串元素防御
+// - 绝对路径与相对路径归一化
+// - work_dir 缺失时不得把相对路径绑到进程 CWD
+// CLI 的 load_project 已改为复用 ProjectManager 的这两个 public helper,
+// 因此这里覆盖了 GUI / CLI 共用的读取规则。
+
+using GLib;
+using Gee;
+
+int pass_count = 0;
+int fail_count = 0;
+
+void pass (string desc) { print ("PASS: %s\n", desc); pass_count++; }
+void fail (string desc, string detail = "") {
+ print ("FAIL: %s%s%s\n", desc, detail.length > 0 ? " - " : "", detail);
+ fail_count++;
+}
+void assert_true (string desc, bool cond) {
+ if (cond) pass (desc);
+ else fail (desc);
+}
+void assert_eq_int (string desc, int expected, int actual) {
+ if (expected == actual) pass (desc);
+ else fail (desc, @"期望 $expected, 实际 $actual");
+}
+void assert_eq_str (string desc, string expected, string actual) {
+ if (expected == actual) pass (desc);
+ else fail (desc, @"期望 '$expected', 实际 '$actual'");
+}
+
+// Vala 不允许在命名空间级给 owned 字段带初始化器 (会被 valac 拒绝),
+// 统一由 setup_fixture() 在 main() 里赋值。
+private static string tmp_dir;
+private static string real_file;
+private static string expected_path;
+
+private static void setup_fixture () throws Error {
+ tmp_dir = GLib.Path.build_filename (
+ GLib.Environment.get_tmp_dir (), "filecollector-pm-tests");
+ GLib.DirUtils.create_with_parents (tmp_dir, 0755);
+ real_file = GLib.Path.build_filename (tmp_dir, "a.txt");
+ GLib.FileUtils.set_contents (real_file, "hello");
+ expected_path = File.new_for_path (real_file).get_path () ?? "";
+}
+
+private static string write_json (string name, Json.Builder builder) throws Error {
+ string path = GLib.Path.build_filename (tmp_dir, name);
+ var gen = new Json.Generator ();
+ gen.set_root (builder.get_root ());
+ // to_stream / File.replace 都是项目里已使用的 API, 不依赖 to_data 绑定差异。
+ var stream = File.new_for_path (path).replace (null, false, FileCreateFlags.NONE);
+ gen.to_stream (stream, null);
+ stream.close ();
+ return path;
+}
+
+// Windows 上把 \ 换成 /, 模拟 Flet 版写出的正斜杠路径; Linux 上无变化。
+private static string forward_slashes (string path) {
+ var sb = new StringBuilder ();
+ for (int i = 0; i < path.length; i++) {
+ char c = path[i];
+ sb.append_c (c == '\\' ? '/' : c);
+ }
+ return sb.str;
+}
+
+// ─── 1. 路径归一化 ────────────────────────────────────────────────
+
+void test_canonicalize_checked_path () {
+ print ("\n=== test_canonicalize_checked_path ===\n");
+ var wd = File.new_for_path (tmp_dir);
+
+ assert_eq_str ("absolute path normalizes to File.get_path()",
+ expected_path,
+ ProjectManager.canonicalize_checked_path (real_file, null) ?? "");
+
+ assert_eq_str ("forward-slash absolute path normalizes",
+ expected_path,
+ ProjectManager.canonicalize_checked_path (forward_slashes (real_file), null) ?? "");
+
+ assert_eq_str ("relative path resolves against work_dir",
+ expected_path,
+ ProjectManager.canonicalize_checked_path ("a.txt", wd) ?? "");
+
+ // B3: File.new_for_path() 内部会把相对路径按 CWD 规范化, 不能用它兜底。
+ assert_true ("relative path without work_dir is dropped (no CWD binding)",
+ ProjectManager.canonicalize_checked_path ("a.txt", null) == null);
+
+ assert_true ("empty path is dropped",
+ ProjectManager.canonicalize_checked_path ("", wd) == null);
+}
+
+// ─── 2. 字段回退与 null 防御 ──────────────────────────────────────
+
+void test_read_string_array () {
+ print ("\n=== test_read_string_array ===\n");
+
+ // primary 存在但值不是数组 -> 必须继续回退, 不能"命中即停"。
+ var b = new Json.Builder ();
+ b.begin_object ();
+ b.set_member_name ("checked_files");
+ b.add_null_value ();
+ b.set_member_name ("checked_entries");
+ b.begin_array ();
+ b.add_string_value ("kept");
+ b.add_null_value ();
+ b.begin_object (); b.end_object ();
+ b.add_string_value ("");
+ b.end_array ();
+ b.end_object ();
+ Json.Object? obj = b.get_root ().get_object ();
+ if (obj == null) { fail ("fallback fixture root is not an object"); return; }
+ var values = ProjectManager.read_string_array (obj, "checked_files", "checked_entries");
+ assert_eq_int ("falls back when primary is not an array", 1, values.size);
+ if (values.size == 1) {
+ assert_eq_str ("null/object/empty elements are skipped", "kept", values.get (0));
+ }
+
+ // primary 是正常数组时优先, 不读 fallback。
+ var b2 = new Json.Builder ();
+ b2.begin_object ();
+ b2.set_member_name ("checked_files");
+ b2.begin_array (); b2.add_string_value ("own"); b2.end_array ();
+ b2.set_member_name ("checked_entries");
+ b2.begin_array (); b2.add_string_value ("other"); b2.end_array ();
+ b2.end_object ();
+ Json.Object? obj2 = b2.get_root ().get_object ();
+ if (obj2 == null) { fail ("primary fixture root is not an object"); return; }
+ var own = ProjectManager.read_string_array (obj2, "checked_files", "checked_entries");
+ assert_eq_int ("primary array wins", 1, own.size);
+ if (own.size == 1) {
+ assert_eq_str ("primary value kept", "own", own.get (0));
+ }
+
+ // 无回退字段: null 元素不能进入集合。
+ var b3 = new Json.Builder ();
+ b3.begin_object ();
+ b3.set_member_name ("checked_dirs");
+ b3.begin_array ();
+ b3.add_null_value ();
+ b3.add_string_value ("dir");
+ b3.end_array ();
+ b3.end_object ();
+ Json.Object? obj3 = b3.get_root ().get_object ();
+ if (obj3 == null) { fail ("checked_dirs fixture root is not an object"); return; }
+ var dirs = ProjectManager.read_string_array (obj3, "checked_dirs", "");
+ assert_eq_int ("checked_dirs null element skipped", 1, dirs.size);
+}
+
+// ─── 3. 端到端加载 ────────────────────────────────────────────────
+
+private static string build_project_fixture (string name, bool with_work_dir) throws Error {
+ var b = new Json.Builder ();
+ b.begin_object ();
+ if (with_work_dir) {
+ b.set_member_name ("work_dir");
+ b.add_string_value (tmp_dir);
+ }
+ b.set_member_name ("checked_entries");
+ b.begin_array ();
+ b.add_string_value (forward_slashes (real_file));
+ b.add_null_value ();
+ b.begin_object (); b.end_object ();
+ b.add_string_value ("missing.txt");
+ b.end_array ();
+ b.set_member_name ("checked_dirs");
+ b.begin_array ();
+ b.add_null_value ();
+ b.add_string_value ("some-dir");
+ b.end_array ();
+ b.set_member_name ("common_phrases");
+ b.begin_array ();
+ b.add_null_value ();
+ b.add_string_value ("phrase");
+ b.end_array ();
+ b.end_object ();
+ return write_json (name, b);
+}
+
+private static void load_fixture (
+ string path,
+ out Gee.HashSet checked,
+ out Gee.HashSet dirs,
+ out Gee.ArrayList phrases
+) throws Error {
+ var items = new Gee.ArrayList ();
+ var snaps = new Gee.ArrayList ();
+ checked = new Gee.HashSet ();
+ dirs = new Gee.HashSet ();
+ phrases = new Gee.ArrayList ();
+ File? wd = null;
+ string? pf = null;
+ bool ua = false;
+ bool sh = false;
+ ProjectManager.load_project_file (
+ path, items, checked, dirs, phrases, snaps,
+ out wd, out pf, out ua, out sh);
+}
+
+void test_load_project_file () {
+ print ("\n=== test_load_project_file ===\n");
+ try {
+ string path = build_project_fixture ("flet-compat.fcol", true);
+ Gee.HashSet checked;
+ Gee.HashSet dirs;
+ Gee.ArrayList phrases;
+ load_fixture (path, out checked, out dirs, out phrases);
+
+ // 正斜杠绝对路径 + 存在性过滤后只剩 1 条; null / 对象 / 不存在项都被剔除。
+ assert_eq_int ("only existing checked path survives", 1, checked.size);
+ assert_true ("forward-slash path matches canonical path",
+ checked.contains (expected_path));
+ assert_eq_int ("checked_dirs null element skipped", 1, dirs.size);
+ assert_eq_int ("common_phrases null element skipped", 1, phrases.size);
+ } catch (Error e) {
+ fail ("load_project_file with checked_entries", e.message);
+ }
+}
+
+void test_relative_path_without_work_dir () {
+ print ("\n=== test_relative_path_without_work_dir ===\n");
+ try {
+ // 项目文件缺 work_dir, 只写相对勾选路径。
+ // 若实现用 File.new_for_path() 兜底, 会绑到 CWD 并可能误命中同名文件。
+ var b = new Json.Builder ();
+ b.begin_object ();
+ b.set_member_name ("checked_entries");
+ b.begin_array ();
+ b.add_string_value ("a.txt");
+ b.end_array ();
+ b.end_object ();
+ string path = write_json ("no-work-dir.fcol", b);
+
+ Gee.HashSet checked;
+ Gee.HashSet dirs;
+ Gee.ArrayList phrases;
+ load_fixture (path, out checked, out dirs, out phrases);
+ assert_eq_int ("relative paths dropped when work_dir is missing", 0, checked.size);
+ } catch (Error e) {
+ fail ("load_project_file without work_dir", e.message);
+ }
+}
+
+public static int main (string[] args) {
+ print ("========== ProjectManager 测试开始 ==========\n");
+ try {
+ setup_fixture ();
+ } catch (Error e) {
+ print ("FAIL: fixture setup - %s\n", e.message);
+ return 1;
+ }
+
+ test_canonicalize_checked_path ();
+ test_read_string_array ();
+ test_load_project_file ();
+ test_relative_path_without_work_dir ();
+
+ print ("\n========== 测试结果 ==========\n");
+ print ("PASS: %d, FAIL: %d\n", pass_count, fail_count);
+ return fail_count > 0 ? 1 : 0;
+}
diff --git a/tools/build-windows-package.sh b/tools/build-windows-package.sh
index f336a07..ccfb710 100755
--- a/tools/build-windows-package.sh
+++ b/tools/build-windows-package.sh
@@ -147,17 +147,64 @@ BAT
# 打包前用 PE 导入表做依赖闭包校验, 任何一个非系统 DLL 缺失都直接失败。
python3 tools/verify_windows_package.py "${STAGING_DIR}"
-# i18n 运行时冒烟: --help 走完整 gettext 链 (bindtextdomain -> zh_CN.mo 加载)。
-# 曾出过: .mo 在包里、清单校验也过, 但 gettext 运行时根本没加载译文, 语言
-# 设置/跟随系统在用户侧永远只有英文 (Windows 下 gettext 在 locale 为 "C" 时
-# 忽略 LANGUAGE)。这里真实运行主程序验证译文可达, 而不是只看清单。
-echo "Running i18n runtime smoke test (LANG=zh_CN.UTF-8 --help -> Chinese) ..."
-HELP_OUT="$(cd "${STAGING_DIR}/bin" && LANG=zh_CN.UTF-8 ./filecollector.exe --help 2>/dev/null || true)"
-if ! printf '%s\n' "${HELP_OUT}" | grep -q '命令行模式'; then
- echo "i18n smoke test failed: --help output is not Chinese with LANG=zh_CN.UTF-8" >&2
- printf '%s\n' "${HELP_OUT}" | head -5 >&2
- exit 1
+# ─── i18n 运行时冒烟 ──────────────────────────────────────────────────
+# 验证"应用自己"能把偏好设置 / 跟随系统落地, 而不只是 .mo 文件存在。
+# 旧写法用外部 LANG=zh_CN.UTF-8 跑 --help, 恰好绕过了 CLI 分支的语言设置,
+# 测不到设置不生效的回归。这里用干净环境 + 临时 XDG_CONFIG_HOME 里的
+# settings.json 驱动, 由应用自己完成 locale 解析。
+SMOKE_TMP_DIR="$(mktemp -d)"
+trap 'rm -rf "${SMOKE_TMP_DIR}"' EXIT
+mkdir -p "${SMOKE_TMP_DIR}/filecollector"
+# Windows 原生 exe 不认 MSYS2 的 POSIX 路径; -m 产出 C:/... 形式, GLib 可解析
+# 且不会被 MSYS2 当成 POSIX 绝对路径改写。
+if command -v cygpath >/dev/null 2>&1; then
+ SMOKE_CONFIG_HOME="$(cygpath -m "${SMOKE_TMP_DIR}")"
+else
+ SMOKE_CONFIG_HOME="$(cd "${SMOKE_TMP_DIR}" && pwd -W)"
fi
+SMOKE_SETTINGS="${SMOKE_TMP_DIR}/filecollector/settings.json"
+SMOKE_ERR="${SMOKE_TMP_DIR}/help.stderr"
+
+# $1 用例名, $2 期望语言 (zh|en), $3 额外的 env 赋值 (可为空)
+run_i18n_smoke () {
+ local label="$1" expected="$2" extra="$3" out
+ # env -u 清掉所有外部语言变量; timeout 防止 GUI 子系统 exe 挂起拖死 CI。
+ if ! out="$(cd "${STAGING_DIR}/bin" && env -u LANG -u LC_ALL -u LC_MESSAGES \
+ -u LANGUAGE -u FILECOLLECTOR_LANG_MANAGED \
+ XDG_CONFIG_HOME="${SMOKE_CONFIG_HOME}" ${extra} \
+ timeout 30 ./filecollector.exe --help 2>"${SMOKE_ERR}")"; then
+ echo "i18n smoke [${label}]: filecollector.exe failed or timed out" >&2
+ sed -n '1,20p' "${SMOKE_ERR}" >&2
+ return 1
+ fi
+ # 用 bash 字符串匹配, 不经过管道: 避免 pipefail 下 grep -q 的 SIGPIPE(141) 误报。
+ if [[ "${expected}" == "zh" && "${out}" != *"命令行模式"* ]] ||
+ [[ "${expected}" == "en" && ( "${out}" != *"CLI Mode"* || "${out}" == *"命令行模式"* ) ]]; then
+ echo "i18n smoke [${label}]: --help language mismatch (expected ${expected})" >&2
+ printf '%s\n' "${out}" >&2
+ sed -n '1,10p' "${SMOKE_ERR}" >&2
+ return 1
+ fi
+ echo "i18n smoke [${label}]: OK"
+}
+
+echo '{"language": "zh"}' > "${SMOKE_SETTINGS}"
+run_i18n_smoke "preferences=zh, clean env" zh ""
+
+echo '{"language": "en"}' > "${SMOKE_SETTINGS}"
+run_i18n_smoke "preferences=en, clean env" en ""
+
+# LC_ALL 优先级高于 LANG: 应用显式选中文时必须能压过外部 LC_ALL=C。
+echo '{"language": "zh"}' > "${SMOKE_SETTINGS}"
+run_i18n_smoke "preferences=zh, LC_ALL=C" zh "LC_ALL=C"
+
+# 跟随系统分支不得覆盖用户在 shell 里明确设置的真实语言。
+echo '{}' > "${SMOKE_SETTINGS}"
+run_i18n_smoke "follow-system, external LANG respected" zh "LANG=zh_CN.UTF-8"
+
+# 干净环境走 g_win32_getlocale; CI runner 是 en-US, 应回退到英文原文。
+echo '{}' > "${SMOKE_SETTINGS}"
+run_i18n_smoke "follow-system, clean env" en ""
PACKAGE_PATH="${DIST_DIR}/filecollector-windows-${VERSION}-x64.zip"
(