armarium 0.1.0__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.
- armarium/__init__.py +1 -0
- armarium/add.py +304 -0
- armarium/cli.py +232 -0
- armarium/clue.py +97 -0
- armarium/content.py +126 -0
- armarium/index.py +195 -0
- armarium/init.py +79 -0
- armarium/lib.py +518 -0
- armarium/logging.py +40 -0
- armarium/note.py +46 -0
- armarium/parse.py +599 -0
- armarium/player.py +45 -0
- armarium/schemas.py +178 -0
- armarium/session.py +52 -0
- armarium/starter/.gitignore +8 -0
- armarium/starter/.obsidian/app.json +3 -0
- armarium/starter/.obsidian/appearance.json +5 -0
- armarium/starter/.obsidian/snippets/armarium-prose.css +27 -0
- armarium/starter/.scratch/.gitkeep +0 -0
- armarium/starter/assets/.gitkeep +0 -0
- armarium/starter/campaigns/campaign_1/clues/.gitkeep +0 -0
- armarium/starter/campaigns/campaign_1/content/.gitkeep +0 -0
- armarium/starter/campaigns/campaign_1/notes/.gitkeep +0 -0
- armarium/starter/campaigns/campaign_1/reference/Campaign.md +4 -0
- armarium/starter/campaigns/campaign_1/reference/indexes/Clues.md +21 -0
- armarium/starter/campaigns/campaign_1/reference/players/.gitkeep +0 -0
- armarium/starter/campaigns/campaign_1/sessions/.gitkeep +0 -0
- armarium/starter/campaigns/campaign_1/sessions/transcripts/.gitkeep +0 -0
- armarium/starter/content/.gitkeep +0 -0
- armarium/starter/notes/.gitkeep +0 -0
- armarium/starter/reference/.gitkeep +0 -0
- armarium/starter/reference/schemas/clue.schema.json +142 -0
- armarium/starter/reference/schemas/content.schema.json +360 -0
- armarium/starter/reference/schemas/note.schema.json +28 -0
- armarium/starter/reference/schemas/player.schema.json +42 -0
- armarium/starter/reference/schemas/reference.schema.json +28 -0
- armarium/starter/reference/schemas/session.schema.json +143 -0
- armarium/starter/reference/schemas/status.schema.json +34 -0
- armarium/starter/reference/schemas/transcript.schema.json +41 -0
- armarium/starter/reference/schemas/type.schema.json +50 -0
- armarium/starter/reference/statuses/Abandoned.md +5 -0
- armarium/starter/reference/statuses/Dormant.md +5 -0
- armarium/starter/reference/statuses/Hinted.md +5 -0
- armarium/starter/reference/statuses/Pending.md +5 -0
- armarium/starter/reference/statuses/Revealed.md +5 -0
- armarium/starter/reference/statuses/Superseded.md +5 -0
- armarium/starter/reference/templates/Campaign.md +4 -0
- armarium/starter/reference/templates/Clue.md +10 -0
- armarium/starter/reference/templates/Clues.md +21 -0
- armarium/starter/reference/templates/Content.md +15 -0
- armarium/starter/reference/templates/Note.md +3 -0
- armarium/starter/reference/templates/Player.md +4 -0
- armarium/starter/reference/templates/Session.md +46 -0
- armarium/starter/reference/templates/Transcript.md +7 -0
- armarium/starter/reference/types/Clue.md +32 -0
- armarium/starter/reference/types/Content.md +66 -0
- armarium/starter/reference/types/Note.md +18 -0
- armarium/starter/reference/types/Player.md +16 -0
- armarium/starter/reference/types/Reference.md +15 -0
- armarium/starter/reference/types/Session.md +38 -0
- armarium/starter/reference/types/Status.md +26 -0
- armarium/starter/reference/types/Transcript.md +41 -0
- armarium/starter/reference/types/Type.md +29 -0
- armarium/starter/reference/views/clue-index.base +33 -0
- armarium/starter/reference/views/clue-sessions.base +23 -0
- armarium/starter/reference/views/content-clues.base +23 -0
- armarium/starter/reference/views/prepared-clues.base +22 -0
- armarium/starter/reference/views/prepared-locations.base +23 -0
- armarium/starter/reference/views/prepared-npcs.base +23 -0
- armarium/transcript.py +83 -0
- armarium/validate.py +1359 -0
- armarium-0.1.0.dist-info/METADATA +98 -0
- armarium-0.1.0.dist-info/RECORD +76 -0
- armarium-0.1.0.dist-info/WHEEL +4 -0
- armarium-0.1.0.dist-info/entry_points.txt +2 -0
- armarium-0.1.0.dist-info/licenses/LICENSE +21 -0
armarium/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Read-only tools for human-maintained Armarium vaults."""
|
armarium/add.py
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
"""Campaign creation and shared operations for adding records.
|
|
2
|
+
|
|
3
|
+
Command modules prepare type-specific contents. These helpers resolve the local
|
|
4
|
+
vault conventions and validate each new record; the CLI validates the whole vault.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
import shutil
|
|
9
|
+
import unicodedata
|
|
10
|
+
from collections.abc import Mapping
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import yaml
|
|
15
|
+
|
|
16
|
+
from armarium.lib import CAMPAIGN_NAME, find_files, find_vault, parse_directories
|
|
17
|
+
from armarium.logging import logger
|
|
18
|
+
from armarium.parse import Record
|
|
19
|
+
from armarium.validate import CAMPAIGN_DIRECTORIES, validate
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def select_vault(vault: Path | None) -> Path:
|
|
23
|
+
"""Resolve an explicit vault root or discover it from the working directory.
|
|
24
|
+
|
|
25
|
+
Reject explicit paths inside a vault rather than silently changing their scope.
|
|
26
|
+
"""
|
|
27
|
+
selected = vault.expanduser().resolve() if vault is not None else Path.cwd()
|
|
28
|
+
root = find_vault(selected)
|
|
29
|
+
if vault is not None and selected != root:
|
|
30
|
+
raise ValueError("--vault must name the vault root")
|
|
31
|
+
return root
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def infer_campaign(root: Path, campaign: int | None) -> int | None:
|
|
35
|
+
"""Prefer an explicit campaign; otherwise infer one only inside this vault."""
|
|
36
|
+
working_directory = Path.cwd().resolve()
|
|
37
|
+
if campaign is None and working_directory.is_relative_to(root):
|
|
38
|
+
parts = working_directory.relative_to(root).parts
|
|
39
|
+
if (
|
|
40
|
+
len(parts) >= 2
|
|
41
|
+
and parts[0] == "campaigns"
|
|
42
|
+
and CAMPAIGN_NAME.fullmatch(parts[1])
|
|
43
|
+
):
|
|
44
|
+
campaign = int(parts[1].removeprefix("campaign_"))
|
|
45
|
+
return campaign
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def require_campaign(root: Path, campaign: int | None) -> int:
|
|
49
|
+
"""Select a positive campaign number, raising when none can be inferred."""
|
|
50
|
+
campaign = infer_campaign(root, campaign)
|
|
51
|
+
if campaign is None:
|
|
52
|
+
raise ValueError("specify --campaign or run inside a campaign directory")
|
|
53
|
+
if campaign < 1:
|
|
54
|
+
raise ValueError("campaign number must be positive")
|
|
55
|
+
return campaign
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def check_name(name: str) -> None:
|
|
59
|
+
"""Reject names that are empty, non-plain, path-like or include .md."""
|
|
60
|
+
if (
|
|
61
|
+
not name
|
|
62
|
+
or name != " ".join(name.split())
|
|
63
|
+
or name.startswith(".")
|
|
64
|
+
or name.endswith(".")
|
|
65
|
+
or name.lower().endswith(".md")
|
|
66
|
+
or any(c in name for c in '/\\\x00[]#|:*?"<>')
|
|
67
|
+
or any(ord(c) < 32 for c in name)
|
|
68
|
+
):
|
|
69
|
+
raise ValueError(
|
|
70
|
+
"name must be a plain record name without .md, path separators, or reserved characters"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def record_directory(root: Path, kind: str, campaign: int | str | None) -> Path:
|
|
75
|
+
"""Read a Type's scoped directory and require a real, existing write path.
|
|
76
|
+
|
|
77
|
+
None selects shared scope. An integer selects campaign_N; a folder name
|
|
78
|
+
preserves a campaign already identified by an existing record.
|
|
79
|
+
"""
|
|
80
|
+
definition, failures = Record.parse(root / f"reference/types/{kind}.md", root)
|
|
81
|
+
if definition is None:
|
|
82
|
+
raise ValueError(f"cannot read {kind} Type: {failures[0].message}")
|
|
83
|
+
declarations = parse_directories(definition.frontmatter.get("directories"))
|
|
84
|
+
scope = "shared" if campaign is None else "campaign"
|
|
85
|
+
if scope not in declarations:
|
|
86
|
+
raise ValueError(f"{kind} Type declares no {scope} directory")
|
|
87
|
+
if isinstance(campaign, int):
|
|
88
|
+
campaign = f"campaign_{campaign}"
|
|
89
|
+
base = root if campaign is None else root / "campaigns" / campaign
|
|
90
|
+
directory = base / declarations[scope]
|
|
91
|
+
for path in (directory, *directory.parents):
|
|
92
|
+
if path == root:
|
|
93
|
+
break
|
|
94
|
+
if not path.is_dir() or path.is_symlink():
|
|
95
|
+
raise ValueError(f"{path} must be an existing real directory")
|
|
96
|
+
return directory
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def check_destination(destination: Path, *, normalize: bool = False) -> None:
|
|
100
|
+
"""Refuse case-equivalent entries, optionally normalizing Unicode names.
|
|
101
|
+
|
|
102
|
+
Plain user-supplied record names use NFC normalization. Numbered filenames
|
|
103
|
+
use their existing case-insensitive comparison. Exclusive writing also
|
|
104
|
+
protects against a destination created after this check.
|
|
105
|
+
"""
|
|
106
|
+
names = [destination.name, *(p.name for p in destination.parent.iterdir())]
|
|
107
|
+
if normalize:
|
|
108
|
+
names = [unicodedata.normalize("NFC", name) for name in names]
|
|
109
|
+
if names[0].casefold() in {name.casefold() for name in names[1:]}:
|
|
110
|
+
raise FileExistsError(f"record already exists: {destination}")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def read_template(root: Path, kind: str, *, check_reference: bool = False) -> Record:
|
|
114
|
+
"""Parse a local template of the expected type, rejecting symlinked inputs.
|
|
115
|
+
|
|
116
|
+
check_reference also rejects a symlink at the reference directory, for
|
|
117
|
+
callers that require that component to be a real directory.
|
|
118
|
+
"""
|
|
119
|
+
path = root / f"reference/templates/{kind}.md"
|
|
120
|
+
paths = (
|
|
121
|
+
(path, path.parent, path.parent.parent)
|
|
122
|
+
if check_reference
|
|
123
|
+
else (path, path.parent)
|
|
124
|
+
)
|
|
125
|
+
if any(p.is_symlink() for p in paths):
|
|
126
|
+
raise ValueError(f"{kind} template must be a regular file in the vault")
|
|
127
|
+
template, failures = Record.parse(path, root)
|
|
128
|
+
if template is None:
|
|
129
|
+
raise ValueError(f"cannot read {kind} template: {failures[0].message}")
|
|
130
|
+
if template.frontmatter.type != kind:
|
|
131
|
+
raise ValueError(f"{kind} template must declare the {kind} type")
|
|
132
|
+
return template
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def record_text(metadata: Mapping[str, Any], body: str) -> str:
|
|
136
|
+
"""Serialize frontmatter without reordering fields and append the given body."""
|
|
137
|
+
return (
|
|
138
|
+
"---\n"
|
|
139
|
+
+ yaml.safe_dump(dict(metadata), sort_keys=False, allow_unicode=True)
|
|
140
|
+
+ "---\n"
|
|
141
|
+
+ body
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def write_record(destination: Path, text: str, root: Path, kind: str) -> Path:
|
|
146
|
+
"""Exclusively create and validate a record, removing it on any failure.
|
|
147
|
+
|
|
148
|
+
Log the destination and validation diagnostics. A failed exclusive open
|
|
149
|
+
leaves an existing entry untouched. After a successful open, write errors,
|
|
150
|
+
failed validation and interruptions remove only the newly created record.
|
|
151
|
+
Whole-vault validation is the caller's responsibility.
|
|
152
|
+
"""
|
|
153
|
+
logger.info("Adding new %s record at %s", kind, destination)
|
|
154
|
+
stream = destination.open("x", encoding="utf-8")
|
|
155
|
+
try:
|
|
156
|
+
with stream:
|
|
157
|
+
stream.write(text)
|
|
158
|
+
result = validate(destination, root)
|
|
159
|
+
if result.failed:
|
|
160
|
+
for diagnostic in result.diagnostics:
|
|
161
|
+
diagnostic.report()
|
|
162
|
+
raise ValueError(
|
|
163
|
+
f"generated {kind} record failed validation; no record created"
|
|
164
|
+
)
|
|
165
|
+
except BaseException:
|
|
166
|
+
destination.unlink()
|
|
167
|
+
raise
|
|
168
|
+
return destination
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def record_number(
|
|
172
|
+
directory: Path,
|
|
173
|
+
campaign: int,
|
|
174
|
+
number: int | None,
|
|
175
|
+
*,
|
|
176
|
+
kind: str,
|
|
177
|
+
prefix: str,
|
|
178
|
+
digits: int,
|
|
179
|
+
) -> int:
|
|
180
|
+
"""Choose an unused bounded number, including records in archive directories.
|
|
181
|
+
|
|
182
|
+
When omitted, use the largest existing number plus one. Explicit numbers
|
|
183
|
+
must be unused and in the range representable by the filename's digits.
|
|
184
|
+
"""
|
|
185
|
+
pattern = re.compile(rf"{prefix}-{campaign}-([0-9]{{{digits}}})\.md", re.IGNORECASE)
|
|
186
|
+
existing = {
|
|
187
|
+
int(match[1])
|
|
188
|
+
for path in find_files(directory)
|
|
189
|
+
if (match := pattern.fullmatch(path.name))
|
|
190
|
+
}
|
|
191
|
+
if number is None:
|
|
192
|
+
number = max(existing, default=0) + 1
|
|
193
|
+
maximum = 10**digits - 1
|
|
194
|
+
if not 1 <= number <= maximum:
|
|
195
|
+
raise ValueError(f"{kind} number must be between 1 and {maximum}")
|
|
196
|
+
if number in existing:
|
|
197
|
+
raise ValueError(
|
|
198
|
+
f"{kind} number {number} already exists in campaign {campaign}"
|
|
199
|
+
)
|
|
200
|
+
return number
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _campaign_directories(root: Path) -> set[str]:
|
|
204
|
+
"""Collect required campaign directories and locally declared additions.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
root: Resolved vault directory.
|
|
208
|
+
|
|
209
|
+
Returns:
|
|
210
|
+
set[str]: Paths relative to each campaign directory.
|
|
211
|
+
|
|
212
|
+
Raises:
|
|
213
|
+
ValueError: A Type record cannot be parsed or its directories are invalid.
|
|
214
|
+
OSError: Type records cannot be read.
|
|
215
|
+
"""
|
|
216
|
+
directories = set(CAMPAIGN_DIRECTORIES)
|
|
217
|
+
for path in find_files(root / "reference/types"):
|
|
218
|
+
if path.suffix.lower() != ".md":
|
|
219
|
+
continue
|
|
220
|
+
record, diagnostics = Record.parse(path, root)
|
|
221
|
+
if record is None:
|
|
222
|
+
raise ValueError(f"{path}: {diagnostics[0].message}")
|
|
223
|
+
declarations = parse_directories(record.frontmatter.get("directories"))
|
|
224
|
+
if "campaign" in declarations:
|
|
225
|
+
directories.add(declarations["campaign"])
|
|
226
|
+
return directories
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _render_campaign(text: str, number: int) -> str:
|
|
230
|
+
"""Adapt the campaign-1 examples in a local campaign template.
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
text: Template contents; campaign_1 denotes the new campaign.
|
|
234
|
+
number: Campaign number to put in directory names and clue ID examples.
|
|
235
|
+
|
|
236
|
+
Returns:
|
|
237
|
+
str: Text with campaign_1 and C-1-NNNN/C-1-XXXX tokens updated. Other
|
|
238
|
+
campaign references and surrounding formatting are preserved.
|
|
239
|
+
"""
|
|
240
|
+
text = re.sub(r"\bcampaign_1\b", f"campaign_{number}", text)
|
|
241
|
+
return re.sub(r"\bC-1-(?=\d{4}\b|XXXX\b)", f"C-{number}-", text)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def add_campaign(vault: Path | None = None, *, number: int | None = None) -> Path:
|
|
245
|
+
"""Create an empty campaign from the selected vault's local scaffolding.
|
|
246
|
+
|
|
247
|
+
Args:
|
|
248
|
+
vault: Vault root, or None to discover it from the working directory.
|
|
249
|
+
number: Positive campaign number; defaults to the largest existing
|
|
250
|
+
campaign number plus one, or 1 when there are no campaigns.
|
|
251
|
+
|
|
252
|
+
Returns:
|
|
253
|
+
Path: Resolved absolute path of the new campaign. Existing files and
|
|
254
|
+
shared templates are unchanged; callers can validate the whole vault.
|
|
255
|
+
|
|
256
|
+
Raises:
|
|
257
|
+
ValueError: The vault cannot be identified, the number is invalid or
|
|
258
|
+
already used, or the local scaffolding is invalid.
|
|
259
|
+
OSError: A required template is missing, the destination exists, or
|
|
260
|
+
creation fails. A partial campaign is removed on write failure.
|
|
261
|
+
KeyboardInterrupt: Interrupted creation is cleaned up before propagating.
|
|
262
|
+
"""
|
|
263
|
+
root = select_vault(vault)
|
|
264
|
+
for directory in ("campaigns", "reference/types", "reference/templates"):
|
|
265
|
+
path = root / directory
|
|
266
|
+
if not path.is_dir() or path.is_symlink():
|
|
267
|
+
raise ValueError(f"{path} must be a real directory")
|
|
268
|
+
existing = {
|
|
269
|
+
int(path.name.removeprefix("campaign_"))
|
|
270
|
+
for path in (root / "campaigns").iterdir()
|
|
271
|
+
if CAMPAIGN_NAME.fullmatch(path.name) and path.is_dir()
|
|
272
|
+
}
|
|
273
|
+
if number is None:
|
|
274
|
+
number = max(existing, default=0) + 1
|
|
275
|
+
if number < 1:
|
|
276
|
+
raise ValueError("campaign number must be positive")
|
|
277
|
+
if number in existing:
|
|
278
|
+
raise ValueError(f"campaign number {number} already exists")
|
|
279
|
+
destination = root / "campaigns" / f"campaign_{number}"
|
|
280
|
+
directories = _campaign_directories(root)
|
|
281
|
+
records: dict[str, str] = {}
|
|
282
|
+
for template, relative in (
|
|
283
|
+
("Campaign", "reference/Campaign.md"),
|
|
284
|
+
("Clues", "reference/indexes/Clues.md"),
|
|
285
|
+
):
|
|
286
|
+
path = root / f"reference/templates/{template}.md"
|
|
287
|
+
if path.is_symlink():
|
|
288
|
+
raise ValueError(f"{path} must be a regular template file")
|
|
289
|
+
records[relative] = _render_campaign(path.read_text(encoding="utf-8"), number)
|
|
290
|
+
logger.info("Adding new campaign at %s", destination)
|
|
291
|
+
destination.mkdir()
|
|
292
|
+
try:
|
|
293
|
+
for directory in sorted(directories):
|
|
294
|
+
(destination / directory).mkdir(parents=True, exist_ok=True)
|
|
295
|
+
for relative, text in records.items():
|
|
296
|
+
(destination / relative).write_text(text, encoding="utf-8")
|
|
297
|
+
for directory in sorted(directories):
|
|
298
|
+
path = destination / directory
|
|
299
|
+
if not any(path.iterdir()):
|
|
300
|
+
(path / ".gitkeep").write_text("", encoding="utf-8")
|
|
301
|
+
except BaseException:
|
|
302
|
+
shutil.rmtree(destination)
|
|
303
|
+
raise
|
|
304
|
+
return destination
|
armarium/cli.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""Command-line adapters for creating and validating vaults and records."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from armarium.add import add_campaign, read_template, select_vault
|
|
10
|
+
from armarium.clue import add_clue
|
|
11
|
+
from armarium.content import SUBTYPES, add_content
|
|
12
|
+
from armarium.init import init_vault
|
|
13
|
+
from armarium.lib import find_vault
|
|
14
|
+
from armarium.logging import configure_logging, logger
|
|
15
|
+
from armarium.note import add_note
|
|
16
|
+
from armarium.player import add_player
|
|
17
|
+
from armarium.session import add_session
|
|
18
|
+
from armarium.transcript import add_transcript
|
|
19
|
+
from armarium.validate import validate
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _clue_text_help(vault: Path | None) -> str:
|
|
23
|
+
"""Describe the selected vault's template text without making help fail.
|
|
24
|
+
|
|
25
|
+
Show usable text as a quoted string with escaped newlines. Missing vaults,
|
|
26
|
+
unreadable templates and invalid defaults are described explicitly. Escape
|
|
27
|
+
percent signs because argparse interpolates help strings.
|
|
28
|
+
"""
|
|
29
|
+
try:
|
|
30
|
+
root = select_vault(vault)
|
|
31
|
+
template = read_template(root, "Clue")
|
|
32
|
+
text = template.frontmatter.get("text")
|
|
33
|
+
default = (
|
|
34
|
+
json.dumps(text, ensure_ascii=False)
|
|
35
|
+
if isinstance(text, str) and text.strip()
|
|
36
|
+
else "none; --text required"
|
|
37
|
+
)
|
|
38
|
+
except OSError, ValueError:
|
|
39
|
+
default = "unavailable; select a vault with a readable Clue template"
|
|
40
|
+
return f"nonblank clue text (default: {default})".replace("%", "%%")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
|
|
44
|
+
"""Parse command arguments separately from command execution.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
argv: Arguments without the executable name, or None to read sys.argv.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
argparse.Namespace: Selected command and its paths and options.
|
|
51
|
+
|
|
52
|
+
Raises:
|
|
53
|
+
SystemExit: Argparse exits with 0 for help or 2 for invalid arguments.
|
|
54
|
+
"""
|
|
55
|
+
parser = argparse.ArgumentParser(prog="armarium")
|
|
56
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
57
|
+
initialize = commands.add_parser(
|
|
58
|
+
"init",
|
|
59
|
+
help="create and validate a vault from the starter vault",
|
|
60
|
+
description=(
|
|
61
|
+
"Create and validate a single-campaign vault at a new path. Missing "
|
|
62
|
+
"parent directories are created. Use --force to replace an existing directory."
|
|
63
|
+
),
|
|
64
|
+
)
|
|
65
|
+
initialize.add_argument("path", type=Path, help="new vault directory")
|
|
66
|
+
initialize.add_argument(
|
|
67
|
+
"--force",
|
|
68
|
+
action="store_true",
|
|
69
|
+
help="replace an existing directory and all its contents; refuse files and symlinks",
|
|
70
|
+
)
|
|
71
|
+
add = commands.add_parser("add", help="add to an existing vault")
|
|
72
|
+
additions = add.add_subparsers(dest="addition", required=True)
|
|
73
|
+
campaign = additions.add_parser("campaign", help="create a new campaign")
|
|
74
|
+
content = additions.add_parser("content", help="create a Content record")
|
|
75
|
+
player = additions.add_parser("player", help="create a Player record")
|
|
76
|
+
note = additions.add_parser("note", help="create a Note record")
|
|
77
|
+
session = additions.add_parser("session", help="create a Session record")
|
|
78
|
+
clue = additions.add_parser("clue", help="create a Clue record", add_help=False)
|
|
79
|
+
transcript = additions.add_parser(
|
|
80
|
+
"transcript", help="create a Transcript for an existing Session"
|
|
81
|
+
)
|
|
82
|
+
for addition, create in (
|
|
83
|
+
(campaign, add_campaign),
|
|
84
|
+
(content, add_content),
|
|
85
|
+
(player, add_player),
|
|
86
|
+
(note, add_note),
|
|
87
|
+
(session, add_session),
|
|
88
|
+
(clue, add_clue),
|
|
89
|
+
(transcript, add_transcript),
|
|
90
|
+
):
|
|
91
|
+
addition.set_defaults(create=create)
|
|
92
|
+
addition.add_argument(
|
|
93
|
+
"--vault",
|
|
94
|
+
type=Path,
|
|
95
|
+
help="vault root (default: discovered from the current directory)",
|
|
96
|
+
)
|
|
97
|
+
for addition in (content, player, note):
|
|
98
|
+
addition.add_argument("name", help="record name without .md")
|
|
99
|
+
for addition, shared in (
|
|
100
|
+
(content, ", or shared content"),
|
|
101
|
+
(player, ""),
|
|
102
|
+
(note, ", or shared notes"),
|
|
103
|
+
(session, ""),
|
|
104
|
+
(clue, ""),
|
|
105
|
+
):
|
|
106
|
+
addition.add_argument(
|
|
107
|
+
"--campaign",
|
|
108
|
+
type=int,
|
|
109
|
+
help=f"campaign number within vault (default: current campaign directory{shared})",
|
|
110
|
+
)
|
|
111
|
+
for addition, description in (
|
|
112
|
+
(campaign, "positive campaign number"),
|
|
113
|
+
(session, "session number from 1 to 999"),
|
|
114
|
+
(clue, "clue number from 1 to 9999"),
|
|
115
|
+
):
|
|
116
|
+
addition.add_argument(
|
|
117
|
+
"--number",
|
|
118
|
+
type=int,
|
|
119
|
+
help=f"{description} (default: largest existing number + 1)",
|
|
120
|
+
)
|
|
121
|
+
content.add_argument("--subtype", required=True, choices=SUBTYPES)
|
|
122
|
+
content.add_argument(
|
|
123
|
+
"--player", help="existing Player name or vault-relative path (PC only)"
|
|
124
|
+
)
|
|
125
|
+
clue.add_argument(
|
|
126
|
+
"-h",
|
|
127
|
+
"--help",
|
|
128
|
+
action="store_true",
|
|
129
|
+
dest="clue_help",
|
|
130
|
+
help="show this help message and exit",
|
|
131
|
+
)
|
|
132
|
+
clue_text = clue.add_argument("--text", help="nonblank clue text")
|
|
133
|
+
transcript.add_argument(
|
|
134
|
+
"session", help="unambiguous Session name or vault-relative path"
|
|
135
|
+
)
|
|
136
|
+
transcript.add_argument(
|
|
137
|
+
"--body-file",
|
|
138
|
+
type=Path,
|
|
139
|
+
required=True,
|
|
140
|
+
help="UTF-8 Markdown file containing transcript body",
|
|
141
|
+
)
|
|
142
|
+
command = commands.add_parser(
|
|
143
|
+
"validate",
|
|
144
|
+
help="validate a Markdown file or directory",
|
|
145
|
+
description=(
|
|
146
|
+
"Validate a Markdown file, or all Markdown files within a directory, "
|
|
147
|
+
"checking schemas, links and record context. Excludes files outside valid "
|
|
148
|
+
"Obsidian vaults; single-file mode will fail if outside a vault."
|
|
149
|
+
),
|
|
150
|
+
)
|
|
151
|
+
command.add_argument(
|
|
152
|
+
"path", type=Path, help="Markdown file or directory to validate"
|
|
153
|
+
)
|
|
154
|
+
command.add_argument("--vault", type=Path, help="explicit vault directory")
|
|
155
|
+
args = parser.parse_args(argv)
|
|
156
|
+
if args.command == "add" and args.addition == "clue" and args.clue_help:
|
|
157
|
+
clue_text.help = _clue_text_help(args.vault)
|
|
158
|
+
clue.print_help()
|
|
159
|
+
parser.exit()
|
|
160
|
+
if args.command == "add":
|
|
161
|
+
for option in ("number", "campaign"):
|
|
162
|
+
value = getattr(args, option, None)
|
|
163
|
+
if value is not None and value < 1:
|
|
164
|
+
parser.error(f"--{option} must be positive")
|
|
165
|
+
if args.addition == "session" and args.number is not None and args.number > 999:
|
|
166
|
+
parser.error("--number must be between 1 and 999")
|
|
167
|
+
if args.addition == "clue" and args.number is not None and args.number > 9999:
|
|
168
|
+
parser.error("--number must be between 1 and 9999")
|
|
169
|
+
return args
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def main() -> None:
|
|
173
|
+
"""Run the selected command and log its result.
|
|
174
|
+
|
|
175
|
+
Raises:
|
|
176
|
+
SystemExit: Status 1 when creation fails or files fail validation.
|
|
177
|
+
Creation reports validation warnings and errors; validate also reports
|
|
178
|
+
informational diagnostics and coverage counts. Argument parsing
|
|
179
|
+
exits with 0 for help or 2 for usage errors.
|
|
180
|
+
"""
|
|
181
|
+
args = parse_args()
|
|
182
|
+
configure_logging()
|
|
183
|
+
if args.command == "init":
|
|
184
|
+
try:
|
|
185
|
+
logger.info(
|
|
186
|
+
"Initializing new vault at %s", args.path.expanduser().resolve()
|
|
187
|
+
)
|
|
188
|
+
destination = init_vault(args.path, force=args.force)
|
|
189
|
+
except OSError as exc:
|
|
190
|
+
logger.error("Cannot create vault at %s: %s", args.path, exc)
|
|
191
|
+
sys.exit(1)
|
|
192
|
+
logger.info("New vault successfully initialized; validating")
|
|
193
|
+
result = validate(destination)
|
|
194
|
+
elif args.command == "add":
|
|
195
|
+
try:
|
|
196
|
+
options = {
|
|
197
|
+
key: value
|
|
198
|
+
for key, value in vars(args).items()
|
|
199
|
+
if key not in {"command", "addition", "create", "clue_help"}
|
|
200
|
+
}
|
|
201
|
+
destination = args.create(**options)
|
|
202
|
+
except (OSError, ValueError) as exc:
|
|
203
|
+
logger.error("Cannot add %s: %s", args.addition, exc)
|
|
204
|
+
sys.exit(1)
|
|
205
|
+
logger.info("New %s successfully created; validating", args.addition)
|
|
206
|
+
result = validate(find_vault(destination))
|
|
207
|
+
else:
|
|
208
|
+
result = validate(args.path, args.vault)
|
|
209
|
+
result.report()
|
|
210
|
+
if args.command in {"init", "add"}:
|
|
211
|
+
for diagnostic in result.diagnostics:
|
|
212
|
+
if diagnostic.severity != "info":
|
|
213
|
+
diagnostic.report()
|
|
214
|
+
if result.failed:
|
|
215
|
+
failed_files = result.failed_files
|
|
216
|
+
noun = "file" if failed_files == 1 else "files"
|
|
217
|
+
logger.error("%s %s failed validation", failed_files, noun)
|
|
218
|
+
if args.command == "init":
|
|
219
|
+
logger.error("Vault retained at %s for inspection", destination)
|
|
220
|
+
elif args.command == "add":
|
|
221
|
+
logger.error(
|
|
222
|
+
"%s retained at %s for inspection",
|
|
223
|
+
args.addition.capitalize(),
|
|
224
|
+
destination,
|
|
225
|
+
)
|
|
226
|
+
sys.exit(1)
|
|
227
|
+
if args.command in {"init", "add"}:
|
|
228
|
+
logger.info("Validation completed successfully")
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
if __name__ == "__main__":
|
|
232
|
+
main()
|
armarium/clue.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Create numbered Clue records using a vault's local template."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from armarium.add import (
|
|
6
|
+
check_destination,
|
|
7
|
+
read_template,
|
|
8
|
+
record_directory,
|
|
9
|
+
record_number,
|
|
10
|
+
record_text,
|
|
11
|
+
require_campaign,
|
|
12
|
+
select_vault,
|
|
13
|
+
write_record,
|
|
14
|
+
)
|
|
15
|
+
from armarium.index import VaultIndex
|
|
16
|
+
from armarium.lib import (
|
|
17
|
+
find_campaign,
|
|
18
|
+
iter_wikilinks,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _clue_subjects(text: str, destination: Path, index: VaultIndex) -> list[str]:
|
|
23
|
+
"""Return canonical Content links in first-mention order, without duplicates.
|
|
24
|
+
|
|
25
|
+
Resolve text with the existing wikilink parser and vault index. Targets must
|
|
26
|
+
be shared or in the destination campaign. Malformed, missing, ambiguous or
|
|
27
|
+
ineligible targets raise ValueError before any record is written.
|
|
28
|
+
"""
|
|
29
|
+
subjects: list[str] = []
|
|
30
|
+
for link in iter_wikilinks(text):
|
|
31
|
+
if isinstance(link, ValueError):
|
|
32
|
+
raise ValueError(f"invalid clue text link: {link}")
|
|
33
|
+
target, problem = index.resolve(link[0], destination)
|
|
34
|
+
if problem or target is None:
|
|
35
|
+
raise ValueError(
|
|
36
|
+
f"clue text [[{link[0]}]]: {problem}; use a unique Content path"
|
|
37
|
+
)
|
|
38
|
+
record = index.parse(target)[0] if target.suffix.lower() == ".md" else None
|
|
39
|
+
if record is None or record.frontmatter.type != "Content":
|
|
40
|
+
raise ValueError(f"clue text [[{link[0]}]] must target Content")
|
|
41
|
+
if find_campaign(target, index.root) not in {
|
|
42
|
+
None,
|
|
43
|
+
find_campaign(destination, index.root),
|
|
44
|
+
}:
|
|
45
|
+
raise ValueError(f"clue text [[{link[0]}]] targets another campaign")
|
|
46
|
+
canonical = f"[[{target.relative_to(index.root).with_suffix('').as_posix()}]]"
|
|
47
|
+
if canonical not in subjects:
|
|
48
|
+
subjects.append(canonical)
|
|
49
|
+
return subjects
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def add_clue(
|
|
53
|
+
vault: Path | None = None,
|
|
54
|
+
*,
|
|
55
|
+
campaign: int | None = None,
|
|
56
|
+
number: int | None = None,
|
|
57
|
+
text: str | None = None,
|
|
58
|
+
) -> Path:
|
|
59
|
+
"""Create and validate a Clue stub without modifying existing records.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
vault: Vault root, or None to discover it from the working directory.
|
|
63
|
+
campaign: Existing campaign number, or None to infer the current campaign.
|
|
64
|
+
number: Clue number, or None for the largest existing number plus one.
|
|
65
|
+
text: Nonblank clue text, or None to use the local template text.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
Path: Resolved path of the new C-N-NNNN.md record after its schema and
|
|
69
|
+
contextual checks pass. Initial values come from the local template.
|
|
70
|
+
|
|
71
|
+
Raises:
|
|
72
|
+
ValueError: No campaign is selected, numbering or scaffolding is invalid,
|
|
73
|
+
or the generated record fails validation. Diagnostics are logged.
|
|
74
|
+
OSError: A destination exists or a filesystem operation fails.
|
|
75
|
+
KeyboardInterrupt: The partial record is removed before propagating.
|
|
76
|
+
"""
|
|
77
|
+
root = select_vault(vault)
|
|
78
|
+
campaign = require_campaign(root, campaign)
|
|
79
|
+
directory = record_directory(root, "Clue", campaign)
|
|
80
|
+
number = record_number(
|
|
81
|
+
directory, campaign, number, kind="clue", prefix="C", digits=4
|
|
82
|
+
)
|
|
83
|
+
destination = directory / f"C-{campaign}-{number:04}.md"
|
|
84
|
+
check_destination(destination)
|
|
85
|
+
template = read_template(root, "Clue")
|
|
86
|
+
metadata = dict(template.frontmatter)
|
|
87
|
+
clue_text = text if text is not None else metadata.get("text")
|
|
88
|
+
if not isinstance(clue_text, str) or not clue_text.strip():
|
|
89
|
+
raise ValueError(
|
|
90
|
+
"provide nonblank --text or set text in the local Clue template"
|
|
91
|
+
)
|
|
92
|
+
metadata["text"] = clue_text
|
|
93
|
+
metadata["subjects"] = _clue_subjects(clue_text, destination, VaultIndex(root))
|
|
94
|
+
for field in ("first_session", "last_session"):
|
|
95
|
+
metadata.setdefault(field, None)
|
|
96
|
+
text = record_text(metadata, template.body.text)
|
|
97
|
+
return write_record(destination, text, root, "Clue")
|