git4subeditor 0.1.20__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,843 @@
1
+ """Workspace configuration, initialization, and registered submarine display."""
2
+
3
+ import hashlib
4
+ import xml.etree.ElementTree as ET
5
+ from collections.abc import Callable
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ from git4subeditor import __version__
10
+ from git4subeditor.l10n import DEFAULT_LANGUAGE, SUPPORTED_LANGUAGES, t
11
+ from git4subeditor.subfile import SubFileError
12
+ from git4subeditor.utils import format_message
13
+
14
+
15
+ @dataclass(frozen=True, slots=True)
16
+ class SubmarineEntry:
17
+ """A submarine registered in the project configuration.
18
+
19
+ Attributes:
20
+ name: Display name. Defaults to the ``.sub`` filename when the
21
+ configuration omits it. Names may repeat and are not identifiers.
22
+ path: Submarine path relative to the workspace directory. This is the
23
+ registry identity.
24
+ preview: Preview image path relative to the workspace directory, or
25
+ None when no preview is configured.
26
+ hash: SHA-256 hash of the submarine file recorded at registration, or
27
+ None when no hash is configured.
28
+ """
29
+
30
+ name: str
31
+ path: str
32
+ preview: str | None = None
33
+ hash: str | None = None
34
+
35
+
36
+ @dataclass(frozen=True, slots=True)
37
+ class WorkspaceInfo:
38
+ """Project information used by the CLI and interactive mode.
39
+
40
+ Attributes:
41
+ project_name: Project name from ``git4subeditor.xml``.
42
+ git_repo: Git repository path relative to the workspace.
43
+ mod_root: MOD root path relative to the workspace.
44
+ submarines: Registered submarines in configuration order.
45
+ """
46
+
47
+ project_name: str
48
+ git_repo: str
49
+ mod_root: str
50
+ submarines: tuple[SubmarineEntry, ...]
51
+
52
+
53
+ type _TreeEntry = tuple[list[str], SubmarineEntry, str | None]
54
+ type SubmarineConfirm = Callable[
55
+ [tuple[SubmarineEntry, ...], tuple[SubmarineEntry, ...]], bool
56
+ ]
57
+ type RemovalConfirm = Callable[[tuple[SubmarineEntry, ...]], bool]
58
+ type PathConfirm = Callable[[str, str], bool]
59
+
60
+
61
+ def read_config_root() -> ET.Element | None:
62
+ """Read the current directory project configuration root.
63
+
64
+ Returns:
65
+ The ``git4subeditor`` element, or None when the configuration does not
66
+ exist.
67
+
68
+ Raises:
69
+ SubFileError: If the configuration cannot be read or parsed.
70
+ """
71
+ config_path: Path = Path.cwd() / "git4subeditor.xml"
72
+ try:
73
+ return ET.parse(config_path).getroot()
74
+ except FileNotFoundError:
75
+ return None
76
+ except (OSError, ET.ParseError) as exc:
77
+ raise SubFileError(
78
+ t("err.invalid_config", path=config_path, error=exc)
79
+ ) from exc
80
+
81
+
82
+ def load_workspace_info() -> WorkspaceInfo | None:
83
+ """Load workspace details from the current directory project config.
84
+
85
+ Returns:
86
+ Workspace details, or None when ``git4subeditor.xml`` does not exist.
87
+
88
+ Raises:
89
+ SubFileError: If the configuration cannot be read or parsed.
90
+ """
91
+ root: ET.Element | None = read_config_root()
92
+ if root is None:
93
+ return None
94
+
95
+ project_name: str = (root.findtext("projectName") or "").strip()
96
+ git_repo: str = (root.findtext("gitRepo") or "").strip() or "."
97
+ mod_root: str = (root.findtext("modRoot") or "").strip() or "."
98
+ submarines: list[SubmarineEntry] = []
99
+ for entry in root.findall("./submarines/submarine"):
100
+ path_text: str = (entry.get("path") or "").strip()
101
+ if not path_text:
102
+ continue
103
+ name_text: str = (entry.get("name") or "").strip() or Path(path_text).stem
104
+ hash_text: str = (entry.get("hash") or "").strip()
105
+ submarines.append(
106
+ SubmarineEntry(
107
+ name=name_text,
108
+ path=path_text,
109
+ hash=hash_text or None,
110
+ )
111
+ )
112
+
113
+ return WorkspaceInfo(
114
+ project_name=project_name or Path.cwd().name,
115
+ git_repo=git_repo,
116
+ mod_root=mod_root,
117
+ submarines=tuple(submarines),
118
+ )
119
+
120
+
121
+ def _split_path_text(path_text: str) -> list[str]:
122
+ """Split a configured path into components."""
123
+ parts: tuple[str, ...] = Path(path_text).parts
124
+ if not parts:
125
+ return ["."]
126
+ if path_text.startswith(("./", ".\\")):
127
+ return [".", *parts]
128
+ return list(parts)
129
+
130
+
131
+ def _relative_display(path: Path) -> str:
132
+ """Return a path relative to the current directory in POSIX form."""
133
+ try:
134
+ relative: Path = path.resolve().relative_to(
135
+ Path.cwd().resolve(),
136
+ walk_up=True,
137
+ )
138
+ except ValueError:
139
+ return str(path)
140
+ return relative.as_posix()
141
+
142
+
143
+ def _file_hash(path: Path) -> str:
144
+ """Return the SHA-256 hex digest of a file."""
145
+ with path.open("rb") as handle:
146
+ return hashlib.file_digest(handle, "sha256").hexdigest()
147
+
148
+
149
+ def refresh_submarine_hashes(submarines: tuple[SubmarineEntry, ...]) -> bool:
150
+ """Refresh stored hashes for successfully exported submarine entries.
151
+
152
+ Args:
153
+ submarines: Exported entries containing their refreshed hashes.
154
+
155
+ Returns:
156
+ Whether ``git4subeditor.xml`` was rewritten.
157
+
158
+ Raises:
159
+ SubFileError: If the project configuration cannot be read or parsed.
160
+ """
161
+ root: ET.Element | None = read_config_root()
162
+ if root is None:
163
+ return False
164
+ hashes: dict[str, str] = {
165
+ entry.path: entry.hash for entry in submarines if entry.hash is not None
166
+ }
167
+ changed: bool = False
168
+ for child in root.findall("./submarines/submarine"):
169
+ path_text: str = (child.get("path") or "").strip()
170
+ new_hash: str | None = hashes.get(path_text)
171
+ if new_hash is not None and child.get("hash") != new_hash:
172
+ child.set("hash", new_hash)
173
+ changed = True
174
+ if changed:
175
+ _write_project_config(Path.cwd() / "git4subeditor.xml", root)
176
+ return changed
177
+
178
+
179
+ def _status_suffix(status: str | None) -> str:
180
+ """Return a colored status suffix for a listed submarine."""
181
+ if status is None:
182
+ return ""
183
+ color: str = "red" if status == "U" else "yellow"
184
+ return f" {format_message(f'({status})', color=color)}"
185
+
186
+
187
+ def print_submarine_paths(info: WorkspaceInfo, tree: bool = True) -> None:
188
+ """Print all submarine paths under the repository.
189
+
190
+ Paths are shown relative to the workspace directory. Unregistered
191
+ submarine files are marked with a red ``(U)`` suffix.
192
+ Registered files whose hash changed are marked with a yellow ``(M)``.
193
+
194
+ Args:
195
+ info: Workspace information containing registered submarines.
196
+ tree: Whether to print a directory tree. When False, print files in
197
+ path order.
198
+ """
199
+ root_label: str = info.git_repo or "."
200
+ workspace_root: Path = Path.cwd()
201
+ root_path: Path = workspace_root / root_label
202
+ root_path_resolved: Path = root_path.resolve()
203
+ registered = {entry.path: entry for entry in info.submarines}
204
+ scanned_entries, unregistered, modified, _ = _detect_submarines(
205
+ read_config_root(),
206
+ root_path_resolved,
207
+ workspace_root,
208
+ )
209
+ unregistered_paths: set[str] = {entry.path for entry in unregistered}
210
+ modified_paths: set[str] = {entry.path for entry in modified}
211
+ listed: list[tuple[SubmarineEntry, str | None]] = []
212
+ for scanned in scanned_entries:
213
+ entry: SubmarineEntry = registered.get(scanned.path) or scanned
214
+ status: str | None = None
215
+ if scanned.path in unregistered_paths:
216
+ status = "U"
217
+ elif scanned.path in modified_paths:
218
+ status = "M"
219
+ listed.append((entry, status))
220
+
221
+ if not tree:
222
+ for submarine, status in listed:
223
+ path_text: str = _relative_display(workspace_root / submarine.path)
224
+ print(f"{submarine.name}: {path_text}{_status_suffix(status)}")
225
+ return
226
+
227
+ entries: list[_TreeEntry] = []
228
+ for submarine, status in listed:
229
+ try:
230
+ repository_relative: Path = (
231
+ (workspace_root / submarine.path)
232
+ .resolve()
233
+ .relative_to(root_path_resolved)
234
+ )
235
+ except ValueError:
236
+ continue
237
+ parts: list[str] = _split_path_text(repository_relative.as_posix())
238
+ if not parts:
239
+ continue
240
+ entries.append((parts[:-1], submarine, status))
241
+
242
+ if not entries:
243
+ return
244
+
245
+ def print_children(
246
+ children: list[_TreeEntry],
247
+ prefix: str,
248
+ parent: Path,
249
+ ) -> None:
250
+ """Print tree entries below one directory level."""
251
+ files: list[tuple[SubmarineEntry, str | None]] = [
252
+ (submarine, status)
253
+ for directories, submarine, status in children
254
+ if not directories
255
+ ]
256
+ directory_names: list[str] = list(
257
+ dict.fromkeys(
258
+ directories[0] for directories, _, _ in children if directories
259
+ )
260
+ )
261
+
262
+ total: int = len(files) + len(directory_names)
263
+ index: int = 0
264
+ for submarine, status in files:
265
+ index += 1
266
+ connector: str = "└── " if index == total else "├── "
267
+ filename: str = Path(submarine.path).name
268
+ print(
269
+ f"{prefix}{connector}{submarine.name}: {filename}"
270
+ f"{_status_suffix(status)}"
271
+ )
272
+
273
+ for directory_name in directory_names:
274
+ index += 1
275
+ connector = "└── " if index == total else "├── "
276
+ directory_path: Path = parent / directory_name
277
+ directory_text: str = format_message(
278
+ _relative_display(directory_path),
279
+ color="light_blue",
280
+ )
281
+ print(f"{prefix}{connector}{directory_text}")
282
+ child_prefix: str = prefix + (" " if index == total else "│ ")
283
+ nested: list[_TreeEntry] = [
284
+ (directories[1:], submarine, status)
285
+ for directories, submarine, status in children
286
+ if directories and directories[0] == directory_name
287
+ ]
288
+ print_children(nested, child_prefix, directory_path)
289
+
290
+ print(format_message(_relative_display(root_path), color="light_blue"))
291
+ print_children(entries, "", root_path)
292
+
293
+
294
+ IGNORE_PATTERNS: tuple[str, ...] = ("*.sub", "*.bak", "*.png", "history")
295
+
296
+
297
+ class InitializeError(Exception):
298
+ """Raised when a workspace cannot be recognized as a Barotrauma mod."""
299
+
300
+
301
+ @dataclass(frozen=True, slots=True)
302
+ class InitResult:
303
+ """Result of initializing a submarine workspace.
304
+
305
+ Attributes:
306
+ gitignore_path: Path to the .gitignore file.
307
+ config_path: Path to the project configuration file.
308
+ added: Ignore patterns added to .gitignore by this call.
309
+ gitignore_created: Whether the .gitignore file was created.
310
+ config_created: Whether the project configuration file was created.
311
+ config_updated: Whether the project configuration file was rewritten.
312
+ git_repository_path: Repository path relative to the workspace, or an
313
+ empty string when no repository was found.
314
+ current_git_repository: Whether the workspace itself is a repository
315
+ root.
316
+ upper_git_repository_path: Path to an upper-level repository relative
317
+ to the workspace, or None when no upper-level repository exists.
318
+ submarines: Submarine files found below the Git repository root, with
319
+ paths relative to the workspace directory.
320
+ submarines_updated: Whether the submarine registry was updated.
321
+ """
322
+
323
+ gitignore_path: Path
324
+ config_path: Path
325
+ added: tuple[str, ...]
326
+ gitignore_created: bool
327
+ config_created: bool
328
+ config_updated: bool
329
+ git_repository_path: str
330
+ current_git_repository: bool
331
+ upper_git_repository_path: str | None
332
+ submarines: tuple[SubmarineEntry, ...]
333
+ submarines_updated: bool
334
+
335
+
336
+ def _read_config_paths(base: Path) -> tuple[str, str] | None:
337
+ """Return configured gitRepo and modRoot values from an existing config."""
338
+ path: Path = base / "git4subeditor.xml"
339
+ try:
340
+ parser = ET.XMLParser(target=ET.TreeBuilder(insert_comments=True))
341
+ root = ET.parse(path, parser=parser).getroot()
342
+ except FileNotFoundError:
343
+ return None
344
+ except ET.ParseError as exc:
345
+ raise InitializeError(t("err.invalid_config", path=path, error=exc)) from exc
346
+ return (
347
+ (root.findtext("gitRepo") or "").strip(),
348
+ (root.findtext("modRoot") or "").strip(),
349
+ )
350
+
351
+
352
+ def _choose_path(confirm: PathConfirm | None, old: str, new: str) -> str:
353
+ """Return the new path when confirmed, otherwise the old path."""
354
+ if confirm is None or old == new:
355
+ return new
356
+ return new if confirm(old, new) else old
357
+
358
+
359
+ def initialize_project(
360
+ directory: Path | None = None,
361
+ language: str | None = None,
362
+ confirm_submarines: SubmarineConfirm | None = None,
363
+ confirm_removals: RemovalConfirm | None = None,
364
+ confirm_mod_root: PathConfirm | None = None,
365
+ confirm_git_repo: PathConfirm | None = None,
366
+ ) -> InitResult:
367
+ """Initialize or update a workspace with .gitignore and project configuration.
368
+
369
+ A new .gitignore contains the required ignore patterns directly. When an
370
+ existing file is missing patterns, they are appended in a marked block.
371
+ Existing content is never rewritten.
372
+
373
+ Existing user preferences are preserved. The ``gitRepo``, ``modRoot``,
374
+ and ``version`` fields are updated. The project name is taken from the
375
+ nearest ancestor directory containing ``filelist.xml``. After the Git
376
+ repository is located, ``.sub`` files below its root are registered in
377
+ the ``submarines`` section using paths relative to the workspace. Existing
378
+ entries are matched by path, which acts as the registry identity. Their
379
+ ``name``, ``preview``, and other attributes are preserved, while new
380
+ entries get a derived ``.png`` preview and omit the optional ``name``
381
+ attribute.
382
+
383
+ Args:
384
+ directory: Workspace directory. Defaults to the current working
385
+ directory.
386
+ language: Language code written to the project configuration. When
387
+ omitted, an existing valid language is preserved.
388
+ confirm_submarines: Optional callback that receives the registered and
389
+ pending submarine entries. Returning False leaves the registry
390
+ unchanged. When omitted, pending changes are applied directly.
391
+ confirm_removals: Optional callback that receives registered entries
392
+ whose paths no longer exist. Returning False keeps those entries.
393
+ confirm_mod_root: Optional callback that receives the configured and
394
+ detected MOD root paths. Returning False keeps the configured path.
395
+ confirm_git_repo: Optional callback that receives the configured and
396
+ detected Git repository paths. Returning False keeps the configured
397
+ path.
398
+
399
+ Returns:
400
+ Details about the created or updated files.
401
+
402
+ Raises:
403
+ InitializeError: If no ``filelist.xml`` exists in the directory or any
404
+ of its ancestors.
405
+ OSError: If a file cannot be read or written.
406
+
407
+ Examples:
408
+ >>> result = initialize_project(Path("MySubmarine"))
409
+ >>> result.gitignore_created
410
+ True
411
+ >>> result.added
412
+ ('*.sub', '*.bak', '*.png', 'history')
413
+ """
414
+ base: Path = directory if directory is not None else Path.cwd()
415
+ mod_root: Path = _find_mod_root(base)
416
+ repository_path, current_repository, upper_repository = _find_git_repository(
417
+ base, mod_root
418
+ )
419
+ repository_root: Path | None = (
420
+ (base / repository_path).resolve() if repository_path else None
421
+ )
422
+ project_name: str = mod_root.name or str(mod_root)
423
+ mod_root_path: str = _relative_path(base.resolve(), mod_root)
424
+ existing_paths: tuple[str, str] | None = _read_config_paths(base)
425
+ if existing_paths is not None:
426
+ old_repository_path, old_mod_root_path = existing_paths
427
+ mod_root_path = _choose_path(
428
+ confirm_mod_root,
429
+ old_mod_root_path,
430
+ mod_root_path,
431
+ )
432
+ repository_path = _choose_path(
433
+ confirm_git_repo,
434
+ old_repository_path,
435
+ repository_path,
436
+ )
437
+ gitignore_path, added, gitignore_created = _update_gitignore(base)
438
+ (
439
+ config_path,
440
+ config_created,
441
+ config_updated,
442
+ submarines_updated,
443
+ submarines,
444
+ ) = _create_project_config(
445
+ base,
446
+ project_name,
447
+ repository_path,
448
+ mod_root_path,
449
+ language,
450
+ repository_root,
451
+ confirm_submarines,
452
+ confirm_removals,
453
+ )
454
+ return InitResult(
455
+ gitignore_path=gitignore_path,
456
+ config_path=config_path,
457
+ added=added,
458
+ gitignore_created=gitignore_created,
459
+ config_created=config_created,
460
+ config_updated=config_updated,
461
+ git_repository_path=repository_path,
462
+ current_git_repository=current_repository,
463
+ upper_git_repository_path=upper_repository,
464
+ submarines=submarines,
465
+ submarines_updated=submarines_updated,
466
+ )
467
+
468
+
469
+ def _update_gitignore(base: Path) -> tuple[Path, tuple[str, ...], bool]:
470
+ """Create or update .gitignore and return its path, additions, and state."""
471
+ path: Path = base / ".gitignore"
472
+
473
+ try:
474
+ with path.open("r", encoding="utf-8", newline="") as handle:
475
+ text: str = handle.read()
476
+ except FileNotFoundError:
477
+ content: str = "".join(f"{pattern}\n" for pattern in IGNORE_PATTERNS)
478
+ path.write_text(content, encoding="utf-8", newline="\n")
479
+ return path, IGNORE_PATTERNS, True
480
+
481
+ existing: set[str] = {line.strip() for line in text.splitlines()}
482
+ added: tuple[str, ...] = tuple(
483
+ pattern for pattern in IGNORE_PATTERNS if pattern not in existing
484
+ )
485
+ if not added:
486
+ return path, (), False
487
+
488
+ newline: str = "\r\n" if "\r\n" in text else "\n"
489
+ chunks: list[str] = []
490
+ if text and not text.endswith(("\n", "\r")):
491
+ chunks.append(newline)
492
+ chunks.append(f"# ===== Git4SubEditor ====={newline}")
493
+ chunks.extend(f"{pattern}{newline}" for pattern in added)
494
+
495
+ with path.open("a", encoding="utf-8", newline="") as handle:
496
+ handle.write("".join(chunks))
497
+
498
+ return path, added, False
499
+
500
+
501
+ def _find_mod_root(base: Path) -> Path:
502
+ """Return the nearest directory containing filelist.xml."""
503
+ current: Path = base.resolve()
504
+ for directory in (current, *current.parents):
505
+ if (directory / "filelist.xml").is_file():
506
+ return directory
507
+ raise InitializeError(t("err.not_mod", path=current))
508
+
509
+
510
+ def _find_git_repository(base: Path, mod_root: Path) -> tuple[str, bool, str | None]:
511
+ """Find repositories from the workspace up to the mod root."""
512
+ current: Path = base.resolve()
513
+ repositories: list[Path] = []
514
+ for directory in (current, *current.parents):
515
+ if (directory / ".git").exists():
516
+ repositories.append(directory)
517
+ if directory == mod_root:
518
+ break
519
+
520
+ if not repositories:
521
+ return "", False, None
522
+
523
+ if repositories[0] == current:
524
+ upper: str | None = None
525
+ if len(repositories) > 1:
526
+ upper = _relative_path(current, repositories[1])
527
+ return ".", True, upper
528
+
529
+ return _relative_path(current, repositories[0]), False, None
530
+
531
+
532
+ def _relative_path(current: Path, target: Path) -> str:
533
+ """Return a portable relative path from the workspace to the target."""
534
+ return target.relative_to(current, walk_up=True).as_posix()
535
+
536
+
537
+ def _detect_submarines(
538
+ root: ET.Element | None,
539
+ repository_root: Path | None,
540
+ workspace_root: Path,
541
+ ) -> tuple[
542
+ tuple[SubmarineEntry, ...],
543
+ tuple[SubmarineEntry, ...],
544
+ tuple[SubmarineEntry, ...],
545
+ tuple[SubmarineEntry, ...],
546
+ ]:
547
+ """Return scanned submarine files and registry differences.
548
+
549
+ Files inside ``.git`` directories are ignored. Paths are relative to the
550
+ workspace directory and always use ``/`` separators. Every scanned entry
551
+ gets a derived ``.png`` preview path and its SHA-256 hash.
552
+
553
+ Args:
554
+ root: Existing project configuration root, or None when the
555
+ configuration does not exist yet.
556
+ repository_root: Resolved Git repository root, or None when no
557
+ repository was found.
558
+ workspace_root: Workspace directory used as the base for relative
559
+ paths.
560
+
561
+ Returns:
562
+ All scanned submarine entries, unregistered entries, registered
563
+ entries whose hash changed, and registered entries whose paths are no
564
+ longer present.
565
+ """
566
+ entries: list[SubmarineEntry] = []
567
+ if repository_root is not None:
568
+ for path in repository_root.rglob("*"):
569
+ if path.suffix.lower() != ".sub" or not path.is_file():
570
+ continue
571
+ repository_relative: Path = path.relative_to(repository_root)
572
+ if ".git" in repository_relative.parts[:-1]:
573
+ continue
574
+ relative: str = _relative_path(workspace_root.resolve(), path.resolve())
575
+ preview: str = Path(relative).with_suffix(".png").as_posix()
576
+ entries.append(
577
+ SubmarineEntry(
578
+ name=path.stem,
579
+ path=relative,
580
+ preview=preview,
581
+ hash=_file_hash(path),
582
+ )
583
+ )
584
+ entries.sort(key=lambda entry: entry.path.casefold())
585
+
586
+ registered: dict[str, SubmarineEntry] = {}
587
+ if root is not None:
588
+ for child in root.findall("./submarines/submarine"):
589
+ path_text: str = (child.get("path") or "").strip()
590
+ if not path_text:
591
+ continue
592
+ name_text: str = (child.get("name") or "").strip() or Path(path_text).stem
593
+ registered[path_text] = SubmarineEntry(
594
+ name=name_text,
595
+ path=path_text,
596
+ preview=(child.get("preview") or "").strip() or None,
597
+ hash=(child.get("hash") or "").strip() or None,
598
+ )
599
+
600
+ unregistered: list[SubmarineEntry] = []
601
+ modified: list[SubmarineEntry] = []
602
+ for entry in entries:
603
+ old: SubmarineEntry | None = registered.get(entry.path)
604
+ if old is None:
605
+ unregistered.append(entry)
606
+ elif old.hash != entry.hash:
607
+ modified.append(
608
+ SubmarineEntry(
609
+ name=old.name,
610
+ path=entry.path,
611
+ preview=entry.preview,
612
+ hash=entry.hash,
613
+ )
614
+ )
615
+
616
+ scanned_paths: set[str] = {entry.path for entry in entries}
617
+ missing: tuple[SubmarineEntry, ...] = tuple(
618
+ entry for path, entry in registered.items() if path not in scanned_paths
619
+ )
620
+ return tuple(entries), tuple(unregistered), tuple(modified), missing
621
+
622
+
623
+ def _confirm_submarine_update(
624
+ confirm: SubmarineConfirm | None,
625
+ root: ET.Element | None,
626
+ pending: tuple[SubmarineEntry, ...],
627
+ ) -> bool:
628
+ """Return whether pending registry changes should be applied."""
629
+ if confirm is None or not pending:
630
+ return True
631
+
632
+ registered: list[SubmarineEntry] = []
633
+ if root is not None:
634
+ for child in root.findall("./submarines/submarine"):
635
+ path: str = (child.get("path") or "").strip()
636
+ if not path:
637
+ continue
638
+ registered.append(
639
+ SubmarineEntry(
640
+ name=(child.get("name") or "").strip() or Path(path).stem,
641
+ path=path,
642
+ preview=(child.get("preview") or "").strip() or None,
643
+ hash=(child.get("hash") or "").strip() or None,
644
+ )
645
+ )
646
+ return confirm(tuple(registered), pending)
647
+
648
+
649
+ def _create_project_config(
650
+ base: Path,
651
+ project_name: str,
652
+ repository_path: str,
653
+ mod_root_path: str,
654
+ language: str | None,
655
+ repository_root: Path | None,
656
+ confirm_submarines: SubmarineConfirm | None,
657
+ confirm_removals: RemovalConfirm | None,
658
+ ) -> tuple[
659
+ Path,
660
+ bool,
661
+ bool,
662
+ bool,
663
+ tuple[SubmarineEntry, ...],
664
+ ]:
665
+ """Create or update the project configuration file."""
666
+ path: Path = base / "git4subeditor.xml"
667
+ try:
668
+ parser = ET.XMLParser(target=ET.TreeBuilder(insert_comments=True))
669
+ root: ET.Element = ET.parse(path, parser=parser).getroot()
670
+ except FileNotFoundError:
671
+ root = ET.Element("git4subeditor")
672
+ _set_config_value(root, "projectName", project_name)
673
+ selected_language: str = (
674
+ language if language in SUPPORTED_LANGUAGES else DEFAULT_LANGUAGE
675
+ )
676
+ _set_config_value(root, "language", selected_language)
677
+ _set_config_value(root, "gitRepo", repository_path)
678
+ _set_config_value(root, "modRoot", mod_root_path)
679
+ _set_config_value(root, "version", __version__)
680
+ submarines, unregistered, modified, _ = _detect_submarines(
681
+ None, repository_root, base
682
+ )
683
+ pending: tuple[SubmarineEntry, ...] = (*unregistered, *modified)
684
+ update_submarines: bool = _confirm_submarine_update(
685
+ confirm_submarines, root, pending
686
+ )
687
+ if update_submarines:
688
+ _sync_submarines(
689
+ root,
690
+ submarines,
691
+ update=True,
692
+ remove_missing=True,
693
+ )
694
+ else:
695
+ ET.SubElement(root, "submarines")
696
+ _write_project_config(path, root)
697
+ return path, True, False, update_submarines, submarines
698
+ except ET.ParseError as exc:
699
+ raise InitializeError(t("err.invalid_config", path=path, error=exc)) from exc
700
+
701
+ submarines, unregistered, modified, removed = _detect_submarines(
702
+ root, repository_root, base
703
+ )
704
+ pending: tuple[SubmarineEntry, ...] = (*unregistered, *modified)
705
+ update_submarines = _confirm_submarine_update(
706
+ confirm_submarines,
707
+ root,
708
+ pending,
709
+ )
710
+ remove_submarines: bool = (
711
+ not removed or confirm_removals is None or confirm_removals(removed)
712
+ )
713
+ changed: bool = False
714
+ legacy: ET.Element | None = root.find("gitRepository")
715
+ if legacy is not None:
716
+ root.remove(legacy)
717
+ changed = True
718
+ if language is not None:
719
+ selected_language = (
720
+ language if language in SUPPORTED_LANGUAGES else DEFAULT_LANGUAGE
721
+ )
722
+ changed |= _set_config_value(root, "language", selected_language)
723
+ else:
724
+ language_element: ET.Element | None = root.find("language")
725
+ current_language: str = (
726
+ (language_element.text or "").strip().lower()
727
+ if language_element is not None
728
+ else ""
729
+ )
730
+ if current_language not in SUPPORTED_LANGUAGES:
731
+ changed |= _set_config_value(root, "language", DEFAULT_LANGUAGE)
732
+ changed |= _set_config_value(root, "gitRepo", repository_path)
733
+ changed |= _set_config_value(root, "modRoot", mod_root_path)
734
+ changed |= _set_config_value(root, "version", __version__)
735
+ submarines_updated: bool = False
736
+ if update_submarines or remove_submarines:
737
+ submarines_updated = _sync_submarines(
738
+ root,
739
+ submarines,
740
+ update=update_submarines,
741
+ remove_missing=remove_submarines,
742
+ )
743
+ changed |= submarines_updated
744
+ if changed:
745
+ _write_project_config(path, root)
746
+ return path, False, changed, submarines_updated, submarines
747
+
748
+
749
+ def _sync_submarines(
750
+ root: ET.Element,
751
+ submarines: tuple[SubmarineEntry, ...],
752
+ *,
753
+ update: bool,
754
+ remove_missing: bool,
755
+ ) -> bool:
756
+ """Sync the submarine registry by path.
757
+
758
+ Existing entries are matched by path. Their ``name``, ``preview``, and any
759
+ other attributes are preserved. When ``update`` is true, hashes are
760
+ refreshed and new entries are added without the optional ``name``.
761
+ When ``remove_missing`` is true, paths absent from the scan are removed.
762
+
763
+ Args:
764
+ root: Root element of the project configuration.
765
+ submarines: Entries found by the current scan.
766
+ update: Whether to add or refresh scanned entries.
767
+ remove_missing: Whether to remove registered paths not in the scan.
768
+
769
+ Returns:
770
+ Whether the configuration changed.
771
+ """
772
+ element: ET.Element | None = root.find("submarines")
773
+ changed: bool = False
774
+ if element is None:
775
+ if not update:
776
+ return False
777
+ element = ET.SubElement(root, "submarines")
778
+ changed = True
779
+
780
+ scanned_paths: set[str] = {entry.path for entry in submarines}
781
+ existing: dict[str, ET.Element] = {}
782
+ for child in list(element.findall("submarine")):
783
+ path_text: str = (child.get("path") or "").strip()
784
+ if (
785
+ not path_text
786
+ or path_text in existing
787
+ or (remove_missing and path_text not in scanned_paths)
788
+ ):
789
+ element.remove(child)
790
+ changed = True
791
+ continue
792
+ existing[path_text] = child
793
+
794
+ if not update:
795
+ return changed
796
+
797
+ for entry in submarines:
798
+ child: ET.Element | None = existing.get(entry.path)
799
+ if child is not None:
800
+ new_hash: str = entry.hash or ""
801
+ if (child.get("hash") or "") != new_hash:
802
+ child.set("hash", new_hash)
803
+ changed = True
804
+ continue
805
+ child = ET.SubElement(
806
+ element,
807
+ "submarine",
808
+ {
809
+ "path": entry.path,
810
+ "preview": entry.preview or "",
811
+ "hash": entry.hash or "",
812
+ },
813
+ )
814
+ existing[entry.path] = child
815
+ changed = True
816
+
817
+ return changed
818
+
819
+
820
+ def _set_config_value(root: ET.Element, tag: str, value: str) -> bool:
821
+ """Set a configuration value and return whether it changed."""
822
+ element: ET.Element | None = root.find(tag)
823
+ changed: bool = False
824
+ if element is None:
825
+ element = ET.SubElement(root, tag)
826
+ changed = True
827
+ if (element.text or "") != value:
828
+ element.text = value
829
+ changed = True
830
+ return changed
831
+
832
+
833
+ def _write_project_config(path: Path, root: ET.Element) -> None:
834
+ """Write the project configuration with stable formatting."""
835
+ ET.indent(root, space=" ")
836
+ with path.open("wb") as handle:
837
+ ET.ElementTree(root).write(
838
+ handle,
839
+ encoding="utf-8",
840
+ xml_declaration=True,
841
+ short_empty_elements=True,
842
+ )
843
+ handle.write(b"\n")