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.
- git4subeditor/__init__.py +41 -0
- git4subeditor/__main__.py +6 -0
- git4subeditor/cli.py +683 -0
- git4subeditor/gitformat.py +427 -0
- git4subeditor/l10n.py +107 -0
- git4subeditor/locales/cn.xml +135 -0
- git4subeditor/locales/en.xml +137 -0
- git4subeditor/locales/ru.xml +137 -0
- git4subeditor/repl.py +256 -0
- git4subeditor/subfile.py +189 -0
- git4subeditor/utils.py +122 -0
- git4subeditor/workspace.py +843 -0
- git4subeditor-0.1.20.dist-info/METADATA +227 -0
- git4subeditor-0.1.20.dist-info/RECORD +17 -0
- git4subeditor-0.1.20.dist-info/WHEEL +4 -0
- git4subeditor-0.1.20.dist-info/entry_points.txt +3 -0
- git4subeditor-0.1.20.dist-info/licenses/LICENSE +21 -0
|
@@ -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")
|