quirework 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.
Files changed (138) hide show
  1. quirework/__init__.py +3 -0
  2. quirework/__main__.py +3 -0
  3. quirework/cli.py +431 -0
  4. quirework/core/__init__.py +1 -0
  5. quirework/core/artifact_paths.py +86 -0
  6. quirework/core/checks.py +235 -0
  7. quirework/core/frontmatter.py +83 -0
  8. quirework/core/globs.py +45 -0
  9. quirework/core/pinned.py +169 -0
  10. quirework/core/usage.py +604 -0
  11. quirework/delivery/__init__.py +1 -0
  12. quirework/delivery/artifacts.py +210 -0
  13. quirework/delivery/text.py +166 -0
  14. quirework/manuscript/__init__.py +1 -0
  15. quirework/manuscript/backfill.py +1972 -0
  16. quirework/manuscript/editions.py +111 -0
  17. quirework/manuscript/footprint.py +95 -0
  18. quirework/manuscript/formats.py +208 -0
  19. quirework/manuscript/lifecycle.py +191 -0
  20. quirework/manuscript/notes.py +2301 -0
  21. quirework/manuscript/pages.py +284 -0
  22. quirework/manuscript/quarto.py +416 -0
  23. quirework/manuscript/quotes.py +512 -0
  24. quirework/manuscript/rename.py +309 -0
  25. quirework/manuscript/sources.py +286 -0
  26. quirework/manuscript/trail.py +1875 -0
  27. quirework/manuscript/units.py +416 -0
  28. quirework/project.py +645 -0
  29. quirework/provenance/__init__.py +1 -0
  30. quirework/provenance/agyfilter.py +436 -0
  31. quirework/provenance/backup/__init__.py +469 -0
  32. quirework/provenance/backup/base.py +290 -0
  33. quirework/provenance/backup/command.py +107 -0
  34. quirework/provenance/backup/directory.py +142 -0
  35. quirework/provenance/backup/git.py +150 -0
  36. quirework/provenance/backup/rclone.py +153 -0
  37. quirework/provenance/capture/__init__.py +1 -0
  38. quirework/provenance/capture/commands.py +28 -0
  39. quirework/provenance/capture/extract_artifacts.py +110 -0
  40. quirework/provenance/capture/extract_files.py +118 -0
  41. quirework/provenance/capture/fetch_conversation.py +127 -0
  42. quirework/provenance/capture/fetch_gemini.py +149 -0
  43. quirework/provenance/capture/render_transcript.py +126 -0
  44. quirework/provenance/capture/transform.py +22 -0
  45. quirework/provenance/catalog.py +733 -0
  46. quirework/provenance/ccfilter.py +1126 -0
  47. quirework/provenance/codexfilter.py +883 -0
  48. quirework/provenance/coldread.py +1731 -0
  49. quirework/provenance/curate.py +67 -0
  50. quirework/provenance/humaninput.py +811 -0
  51. quirework/provenance/ingest.py +31 -0
  52. quirework/provenance/ingestion/__init__.py +1 -0
  53. quirework/provenance/ingestion/agy.py +192 -0
  54. quirework/provenance/ingestion/claude.py +493 -0
  55. quirework/provenance/ingestion/codex.py +296 -0
  56. quirework/provenance/lineage.py +148 -0
  57. quirework/provenance/metrics.py +1247 -0
  58. quirework/provenance/rulings.py +478 -0
  59. quirework/provenance/scope.py +73 -0
  60. quirework/provenance/search.py +455 -0
  61. quirework/provenance/semantic.py +475 -0
  62. quirework/provenance/show.py +1213 -0
  63. quirework/provenance/store.py +1271 -0
  64. quirework/provenance/trees.py +133 -0
  65. quirework/provenance/worktrees.py +199 -0
  66. quirework/providers/__init__.py +1 -0
  67. quirework/providers/blind.py +2192 -0
  68. quirework/providers/canary.py +685 -0
  69. quirework/providers/catalog.py +44 -0
  70. quirework/providers/claude_login.py +92 -0
  71. quirework/providers/exits.py +196 -0
  72. quirework/providers/harness.py +830 -0
  73. quirework/providers/images.py +92 -0
  74. quirework/providers/models.py +1284 -0
  75. quirework/providers/quota.py +588 -0
  76. quirework/providers/reads.py +474 -0
  77. quirework/providers/replies.py +175 -0
  78. quirework/publish/__init__.py +1 -0
  79. quirework/publish/backends.py +112 -0
  80. quirework/publish/bookstage.py +1086 -0
  81. quirework/publish/build.py +60 -0
  82. quirework/publish/editions.py +48 -0
  83. quirework/publish/epubcheck.py +287 -0
  84. quirework/publish/experiment.py +1097 -0
  85. quirework/publish/figfonts.py +234 -0
  86. quirework/publish/fignumbers.py +293 -0
  87. quirework/publish/figures.py +729 -0
  88. quirework/publish/folios.py +62 -0
  89. quirework/publish/fonts.py +307 -0
  90. quirework/publish/graybuild.py +73 -0
  91. quirework/publish/hugo.py +117 -0
  92. quirework/publish/kindle.js +411 -0
  93. quirework/publish/kindle.py +1393 -0
  94. quirework/publish/panelkit.py +918 -0
  95. quirework/publish/plans.py +651 -0
  96. quirework/publish/proof.py +792 -0
  97. quirework/publish/redpen.py +1654 -0
  98. quirework/publish/render.py +942 -0
  99. quirework/publish/targets.py +216 -0
  100. quirework/publish/variant.py +918 -0
  101. quirework/runs/__init__.py +1 -0
  102. quirework/runs/__main__.py +15 -0
  103. quirework/runs/commands.py +278 -0
  104. quirework/runs/coverage.py +86 -0
  105. quirework/runs/engine.py +2143 -0
  106. quirework/runs/feed.py +853 -0
  107. quirework/runs/observe.py +379 -0
  108. quirework/runs/storage.py +155 -0
  109. quirework/settings.py +492 -0
  110. quirework/sops/__init__.py +1 -0
  111. quirework/sops/catalog.py +386 -0
  112. quirework/sops/commands.py +567 -0
  113. quirework/sops/composition.py +382 -0
  114. quirework/sops/instruments.py +184 -0
  115. quirework/sops/personas.py +296 -0
  116. quirework/sops/review.py +2568 -0
  117. quirework/sops/room.py +2791 -0
  118. quirework/sops/roundtable.py +2578 -0
  119. quirework/sops/sections.py +169 -0
  120. quirework/sops/workshop.py +1629 -0
  121. quirework/templates/backup.toml +39 -0
  122. quirework/templates/book.toml +88 -0
  123. quirework/templates/run.toml +32 -0
  124. quirework/templates/site.toml +90 -0
  125. quirework/templates/tells.toml +64 -0
  126. quirework/templates/terminology.toml +20 -0
  127. quirework/templates/tmpfiles.conf +42 -0
  128. quirework/testing.py +345 -0
  129. quirework/text/__init__.py +1 -0
  130. quirework/text/jargon.py +545 -0
  131. quirework/text/mdwrap.py +1001 -0
  132. quirework/text/tells.py +547 -0
  133. quirework-0.1.0.dist-info/METADATA +444 -0
  134. quirework-0.1.0.dist-info/RECORD +138 -0
  135. quirework-0.1.0.dist-info/WHEEL +5 -0
  136. quirework-0.1.0.dist-info/entry_points.txt +2 -0
  137. quirework-0.1.0.dist-info/licenses/LICENSE +21 -0
  138. quirework-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,111 @@
1
+ """Derived editions as the spec layer sees them: more units that answer to the spec.
2
+
3
+ An edition is a directory under `publish.variants`, one per kind. Its
4
+ documents sit at its top level, and each may keep a marks sidecar,
5
+ `_marks/STEM.md` beside it, as a chapter does. A sidecar's `@bind` marks
6
+ name the spec sections its document carries, and its `@omit` marks name
7
+ the ones the edition leaves out and why. So one commitment can be followed
8
+ from the chapter that states it to every edition that retells it.
9
+
10
+ An edition's unit is named `KIND/STEM`. The poems' `otto` is then never
11
+ taken for the canon's chapter `otto`, and nothing that maps the canon's
12
+ chapters to the spec, such as a trail or a backfill menu, hears of it.
13
+
14
+ This is layout only. The publisher renders an edition from the same
15
+ directories, and imports this module for where they are; the spec layer
16
+ never imports the publisher.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from pathlib import Path
22
+
23
+ from quirework.manuscript import quarto
24
+ from quirework.manuscript.quarto import BOOK_CONFIG, MARKS_DIR
25
+ from quirework.project import current_project
26
+
27
+ # Every way an edition document can be written, in the order a sidecar's
28
+ # document is looked for.
29
+ _SUFFIXES = (".qmd", ".md")
30
+
31
+
32
+ def root() -> Path | None:
33
+ """The configured editions root, or None for a project with no editions."""
34
+ if current_project().get("publish.variants") is None:
35
+ return None
36
+ return current_project().path("publish.variants")
37
+
38
+
39
+ def kinds() -> list[str]:
40
+ """Every edition directory, composed or not, by kind, sorted."""
41
+ base = root()
42
+ if base is None or not base.is_dir():
43
+ return []
44
+ return sorted(
45
+ folder.name
46
+ for folder in base.iterdir()
47
+ if folder.is_dir() and not folder.name.startswith(("_", "."))
48
+ )
49
+
50
+
51
+ def composed() -> list[str]:
52
+ """Every edition that has a Quarto file of its own, by kind, sorted."""
53
+ return [kind for kind in kinds() if (root() / kind / BOOK_CONFIG).is_file()]
54
+
55
+
56
+ def documents(kind: str) -> list[Path]:
57
+ """The documents a composed edition renders, as they are on disk.
58
+
59
+ Raises ValueError when its Quarto file is not a shape the parser reads.
60
+ """
61
+ folder = root() / kind
62
+ return [
63
+ folder / rel for rel in quarto.documents(folder) if (folder / rel).is_file()
64
+ ]
65
+
66
+
67
+ def sidecars() -> list[Path]:
68
+ """Every edition's marks sidecars, by kind, then by name."""
69
+ base = root()
70
+ return [
71
+ path
72
+ for kind in kinds()
73
+ for path in sorted((base / kind / MARKS_DIR).glob("*.md"))
74
+ if path.is_file()
75
+ ]
76
+
77
+
78
+ def _kind_and_stem(path: Path) -> tuple[str, str] | None:
79
+ """(kind, stem) for an edition's document or sidecar, else None."""
80
+ base = root()
81
+ if base is None:
82
+ return None
83
+ path = Path(path)
84
+ if not path.is_absolute():
85
+ path = current_project().root / path
86
+ try:
87
+ parts = path.resolve().relative_to(base).parts
88
+ except ValueError:
89
+ return None
90
+ if len(parts) == 2 and Path(parts[1]).suffix in _SUFFIXES:
91
+ return parts[0], Path(parts[1]).stem
92
+ if len(parts) == 3 and parts[1] == MARKS_DIR and parts[2].endswith(".md"):
93
+ return parts[0], Path(parts[2]).stem
94
+ return None
95
+
96
+
97
+ def unit_of(path: Path | str) -> str | None:
98
+ """`KIND/STEM` for an edition's document or sidecar, else None."""
99
+ found = _kind_and_stem(Path(path))
100
+ return f"{found[0]}/{found[1]}" if found else None
101
+
102
+
103
+ def is_sidecar(path: Path) -> bool:
104
+ return unit_of(path) is not None and Path(path).parent.name == MARKS_DIR
105
+
106
+
107
+ def prose_for(sidecar: Path) -> Path:
108
+ """The document an edition sidecar describes, or where it would be."""
109
+ sidecar = Path(sidecar)
110
+ candidates = [sidecar.parent.parent / f"{sidecar.stem}{s}" for s in _SUFFIXES]
111
+ return next((c for c in candidates if c.is_file()), candidates[0])
@@ -0,0 +1,95 @@
1
+ """Where the work lives: which files are the written work, in any format.
2
+
3
+ Several tools ask the same question in different words. Did this session edit
4
+ the work or the tooling around it? Is this commit one a reviewer must see?
5
+ Which paths does the history of the prose run through? Each used to answer it
6
+ for itself, from `manuscript.source` and the companion directories, which is
7
+ right for a book whose spec and marks sit inside its source and wrong for a
8
+ site that keeps them outside the tree its generator renders. This module is
9
+ the one answer, and it is built from what the project already declares:
10
+
11
+ - the units and their marks, as the format lays them out;
12
+ - the register: the shared spec files, each unit's own spec, the sources,
13
+ the references and the review notes;
14
+ - the companion collections;
15
+ - and, kept apart, the working roots (`manuscript.noncanonical_roots`), which
16
+ are writing without authority. A caller measuring effort counts them; a
17
+ caller deciding what a reviewer must see does not.
18
+
19
+ Every entry is a glob over project-relative POSIX paths in the `core.globs`
20
+ dialect. A project without a manuscript has an empty footprint.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from dataclasses import dataclass
26
+
27
+ from quirework.core import globs
28
+ from quirework.manuscript import formats
29
+ from quirework.project import Project, current_project
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class Footprint:
34
+ units: tuple[str, ...] # the prose and its marks, as the format lays them out
35
+ register: tuple[str, ...] # spec files, references, review notes
36
+ companions: tuple[str, ...] # reader-facing collections beside the work
37
+ working: tuple[str, ...] # working material: writing, but no authority
38
+
39
+ def patterns(self, working: bool = False) -> tuple[str, ...]:
40
+ out = self.units + self.register + self.companions
41
+ return out + self.working if working else out
42
+
43
+ def covers(self, path: str, working: bool = False) -> bool:
44
+ """Whether a project-relative path is part of the work."""
45
+ path = path[2:] if path.startswith("./") else path
46
+ return globs.any_match(path, self.patterns(working))
47
+
48
+
49
+ def _directories(project: Project, key: str) -> tuple[str, ...]:
50
+ """A configured list of directories, each as a glob for everything under it."""
51
+ out = []
52
+ for value in project.get(key, ()) or ():
53
+ if not value:
54
+ continue
55
+ text = str(value).strip("/")
56
+ out.append("**" if text in ("", ".") else f"{text}/**")
57
+ return tuple(out)
58
+
59
+
60
+ def _spec_files(project: Project) -> tuple[str, ...]:
61
+ """The shared spec files, one or a list, as project-relative globs; one
62
+ outside the project has no such name and is left out, as `relative` does."""
63
+ if project.get("manuscript.spec") is None:
64
+ return ()
65
+ out = []
66
+ for path in project.paths("manuscript.spec"):
67
+ try:
68
+ out.append(path.relative_to(project.root).as_posix())
69
+ except ValueError:
70
+ continue
71
+ return tuple(out)
72
+
73
+
74
+ def footprint(project: Project | None = None) -> Footprint:
75
+ project = project or current_project()
76
+ if not project.get("manuscript"):
77
+ return Footprint((), (), (), ())
78
+ units = formats.get(project.get("manuscript.format", "quarto")).footprint(project)
79
+ register = _spec_files(project) + tuple(
80
+ found
81
+ for key in ("manuscript.references", "manuscript.review_notes")
82
+ if (found := formats.relative(project, key)) is not None
83
+ )
84
+ unit_spec = project.get("manuscript.unit_spec")
85
+ if unit_spec:
86
+ register += (str(unit_spec).replace("{slug}", "*"),)
87
+ from quirework.manuscript import sources
88
+
89
+ register += sources.footprint_patterns(project)
90
+ return Footprint(
91
+ units,
92
+ register,
93
+ _directories(project, "manuscript.companion_dirs"),
94
+ _directories(project, "manuscript.noncanonical_roots"),
95
+ )
@@ -0,0 +1,208 @@
1
+ """What a manuscript format is, and how one is selected.
2
+
3
+ A format answers every question the spec layer asks about the written work's
4
+ layout: which files are its units and in what order, which files are prose,
5
+ where a unit's marks sidecar lives, and which unit a sidecar belongs to. The
6
+ spec, marks, quotations and trail are written once against this interface, so
7
+ a new layout is one new `Format` and no change to them.
8
+
9
+ `manuscript.format` names one. The two built in are `quarto` (a Quarto book's
10
+ chapters, in `_quarto.yml` order) and `pages` (a site's Markdown pages). A
11
+ project can select a format of its own as an installed extension,
12
+ `module:function`, where the function returns a `Format`; like every
13
+ extension, it is loaded only when the project names it.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import importlib
19
+ import re
20
+ from dataclasses import dataclass
21
+ from pathlib import Path
22
+
23
+ from quirework.core import frontmatter
24
+ from quirework.project import ConfigurationError, Project, current_project, extension
25
+
26
+ # The formats Quirework ships, by module. Imported on first use, so a project
27
+ # that never names a format pays for neither.
28
+ BUILTIN = {
29
+ "quarto": "quirework.manuscript.quarto",
30
+ "pages": "quirework.manuscript.pages",
31
+ }
32
+
33
+ # A slug is lowercase words joined by hyphens: the one shape a reference, a
34
+ # path pattern and a spec binding can all carry without quoting.
35
+ SLUG_OK = re.compile(r"\A[a-z0-9]+(-[a-z0-9]+)*\Z")
36
+
37
+ # Developer-guide files carry annotation *examples* in prose, not content.
38
+ SKIP_NAMES = {"AGENTS.md", "CLAUDE.md", "GEMINI.md"}
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class Unit:
43
+ slug: str # the durable identity: sidecar, anchor, @unit(), binding key
44
+ rel: str # path to the prose file, relative to the project root
45
+ title: str # the H1 or frontmatter title, which may drift from the slug
46
+ number: int | None = None # a printed chapter number, from position
47
+ date: str | None = None # a page's frontmatter date
48
+ # Whether a page is a draft. None where the format has no publication
49
+ # state, as a book's chapters have none of their own.
50
+ draft: bool | None = None
51
+ lastmod: str | None = None # when a published page was last revised
52
+ weight: int | None = None # a page's frontmatter weight, for ordering
53
+ collection: str | None = None # the named collection a page belongs to
54
+
55
+
56
+ class Format:
57
+ """One way of laying out a manuscript. Subclasses answer for their layout.
58
+
59
+ Every method that reads the tree takes ``docs``, the source directory,
60
+ which is `manuscript.source` in normal use and a temporary tree when a
61
+ check reads the work as it stood at a revision.
62
+ """
63
+
64
+ name: str = ""
65
+ # What one unit is called in messages, unless `manuscript.unit_name` says.
66
+ noun: str = "unit"
67
+
68
+ def validate(self, project: Project) -> None:
69
+ """Refuse a configuration this format cannot read. Called by
70
+ `Project.require("manuscript")` inside the project's scope."""
71
+
72
+ def registry(self, docs: Path) -> list[Unit]:
73
+ """Every unit in reading order."""
74
+ raise NotImplementedError
75
+
76
+ def order_problems(self, docs: Path) -> list[str]:
77
+ """What stops the units on disk from being the manuscript's units."""
78
+ raise NotImplementedError
79
+
80
+ def prose_files(self, docs: Path) -> list[Path]:
81
+ """Every prose file on disk, whether or not a reading order lists it."""
82
+ raise NotImplementedError
83
+
84
+ def slug_of(self, path: Path) -> str:
85
+ """The unit slug a prose file carries."""
86
+ raise NotImplementedError
87
+
88
+ def marks_path(self, slug: str, docs: Path) -> Path | None:
89
+ """Where the unit's marks sidecar lives, or would; None if it has none."""
90
+ raise NotImplementedError
91
+
92
+ def sidecars(self, docs: Path) -> list[Path]:
93
+ """Every marks sidecar on disk."""
94
+ raise NotImplementedError
95
+
96
+ def sidecar_slug(self, path: Path) -> str | None:
97
+ """The unit a sidecar belongs to, or None if the file is no sidecar."""
98
+ raise NotImplementedError
99
+
100
+ def prose_for_sidecar(self, path: Path, docs: Path) -> Path | None:
101
+ """The prose file a sidecar's marks describe; None when it has none."""
102
+ raise NotImplementedError
103
+
104
+ def harvest(self, docs: Path) -> list[Path]:
105
+ """The files and directories `notes.scan` reads by default."""
106
+ raise NotImplementedError
107
+
108
+ def default_layers(self, project: Project) -> tuple[str, ...]:
109
+ """Where the metadata sidecars are, as globs relative to the root."""
110
+ raise NotImplementedError
111
+
112
+ def prose_token(self, token: str) -> str | None:
113
+ """The slug a token names when it is written as a prose file's path
114
+ (`the-sidecar.qmd`, `posts/the-kettle/index.md`), else None. A run of
115
+ prose that cites units reads such a token as a pointer."""
116
+ return None
117
+
118
+ def listing(self, units: list[Unit]) -> list[str]:
119
+ """The rows `spec units` prints, one per unit."""
120
+ return [f" {u.slug:34} {u.title}" for u in units]
121
+
122
+ def footprint(self, project: Project) -> tuple[str, ...]:
123
+ """Where the units and their marks live, as globs over project-relative
124
+ paths (`core.globs`). Everything else in a work's footprint, its spec
125
+ and companions, is the project's to declare and `footprint` adds it."""
126
+ return ()
127
+
128
+
129
+ def get(name: str) -> Format:
130
+ """The format ``name`` names: a built-in, or an extension's."""
131
+ if name in BUILTIN:
132
+ return importlib.import_module(BUILTIN[name]).FORMAT
133
+ if ":" in name:
134
+ made = extension(name)()
135
+ if not isinstance(made, Format):
136
+ raise ConfigurationError(
137
+ f"manuscript.format {name} must return a quirework Format"
138
+ )
139
+ return made
140
+ raise ConfigurationError(
141
+ f"manuscript.format must be one of {', '.join(BUILTIN)} or an installed "
142
+ f"module:function, not {name!r}"
143
+ )
144
+
145
+
146
+ def current() -> Format:
147
+ """The selected project's format; `quarto` when it declares none."""
148
+ return get(current_project().get("manuscript.format", "quarto"))
149
+
150
+
151
+ # --------------------------------------------------------------------------- #
152
+ # helpers the built-in formats share
153
+ # --------------------------------------------------------------------------- #
154
+ def base(docs: Path) -> Path:
155
+ """What a unit's `rel` is measured from: the project root when the source
156
+ is inside it, else the directory holding the source. The second case is a
157
+ tree outside any project, which is what a test hands the registry."""
158
+ root = current_project().root
159
+ return root if docs.resolve().is_relative_to(root) else docs.parent
160
+
161
+
162
+ def rel(path: Path) -> str:
163
+ root = current_project().root
164
+ path = Path(path)
165
+ return str(path.relative_to(root)) if path.is_relative_to(root) else str(path)
166
+
167
+
168
+ _H1 = re.compile(r"^#\s+(.*?)\s*(?:\{(.*?)\})?\s*$")
169
+
170
+
171
+ def h1(text: str) -> tuple[str, str]:
172
+ """(title, attribute block) of the first H1 after any frontmatter."""
173
+ found = frontmatter.block(text)
174
+ body = text[found.end() :] if found else text
175
+ for line in body.splitlines():
176
+ m = _H1.match(line)
177
+ if m:
178
+ return m.group(1).strip(), (m.group(2) or "")
179
+ return "", ""
180
+
181
+
182
+ def relative(project: Project, key: str) -> str | None:
183
+ """A configured path as a project-relative POSIX path; None when it is
184
+ unset or lies outside the project, where no project-relative glob can
185
+ name it."""
186
+ if project.get(key) is None:
187
+ return None
188
+ try:
189
+ return project.path(key).relative_to(project.root).as_posix()
190
+ except ValueError:
191
+ return None
192
+
193
+
194
+ def slug_pattern(key: str) -> str | None:
195
+ """A configured path pattern that must carry `{slug}`, or None if unset."""
196
+ pattern = current_project().get(key)
197
+ if pattern is not None and "{slug}" not in str(pattern):
198
+ raise ConfigurationError(
199
+ f"{key} must contain {{slug}}, e.g. 'specs/{{slug}}/"
200
+ f"{'spec' if key.endswith('spec') else 'marks'}.md'"
201
+ )
202
+ return pattern
203
+
204
+
205
+ def pattern_regex(pattern: str) -> re.Pattern:
206
+ """A `{slug}` path pattern as a regex capturing the slug."""
207
+ head, _, tail = pattern.partition("{slug}")
208
+ return re.compile(re.escape(head) + r"(?P<slug>[^/]+)" + re.escape(tail) + r"\Z")
@@ -0,0 +1,191 @@
1
+ """Where each unit of the work stands: an idea, planned, a draft, published.
2
+
3
+ A book's chapters are written and revised together and carry no state of
4
+ their own. A site's posts each live a life: a brief in working material, a
5
+ spec before a word is drafted, a draft, a publication, and for an explainer
6
+ years of revisions after it. The layers already hold every stage but the
7
+ first, so this module only reads them:
8
+
9
+ - an **idea** is a file `manuscript.ideas` matches that no unit has taken up
10
+ yet: none carries its name, and no mark or spec names where it lives (its
11
+ directory, when it is named by one), which is how a post's @genesis
12
+ points at the brief it grew from
13
+ - a unit is **planned** while it has a spec and no page
14
+ - a page is a **draft** or **published** by its own frontmatter
15
+
16
+ Two advisories follow from the published stage, because what an author
17
+ promised a reader is at stake there and nowhere earlier. A published unit
18
+ with an open @todo is waiting on something its readers cannot see. And a
19
+ published unit whose own spec changed after the page was last dated says
20
+ something it no longer holds, or holds something it no longer says, until the
21
+ page is revised and its revision field (`lastmod` unless
22
+ `manuscript.revision_field` names another) records it. The second reads the
23
+ history, so it needs the project in git; each page is compared with the
24
+ commit that last touched its spec, not with a clock.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import re
30
+ import subprocess
31
+ from dataclasses import dataclass
32
+ from pathlib import Path
33
+
34
+ from quirework.manuscript import notes, units
35
+ from quirework.project import ConfigurationError, current_project
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class Idea:
40
+ slug: str
41
+ rel: str # the file the ideas glob matched, relative to the root
42
+ home: str # its directory when the idea is named by one, else rel
43
+
44
+
45
+ def revision_field() -> str:
46
+ return str(current_project().get("manuscript.revision_field", "lastmod"))
47
+
48
+
49
+ def ideas() -> list[Idea]:
50
+ """Every file `manuscript.ideas` matches, named as a unit would be: by its
51
+ directory when the glob names a fixed file (`ideas/*/brief.md`), else by
52
+ its stem."""
53
+ project = current_project()
54
+ pattern = project.get("manuscript.ideas")
55
+ if not pattern:
56
+ return []
57
+ by_directory = not any(c in pattern.rpartition("/")[2] for c in "*?[")
58
+ out = []
59
+ for path in sorted(project.root.glob(pattern)):
60
+ if not path.is_file() or path.name.startswith("_") or path.name == "README.md":
61
+ continue
62
+ rel = path.relative_to(project.root).as_posix()
63
+ if by_directory:
64
+ home = path.parent.relative_to(project.root).as_posix()
65
+ out.append(Idea(path.parent.name, rel, home))
66
+ else:
67
+ out.append(Idea(path.stem, rel, rel))
68
+ return out
69
+
70
+
71
+ def _names_path(said: str, path: str) -> bool:
72
+ """Whether prose names `path` as a whole path: `ideas/lamp` is named by
73
+ "in ideas/lamp," and by "ideas/lamp/brief.md", not by "ideas/lamp-two".
74
+ A trailing period ends a sentence; one followed by a letter starts an
75
+ extension, and so names another file."""
76
+ return re.search(rf"(?<![\w./-]){re.escape(path)}(?![\w-]|\.\w)", said) is not None
77
+
78
+
79
+ def planned() -> list[tuple[str, Path]]:
80
+ """(slug, spec file) for each unit spec whose unit has no page yet."""
81
+ if notes._unit_spec_pattern() is None:
82
+ return []
83
+ present = units.names()
84
+ return [
85
+ (owner, f)
86
+ for f in notes.spec_files()
87
+ if (owner := notes.spec_owner(f)) is not None and owner not in present
88
+ ]
89
+
90
+
91
+ def open_ideas() -> list[Idea]:
92
+ """The ideas no unit has taken up yet."""
93
+ found = ideas()
94
+ if not found:
95
+ return []
96
+ taken = units.names() | {slug for slug, _ in planned()}
97
+ # A mark or a spec that names the idea's path has taken it up: a post's
98
+ # @genesis points at the brief it grew from. An idea named by its
99
+ # directory is its directory, so naming that is enough; an idea that is
100
+ # one file among others in a directory is named only by its own path.
101
+ try:
102
+ specs = notes.spec_files()
103
+ except ConfigurationError:
104
+ specs = []
105
+ said = "\n".join(
106
+ [a.body for a in notes.scan() if a.body]
107
+ + [f.read_text(encoding="utf-8") for f in specs]
108
+ )
109
+ return [
110
+ idea
111
+ for idea in found
112
+ if idea.slug not in taken and not _names_path(said, idea.home)
113
+ ]
114
+
115
+
116
+ def published() -> list[units.Unit]:
117
+ return [u for u in units.registry() if u.draft is False]
118
+
119
+
120
+ def open_todos() -> list[str]:
121
+ """Advisory lines: an open @todo on a published unit."""
122
+ live = {u.rel for u in published()}
123
+ if not live:
124
+ return []
125
+ return [
126
+ f" {a.source or a.file}:{a.line}: @todo {a.body}".rstrip()
127
+ for a in notes.scan()
128
+ if a.kind == "todo" and a.file in live
129
+ ]
130
+
131
+
132
+ def _git(root, *args):
133
+ return subprocess.run(
134
+ ["git", "-C", str(root), *args], capture_output=True, text=True
135
+ )
136
+
137
+
138
+ def _claims(text: str) -> str:
139
+ """A spec's text as its claims: without comments, anchors or the spacing
140
+ a reflow changes. Renaming an anchor or rewrapping a paragraph revises
141
+ nothing a reader was told."""
142
+ text = re.sub(r"(?s)<!--.*?-->", " ", text)
143
+ text = re.sub(r"\{[^{}\n]*\}", " ", text)
144
+ return re.sub(r"\s+", " ", text).strip()
145
+
146
+
147
+ def unrevised() -> list[str] | None:
148
+ """Advisory lines: a published unit whose own spec's claims changed after
149
+ the page was last dated. None when the project has no git history.
150
+
151
+ The spec is compared as it stood at the commit that last dated the page,
152
+ or, for a spec written after that, as it was first committed: writing a
153
+ spec for a post already published describes it, and only a change after
154
+ that revises it. The comparison is of claims, not bytes (`_claims`), and
155
+ of the working file, so an uncommitted revision is named before it is
156
+ committed and a page being redated in the same change is not.
157
+ """
158
+ project = current_project()
159
+ root = project.root
160
+ if _git(root, "rev-parse", "--verify", "--quiet", "HEAD").returncode:
161
+ return None
162
+ field = revision_field()
163
+ dated = rf"^(date|{re.escape(field)})[[:space:]]*[:=]"
164
+ redating = re.compile(rf"(?m)^[+-](date|{re.escape(field)})\s*[:=]")
165
+ out = []
166
+ for u in published():
167
+ spec = notes.unit_spec_path(u.slug)
168
+ if spec is None or not spec.is_file():
169
+ continue
170
+ spec_rel = spec.relative_to(root).as_posix()
171
+ if redating.search(_git(root, "diff", "HEAD", "-U0", "--", u.rel).stdout):
172
+ continue # being redated now
173
+ last_dated = _git(
174
+ root, "log", "-1", "--format=%H", "-G", dated, "--", u.rel
175
+ ).stdout.strip()
176
+ if not last_dated:
177
+ continue # the page has no dated history yet
178
+ then = _git(root, "show", f"{last_dated}:./{spec_rel}")
179
+ if then.returncode:
180
+ first = _git(
181
+ root, "log", "--diff-filter=A", "--format=%H", "--", spec_rel
182
+ ).stdout.split()
183
+ if not first:
184
+ continue # being written, not revised
185
+ then = _git(root, "show", f"{first[-1]}:./{spec_rel}")
186
+ if _claims(then.stdout) != _claims(spec.read_text(encoding="utf-8")):
187
+ out.append(
188
+ f" {u.rel}: {spec_rel} changed after the {units.noun()} was last "
189
+ f"dated; revise it, set {field}, and tell a reader what changed"
190
+ )
191
+ return out