git-worklog 1.6.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.
- git_worklog/__init__.py +58 -0
- git_worklog/__main__.py +10 -0
- git_worklog/analysis/__init__.py +48 -0
- git_worklog/analysis/commits.py +118 -0
- git_worklog/analysis/coverage.py +149 -0
- git_worklog/analysis/history.py +339 -0
- git_worklog/analysis/manifest.py +432 -0
- git_worklog/analysis/reconcile.py +211 -0
- git_worklog/analysis/refs.py +178 -0
- git_worklog/analysis/results.py +871 -0
- git_worklog/analysis/worktree.py +114 -0
- git_worklog/cli/__init__.py +414 -0
- git_worklog/cli/analyze.py +340 -0
- git_worklog/cli/apply.py +61 -0
- git_worklog/cli/coverage.py +62 -0
- git_worklog/cli/doctor.py +348 -0
- git_worklog/cli/migrate.py +43 -0
- git_worklog/cli/models.py +112 -0
- git_worklog/cli/preview.py +124 -0
- git_worklog/cli/refs.py +55 -0
- git_worklog/cli/reindex.py +72 -0
- git_worklog/cli/report.py +227 -0
- git_worklog/cli/validate.py +252 -0
- git_worklog/cli/version.py +47 -0
- git_worklog/cli/view.py +218 -0
- git_worklog/config.py +79 -0
- git_worklog/data/provider_models.json +34 -0
- git_worklog/dates.py +237 -0
- git_worklog/language.py +295 -0
- git_worklog/markers.py +765 -0
- git_worklog/mdhtml.py +266 -0
- git_worklog/migrate.py +305 -0
- git_worklog/paths.py +85 -0
- git_worklog/preview.py +776 -0
- git_worklog/providers.py +337 -0
- git_worklog/viewer.py +1198 -0
- git_worklog/viewserver.py +120 -0
- git_worklog/writer.py +420 -0
- git_worklog-1.6.0.dist-info/METADATA +352 -0
- git_worklog-1.6.0.dist-info/RECORD +44 -0
- git_worklog-1.6.0.dist-info/WHEEL +5 -0
- git_worklog-1.6.0.dist-info/entry_points.txt +2 -0
- git_worklog-1.6.0.dist-info/licenses/LICENSE +21 -0
- git_worklog-1.6.0.dist-info/top_level.txt +1 -0
git_worklog/markers.py
ADDED
|
@@ -0,0 +1,765 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Shared parser / serialiser for the Git Worklog directory-based worklog.
|
|
3
|
+
|
|
4
|
+
The worklog is no longer a single growing file. It is a directory:
|
|
5
|
+
|
|
6
|
+
.git-worklog/
|
|
7
|
+
├── VERSION
|
|
8
|
+
├── config.json
|
|
9
|
+
├── index.md
|
|
10
|
+
└── days/
|
|
11
|
+
├── 2026-07-15.md
|
|
12
|
+
├── 2026-07-14.md
|
|
13
|
+
└── ...
|
|
14
|
+
|
|
15
|
+
Two on-disk shapes are managed here, each with stable HTML-comment markers that
|
|
16
|
+
use the prefix ``GIT_WORKLOG``.
|
|
17
|
+
|
|
18
|
+
**A per-day file** (``.git-worklog/days/<date>.md``) holds exactly one day. Its
|
|
19
|
+
title and meta blockquote are tool-owned (regenerated on every write); only the
|
|
20
|
+
MANUAL region is human-owned and preserved byte-for-byte::
|
|
21
|
+
|
|
22
|
+
# Project Worklog — 2026-07-15
|
|
23
|
+
|
|
24
|
+
> 時區:Asia/Taipei
|
|
25
|
+
> Branch:main
|
|
26
|
+
> HEAD:abc1234
|
|
27
|
+
|
|
28
|
+
<!-- GIT_WORKLOG:2026-07-15:GENERATED:START -->
|
|
29
|
+
...auto-generated...
|
|
30
|
+
<!-- GIT_WORKLOG:2026-07-15:GENERATED:END -->
|
|
31
|
+
|
|
32
|
+
<!-- GIT_WORKLOG:2026-07-15:MANUAL:START -->
|
|
33
|
+
...human notes...
|
|
34
|
+
<!-- GIT_WORKLOG:2026-07-15:MANUAL:END -->
|
|
35
|
+
|
|
36
|
+
**The index** (``.git-worklog/index.md``) is navigation only. Its GENERATED
|
|
37
|
+
region is a date-descending table rebuilt from the day files; its MANUAL region
|
|
38
|
+
is preserved verbatim::
|
|
39
|
+
|
|
40
|
+
<!-- GIT_WORKLOG:INDEX:GENERATED:START -->
|
|
41
|
+
| 日期 | 摘要 |
|
|
42
|
+
|---|---|
|
|
43
|
+
| [2026-07-15](./days/2026-07-15.md) | ... |
|
|
44
|
+
<!-- GIT_WORKLOG:INDEX:GENERATED:END -->
|
|
45
|
+
|
|
46
|
+
<!-- GIT_WORKLOG:INDEX:MANUAL:START -->
|
|
47
|
+
...human notes...
|
|
48
|
+
<!-- GIT_WORKLOG:INDEX:MANUAL:END -->
|
|
49
|
+
|
|
50
|
+
**Legacy compatibility.** Before v0.6 the directory was ``PROJECT_WORKLOG/``,
|
|
51
|
+
day files sat at its root rather than under ``days/``, and markers used the
|
|
52
|
+
``REPO_WORKLOG`` prefix. Both marker prefixes parse; only ``GIT_WORKLOG`` is
|
|
53
|
+
ever written. ``detect_layout()`` recognises the flat legacy shape so a
|
|
54
|
+
not-yet-migrated worklog stays readable — writing to one is refused, and
|
|
55
|
+
migrate_legacy_worklog.py converts it.
|
|
56
|
+
|
|
57
|
+
``git_worklog.writer``, the validators and the scripts over them all build on
|
|
58
|
+
this module so they agree on exactly one definition of the format.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
from __future__ import annotations
|
|
62
|
+
|
|
63
|
+
import json
|
|
64
|
+
import os
|
|
65
|
+
import re
|
|
66
|
+
from dataclasses import dataclass
|
|
67
|
+
|
|
68
|
+
PREFIX = "GIT_WORKLOG"
|
|
69
|
+
# Markers written before v0.6. Parsed, never written. Migration rewrites them.
|
|
70
|
+
LEGACY_PREFIX = "REPO_WORKLOG"
|
|
71
|
+
|
|
72
|
+
WORKLOG_DIRNAME = ".git-worklog"
|
|
73
|
+
LEGACY_WORKLOG_DIRNAME = "PROJECT_WORKLOG"
|
|
74
|
+
DAYS_SUBDIR = "days"
|
|
75
|
+
INDEX_FILENAME = "index.md"
|
|
76
|
+
CONFIG_FILENAME = "config.json"
|
|
77
|
+
VERSION_FILENAME = "VERSION"
|
|
78
|
+
|
|
79
|
+
# On-disk layout version, written to .git-worklog/VERSION. Bump only when the
|
|
80
|
+
# directory shape changes in a way that needs another migration.
|
|
81
|
+
LAYOUT_VERSION = 1
|
|
82
|
+
|
|
83
|
+
# --- regexes -----------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
|
86
|
+
DATE_FILE_RE = re.compile(r"^(\d{4}-\d{2}-\d{2})\.md$")
|
|
87
|
+
|
|
88
|
+
_ANY_PREFIX = f"(?:{PREFIX}|{LEGACY_PREFIX})"
|
|
89
|
+
_DAY_MARKER_RE = re.compile(
|
|
90
|
+
rf"^<!--\s*{_ANY_PREFIX}:(\d{{4}}-\d{{2}}-\d{{2}}):(GENERATED|MANUAL):(START|END)\s*-->$"
|
|
91
|
+
)
|
|
92
|
+
# The optional lang= attribute records the language the index was first built
|
|
93
|
+
# in (§6.2.12). It is optional because every index.md written before the
|
|
94
|
+
# language contract lacks it and must keep parsing: an index that suddenly fails
|
|
95
|
+
# to parse would read as INDEX_MISSING_GENERATED, i.e. a corrupt file, over a
|
|
96
|
+
# purely additive change.
|
|
97
|
+
_INDEX_MARKER_RE = re.compile(
|
|
98
|
+
rf"^<!--\s*{_ANY_PREFIX}:INDEX:(GENERATED|MANUAL):(START|END)"
|
|
99
|
+
rf"(?:\s+lang=([A-Za-z0-9-]+))?\s*-->$"
|
|
100
|
+
)
|
|
101
|
+
# Accept an em dash or a plain hyphen between the title and the date.
|
|
102
|
+
_DAY_TITLE_RE = re.compile(r"^#\s+Project Worklog\s+[—–-]\s+(\d{4}-\d{2}-\d{2})\s*$")
|
|
103
|
+
|
|
104
|
+
# Brackets the one-line summary inside a day's GENERATED region, so the index
|
|
105
|
+
# can find it without knowing what language the day was written in.
|
|
106
|
+
_SUMMARY_MARKER_RE = re.compile(
|
|
107
|
+
rf"^<!--\s*{_ANY_PREFIX}:SUMMARY:(START|END)\s*-->$"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
# How the summary was found before the marker existed: by its zh-TW heading.
|
|
111
|
+
# Day files written by earlier versions still carry that heading and no marker,
|
|
112
|
+
# and are never rewritten just to gain one — so this stays as the fallback for
|
|
113
|
+
# them. It is not a language rule; it is an artefact of the days when zh-TW was
|
|
114
|
+
# the only language the tool could produce.
|
|
115
|
+
_SUMMARY_HEADING_RE = re.compile(r"^#{1,6}\s*當日摘要\s*$")
|
|
116
|
+
_INDEX_ROW_RE = re.compile(r"^\|\s*\[(\d{4}-\d{2}-\d{2})\]\([^)]*\)\s*\|(.*)\|\s*$")
|
|
117
|
+
|
|
118
|
+
# Fatal validation codes shared by both validators.
|
|
119
|
+
FATAL_CODES = {
|
|
120
|
+
"MISSING_GENERATED", "MISSING_MANUAL", "DUPLICATE_GENERATED",
|
|
121
|
+
"DUPLICATE_MANUAL", "GENERATED_UNCLOSED", "MANUAL_UNCLOSED",
|
|
122
|
+
"ORDER_MANUAL_BEFORE_GENERATED", "MARKER_DATE_MISMATCH",
|
|
123
|
+
"TITLE_MISSING", "TITLE_DATE_MISMATCH", "STRAY_DATE_MARKER",
|
|
124
|
+
"INDEX_MISSING_GENERATED", "INDEX_MISSING_MANUAL",
|
|
125
|
+
"INDEX_DUPLICATE_GENERATED", "INDEX_DUPLICATE_MANUAL",
|
|
126
|
+
"INDEX_GENERATED_UNCLOSED", "INDEX_MANUAL_UNCLOSED",
|
|
127
|
+
"INDEX_ORDER", "INDEX_DUPLICATE_DATE",
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
# Length cap for an index summary cell so the table stays skimmable.
|
|
131
|
+
SUMMARY_MAX_CHARS = 80
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class WorklogFormatError(Exception):
|
|
135
|
+
def __init__(self, issues: list[dict]):
|
|
136
|
+
self.issues = issues
|
|
137
|
+
super().__init__("; ".join(i["message"] for i in issues))
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# --- day-file model ----------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@dataclass
|
|
144
|
+
class DayFile:
|
|
145
|
+
date: str
|
|
146
|
+
generated: str # inner text between GENERATED markers (verbatim)
|
|
147
|
+
manual: str # inner text between MANUAL markers (verbatim)
|
|
148
|
+
title_date: str | None = None
|
|
149
|
+
trailing: str = "" # any content after MANUAL:END (preserved on overwrite)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def is_valid_date(value: str) -> bool:
|
|
153
|
+
return bool(DATE_RE.match(value))
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def contains_marker_line(text: str) -> bool:
|
|
157
|
+
"""True if any line is a day/index marker, in either prefix.
|
|
158
|
+
|
|
159
|
+
Generated content that carries such a line would corrupt the file's
|
|
160
|
+
structure (parsing is line-based), so callers reject it up front rather
|
|
161
|
+
than emit a file that fails to re-parse. The legacy prefix counts too: it
|
|
162
|
+
still parses, so it can still corrupt.
|
|
163
|
+
|
|
164
|
+
SUMMARY markers are deliberately absent from this check: they nest *inside*
|
|
165
|
+
a GENERATED region rather than delimiting one, so they cannot corrupt it,
|
|
166
|
+
and generated content is required to carry them. Adding them here would
|
|
167
|
+
reject every day the contract asks for.
|
|
168
|
+
"""
|
|
169
|
+
for line in text.splitlines():
|
|
170
|
+
s = line.strip()
|
|
171
|
+
if _DAY_MARKER_RE.match(s) or _INDEX_MARKER_RE.match(s):
|
|
172
|
+
return True
|
|
173
|
+
return False
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def parse_date_filename(name: str) -> str | None:
|
|
177
|
+
"""Return the ISO date encoded by ``<date>.md`` or None (index/other names)."""
|
|
178
|
+
m = DATE_FILE_RE.match(name)
|
|
179
|
+
return m.group(1) if m else None
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
_LEGACY_DAY_MARKER_RE = re.compile(
|
|
183
|
+
rf"^(\s*)<!--\s*{LEGACY_PREFIX}:(\d{{4}}-\d{{2}}-\d{{2}}):(GENERATED|MANUAL):(START|END)\s*-->\s*$"
|
|
184
|
+
)
|
|
185
|
+
_LEGACY_INDEX_MARKER_RE = re.compile(
|
|
186
|
+
rf"^(\s*)<!--\s*{LEGACY_PREFIX}:INDEX:(GENERATED|MANUAL):(START|END)\s*-->\s*$"
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def retag_markers(text: str) -> tuple[str, int]:
|
|
191
|
+
"""Rewrite legacy-prefix marker lines to the current prefix.
|
|
192
|
+
|
|
193
|
+
Only whole lines that parse as markers are touched, so prose that merely
|
|
194
|
+
mentions the old prefix is left alone. Everything else -- the title, the
|
|
195
|
+
meta blockquote, GENERATED prose, MANUAL notes -- is preserved byte for
|
|
196
|
+
byte: migration moves a worklog, it does not rewrite one.
|
|
197
|
+
|
|
198
|
+
Returns ``(new_text, markers_rewritten)``.
|
|
199
|
+
"""
|
|
200
|
+
out: list[str] = []
|
|
201
|
+
count = 0
|
|
202
|
+
for line in text.splitlines(keepends=True):
|
|
203
|
+
body = line.rstrip("\n\r")
|
|
204
|
+
eol = line[len(body):]
|
|
205
|
+
dm = _LEGACY_DAY_MARKER_RE.match(body)
|
|
206
|
+
if dm:
|
|
207
|
+
indent, date, region, edge = dm.groups()
|
|
208
|
+
out.append(f"{indent}<!-- {PREFIX}:{date}:{region}:{edge} -->{eol}")
|
|
209
|
+
count += 1
|
|
210
|
+
continue
|
|
211
|
+
im = _LEGACY_INDEX_MARKER_RE.match(body)
|
|
212
|
+
if im:
|
|
213
|
+
indent, region, edge = im.groups()
|
|
214
|
+
out.append(f"{indent}<!-- {PREFIX}:INDEX:{region}:{edge} -->{eol}")
|
|
215
|
+
count += 1
|
|
216
|
+
continue
|
|
217
|
+
out.append(line)
|
|
218
|
+
return "".join(out), count
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
# --- paths & layout ----------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
# Where a worklog directory's day files live.
|
|
224
|
+
LAYOUT_CURRENT = "current" # <dir>/days/<date>.md
|
|
225
|
+
LAYOUT_LEGACY = "legacy" # <dir>/<date>.md (pre-v0.6, read-only)
|
|
226
|
+
LAYOUT_EMPTY = "empty" # no day files yet; treated as current
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def detect_layout(worklog_dir: str) -> str:
|
|
230
|
+
"""Classify a worklog directory by where its day files actually sit.
|
|
231
|
+
|
|
232
|
+
Probes the directory rather than trusting its name, so a legacy worklog
|
|
233
|
+
stays readable wherever it lives and a caller that passes an explicit
|
|
234
|
+
``--dir`` is not second-guessed. ``days/`` wins if both shapes are present:
|
|
235
|
+
a migration that was interrupted leaves the legacy files behind, and the
|
|
236
|
+
migrated copies are the ones to trust.
|
|
237
|
+
"""
|
|
238
|
+
if os.path.isdir(os.path.join(worklog_dir, DAYS_SUBDIR)):
|
|
239
|
+
return LAYOUT_CURRENT
|
|
240
|
+
try:
|
|
241
|
+
names = os.listdir(worklog_dir)
|
|
242
|
+
except OSError:
|
|
243
|
+
return LAYOUT_EMPTY
|
|
244
|
+
if any(parse_date_filename(n) for n in names):
|
|
245
|
+
return LAYOUT_LEGACY
|
|
246
|
+
return LAYOUT_EMPTY
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def days_dir(worklog_dir: str, layout: str | None = None) -> str:
|
|
250
|
+
"""Directory holding the day files, for the given (or detected) layout."""
|
|
251
|
+
if layout is None:
|
|
252
|
+
layout = detect_layout(worklog_dir)
|
|
253
|
+
if layout == LAYOUT_LEGACY:
|
|
254
|
+
return worklog_dir
|
|
255
|
+
return os.path.join(worklog_dir, DAYS_SUBDIR)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def day_path(worklog_dir: str, date: str, layout: str | None = None) -> str:
|
|
259
|
+
return os.path.join(days_dir(worklog_dir, layout), f"{date}.md")
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def index_path(worklog_dir: str) -> str:
|
|
263
|
+
"""The index sits at the worklog root in both layouts."""
|
|
264
|
+
return os.path.join(worklog_dir, INDEX_FILENAME)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def config_path(worklog_dir: str) -> str:
|
|
268
|
+
return os.path.join(worklog_dir, CONFIG_FILENAME)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def version_path(worklog_dir: str) -> str:
|
|
272
|
+
return os.path.join(worklog_dir, VERSION_FILENAME)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def day_link(date: str, layout: str | None = None) -> str:
|
|
276
|
+
"""The index's relative link to a day file."""
|
|
277
|
+
if layout == LAYOUT_LEGACY:
|
|
278
|
+
return f"./{date}.md"
|
|
279
|
+
return f"./{DAYS_SUBDIR}/{date}.md"
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def render_config(timezone: str | None = None) -> str:
|
|
283
|
+
"""The initial ``config.json`` body (roadmap §4.4).
|
|
284
|
+
|
|
285
|
+
``language`` and ``index_language`` default to ``auto``, meaning "nobody has
|
|
286
|
+
decided". Since this file is never rewritten once it exists, that default is
|
|
287
|
+
also what every worklog created before the language contract carries -- so
|
|
288
|
+
``auto`` on disk is indistinguishable from a file the user never opened, and
|
|
289
|
+
git_worklog.config reads it as exactly that rather than as a choice.
|
|
290
|
+
"""
|
|
291
|
+
config = {
|
|
292
|
+
"schema_version": LAYOUT_VERSION,
|
|
293
|
+
"timezone": timezone or "auto",
|
|
294
|
+
"language": "auto",
|
|
295
|
+
"index_language": "auto",
|
|
296
|
+
"authors": [],
|
|
297
|
+
"ignore": [],
|
|
298
|
+
}
|
|
299
|
+
return json.dumps(config, ensure_ascii=False, indent=2) + "\n"
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def ensure_data_dir(worklog_dir: str, timezone: str | None = None) -> list[str]:
|
|
303
|
+
"""Create VERSION and config.json if absent. Returns the paths created.
|
|
304
|
+
|
|
305
|
+
Never rewrites either file: both are user-editable, and a worklog that
|
|
306
|
+
already declares its layout is not ours to redeclare.
|
|
307
|
+
"""
|
|
308
|
+
created: list[str] = []
|
|
309
|
+
os.makedirs(worklog_dir, exist_ok=True)
|
|
310
|
+
vp = version_path(worklog_dir)
|
|
311
|
+
if not os.path.exists(vp):
|
|
312
|
+
with open(vp, "w", encoding="utf-8") as fh:
|
|
313
|
+
fh.write(f"{LAYOUT_VERSION}\n")
|
|
314
|
+
created.append(vp)
|
|
315
|
+
cp = config_path(worklog_dir)
|
|
316
|
+
if not os.path.exists(cp):
|
|
317
|
+
with open(cp, "w", encoding="utf-8") as fh:
|
|
318
|
+
fh.write(render_config(timezone))
|
|
319
|
+
created.append(cp)
|
|
320
|
+
return created
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def list_day_dates(worklog_dir: str, layout: str | None = None) -> list[str]:
|
|
324
|
+
"""Every date with a day file, ascending. Empty when the directory is absent."""
|
|
325
|
+
if layout is None:
|
|
326
|
+
layout = detect_layout(worklog_dir)
|
|
327
|
+
d = days_dir(worklog_dir, layout)
|
|
328
|
+
try:
|
|
329
|
+
names = os.listdir(d)
|
|
330
|
+
except OSError:
|
|
331
|
+
return []
|
|
332
|
+
return sorted(x for x in (parse_date_filename(n) for n in names) if x)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def scan_day(text: str, date: str) -> tuple[DayFile | None, list[dict]]:
|
|
336
|
+
"""Parse a single day file, collecting every structural issue.
|
|
337
|
+
|
|
338
|
+
``date`` is the date the file claims (its filename). Markers for any other
|
|
339
|
+
date are reported as ``MARKER_DATE_MISMATCH``. Returns ``(day, issues)``;
|
|
340
|
+
``day`` is None when the GENERATED/MANUAL regions cannot be located.
|
|
341
|
+
"""
|
|
342
|
+
issues: list[dict] = []
|
|
343
|
+
lines = text.splitlines(keepends=True)
|
|
344
|
+
|
|
345
|
+
title_date = None
|
|
346
|
+
gen_s = gen_e = man_s = man_e = None
|
|
347
|
+
dup_gen = dup_man = False
|
|
348
|
+
|
|
349
|
+
for idx, raw in enumerate(lines):
|
|
350
|
+
s = raw.strip()
|
|
351
|
+
tm = _DAY_TITLE_RE.match(s)
|
|
352
|
+
if tm and title_date is None:
|
|
353
|
+
title_date = tm.group(1)
|
|
354
|
+
continue
|
|
355
|
+
m = _DAY_MARKER_RE.match(s)
|
|
356
|
+
if not m:
|
|
357
|
+
continue
|
|
358
|
+
mdate, region, edge = m.group(1), m.group(2), m.group(3)
|
|
359
|
+
if mdate != date:
|
|
360
|
+
issues.append({"code": "MARKER_DATE_MISMATCH", "line": idx + 1,
|
|
361
|
+
"message": f"Marker date {mdate} does not match file date {date}."})
|
|
362
|
+
continue
|
|
363
|
+
if region == "GENERATED" and edge == "START":
|
|
364
|
+
if gen_s is not None:
|
|
365
|
+
dup_gen = True
|
|
366
|
+
else:
|
|
367
|
+
gen_s = idx
|
|
368
|
+
elif region == "GENERATED" and edge == "END":
|
|
369
|
+
if gen_e is not None:
|
|
370
|
+
dup_gen = True # keep the first END; a second is a duplicate
|
|
371
|
+
else:
|
|
372
|
+
gen_e = idx
|
|
373
|
+
elif region == "MANUAL" and edge == "START":
|
|
374
|
+
if man_s is not None:
|
|
375
|
+
dup_man = True
|
|
376
|
+
else:
|
|
377
|
+
man_s = idx
|
|
378
|
+
elif region == "MANUAL" and edge == "END":
|
|
379
|
+
if man_e is not None:
|
|
380
|
+
dup_man = True
|
|
381
|
+
else:
|
|
382
|
+
man_e = idx
|
|
383
|
+
|
|
384
|
+
if title_date is None:
|
|
385
|
+
issues.append({"code": "TITLE_MISSING", "line": None,
|
|
386
|
+
"message": "Missing '# Project Worklog — <date>' title line."})
|
|
387
|
+
elif title_date != date:
|
|
388
|
+
issues.append({"code": "TITLE_DATE_MISMATCH", "line": None,
|
|
389
|
+
"message": f"Title date {title_date} does not match file date {date}."})
|
|
390
|
+
|
|
391
|
+
if dup_gen:
|
|
392
|
+
issues.append({"code": "DUPLICATE_GENERATED", "line": None,
|
|
393
|
+
"message": f"Duplicate GENERATED region for {date}."})
|
|
394
|
+
if dup_man:
|
|
395
|
+
issues.append({"code": "DUPLICATE_MANUAL", "line": None,
|
|
396
|
+
"message": f"Duplicate MANUAL region for {date}."})
|
|
397
|
+
if gen_s is None:
|
|
398
|
+
issues.append({"code": "MISSING_GENERATED", "line": None,
|
|
399
|
+
"message": f"Missing GENERATED:START for {date}."})
|
|
400
|
+
if gen_s is not None and gen_e is None:
|
|
401
|
+
issues.append({"code": "GENERATED_UNCLOSED", "line": None,
|
|
402
|
+
"message": f"GENERATED region for {date} is never closed."})
|
|
403
|
+
if man_s is None:
|
|
404
|
+
issues.append({"code": "MISSING_MANUAL", "line": None,
|
|
405
|
+
"message": f"Missing MANUAL:START for {date}."})
|
|
406
|
+
if man_s is not None and man_e is None:
|
|
407
|
+
issues.append({"code": "MANUAL_UNCLOSED", "line": None,
|
|
408
|
+
"message": f"MANUAL region for {date} is never closed."})
|
|
409
|
+
if (gen_s is not None and man_s is not None and man_s < gen_s):
|
|
410
|
+
issues.append({"code": "ORDER_MANUAL_BEFORE_GENERATED", "line": None,
|
|
411
|
+
"message": f"MANUAL region precedes GENERATED region for {date}."})
|
|
412
|
+
|
|
413
|
+
if None in (gen_s, gen_e, man_s, man_e):
|
|
414
|
+
return None, issues
|
|
415
|
+
|
|
416
|
+
generated = "".join(lines[gen_s + 1:gen_e])
|
|
417
|
+
manual = "".join(lines[man_s + 1:man_e])
|
|
418
|
+
trailing = "".join(lines[man_e + 1:])
|
|
419
|
+
return DayFile(date, generated, manual, title_date, trailing), issues
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def parse_day(text: str, date: str) -> DayFile:
|
|
423
|
+
"""Parse a day file strictly: raise on any fatal structural issue."""
|
|
424
|
+
day, issues = scan_day(text, date)
|
|
425
|
+
fatal = [i for i in issues if i["code"] in FATAL_CODES]
|
|
426
|
+
if fatal or day is None:
|
|
427
|
+
raise WorklogFormatError(fatal or issues)
|
|
428
|
+
return day
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _meta_block(timezone: str | None, branch: str | None, head: str | None) -> str:
|
|
432
|
+
meta: list[str] = []
|
|
433
|
+
if timezone:
|
|
434
|
+
meta.append(f"> 時區:{timezone}")
|
|
435
|
+
if branch:
|
|
436
|
+
meta.append(f"> Branch:{branch}")
|
|
437
|
+
if head:
|
|
438
|
+
meta.append(f"> HEAD:{head}")
|
|
439
|
+
return ("\n" + "\n".join(meta) + "\n") if meta else ""
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _assemble_day(date: str, generated_md: str, manual_inner: str, *,
|
|
443
|
+
timezone: str | None, branch: str | None, head: str | None,
|
|
444
|
+
trailing: str = "") -> str:
|
|
445
|
+
gen_body = generated_md.strip("\n")
|
|
446
|
+
parts = [
|
|
447
|
+
f"# Project Worklog — {date}\n",
|
|
448
|
+
_meta_block(timezone, branch, head),
|
|
449
|
+
"\n",
|
|
450
|
+
f"<!-- {PREFIX}:{date}:GENERATED:START -->\n",
|
|
451
|
+
(gen_body + "\n") if gen_body else "",
|
|
452
|
+
f"<!-- {PREFIX}:{date}:GENERATED:END -->\n",
|
|
453
|
+
"\n",
|
|
454
|
+
f"<!-- {PREFIX}:{date}:MANUAL:START -->\n",
|
|
455
|
+
manual_inner,
|
|
456
|
+
f"<!-- {PREFIX}:{date}:MANUAL:END -->\n",
|
|
457
|
+
trailing,
|
|
458
|
+
]
|
|
459
|
+
return "".join(parts)
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def build_day_file(date: str, generated_md: str, manual_inner: str = "\n", *,
|
|
463
|
+
timezone: str | None = None, branch: str | None = None,
|
|
464
|
+
head: str | None = None) -> str:
|
|
465
|
+
"""Build a day file with an explicit MANUAL inner block (used by migration)."""
|
|
466
|
+
inner = manual_inner or "\n"
|
|
467
|
+
if not inner.endswith("\n"):
|
|
468
|
+
inner += "\n"
|
|
469
|
+
return _assemble_day(date, generated_md, inner,
|
|
470
|
+
timezone=timezone, branch=branch, head=head)
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def render_new_day_file(date: str, generated_md: str, *, timezone: str | None = None,
|
|
474
|
+
branch: str | None = None, head: str | None = None) -> str:
|
|
475
|
+
"""Build a brand-new day file with an empty MANUAL region."""
|
|
476
|
+
return _assemble_day(date, generated_md, "\n",
|
|
477
|
+
timezone=timezone, branch=branch, head=head)
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def overwrite_day_generated(text: str, date: str, generated_md: str, *,
|
|
481
|
+
timezone: str | None = None, branch: str | None = None,
|
|
482
|
+
head: str | None = None) -> str:
|
|
483
|
+
"""Return the day file with header + GENERATED refreshed, MANUAL preserved.
|
|
484
|
+
|
|
485
|
+
The MANUAL inner text is copied byte-for-byte from ``text``; the title and
|
|
486
|
+
meta blockquote are regenerated to reflect the current analysis run.
|
|
487
|
+
"""
|
|
488
|
+
day = parse_day(text, date)
|
|
489
|
+
return _assemble_day(date, generated_md, day.manual,
|
|
490
|
+
timezone=timezone, branch=branch, head=head,
|
|
491
|
+
trailing=day.trailing)
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
# --- index model -------------------------------------------------------------
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
# The index's own furniture: everything on the page that is not a day's summary.
|
|
498
|
+
# A catalog is safe here in a way it would not be for day files, because this
|
|
499
|
+
# text is rendered by this function -- there is no LLM in the loop to reproduce
|
|
500
|
+
# it approximately, so a heading cannot drift and silently stop matching.
|
|
501
|
+
#
|
|
502
|
+
# Only the languages this project can actually vouch for are listed. An index
|
|
503
|
+
# resolved to any other language gets English furniture around day summaries
|
|
504
|
+
# written in that language, which is honest; inventing translations nobody here
|
|
505
|
+
# can proofread would not be.
|
|
506
|
+
_INDEX_CHROME = {
|
|
507
|
+
"zh-TW": {
|
|
508
|
+
"title": "Project Worklog",
|
|
509
|
+
"intro": ("> 本目錄依據 Git commit、實際程式碼 diff 與相關程式碼上下文產生。\n"
|
|
510
|
+
"> 用於專案維護、交接與異動追蹤。\n"
|
|
511
|
+
"> 日期依執行環境的本地時區判定。"),
|
|
512
|
+
"section": "工作日誌",
|
|
513
|
+
"col_date": "日期",
|
|
514
|
+
"col_summary": "摘要",
|
|
515
|
+
"manual_section": "人工說明",
|
|
516
|
+
"manual_default": "可在此補充專案工作日誌的閱讀方式、重要里程碑或交接說明。\n",
|
|
517
|
+
},
|
|
518
|
+
"en": {
|
|
519
|
+
"title": "Project Worklog",
|
|
520
|
+
"intro": ("> Generated from Git commits, the actual code diffs and the\n"
|
|
521
|
+
"> surrounding code context. Used for maintenance, handover and\n"
|
|
522
|
+
"> change tracking. Dates follow the local timezone of the run."),
|
|
523
|
+
"section": "Worklog",
|
|
524
|
+
"col_date": "Date",
|
|
525
|
+
"col_summary": "Summary",
|
|
526
|
+
"manual_section": "Notes",
|
|
527
|
+
"manual_default": ("Add anything worth knowing about how to read this "
|
|
528
|
+
"worklog: milestones, handover notes, context.\n"),
|
|
529
|
+
},
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
INDEX_CHROME_LANGUAGES = tuple(_INDEX_CHROME)
|
|
533
|
+
DEFAULT_INDEX_LANGUAGE = "zh-TW"
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def index_chrome(language: "str | None") -> dict:
|
|
537
|
+
"""The index's furniture for ``language``, falling back to English."""
|
|
538
|
+
if language in _INDEX_CHROME:
|
|
539
|
+
return _INDEX_CHROME[language]
|
|
540
|
+
return _INDEX_CHROME["en"]
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
# Kept as a module constant because callers outside this module reach for it.
|
|
544
|
+
DEFAULT_INDEX_MANUAL = _INDEX_CHROME[DEFAULT_INDEX_LANGUAGE]["manual_default"]
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
@dataclass
|
|
548
|
+
class IndexDoc:
|
|
549
|
+
rows: list[tuple[str, str]] # (date, summary), order as found
|
|
550
|
+
manual: str # inner text between INDEX:MANUAL markers (verbatim)
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def summarise_generated(generated_md: str) -> str:
|
|
554
|
+
"""Derive the one-line index summary from a day's GENERATED region.
|
|
555
|
+
|
|
556
|
+
Prefers the SUMMARY marker, which says where the summary is without saying
|
|
557
|
+
what language it is in. That matters because the index is built by scanning
|
|
558
|
+
every day file, and days may be written in different languages: keying off a
|
|
559
|
+
heading's text would silently yield an empty summary for any day not written
|
|
560
|
+
in the language the reader happened to hardcode.
|
|
561
|
+
|
|
562
|
+
Falls back to the zh-TW 當日摘要 heading for day files written before the
|
|
563
|
+
marker existed. Those are never rewritten just to gain one, so the fallback
|
|
564
|
+
is permanent, not a migration window.
|
|
565
|
+
|
|
566
|
+
Returns a single line, table-escaped and length-capped; empty string when
|
|
567
|
+
there is no summary to find.
|
|
568
|
+
"""
|
|
569
|
+
marked = _between_summary_markers(generated_md)
|
|
570
|
+
if marked is not None:
|
|
571
|
+
return clean_summary(marked)
|
|
572
|
+
|
|
573
|
+
in_summary = False
|
|
574
|
+
for raw in generated_md.splitlines():
|
|
575
|
+
s = raw.strip()
|
|
576
|
+
if _SUMMARY_HEADING_RE.match(s):
|
|
577
|
+
in_summary = True
|
|
578
|
+
continue
|
|
579
|
+
if not in_summary:
|
|
580
|
+
continue
|
|
581
|
+
if not s or _SUMMARY_MARKER_RE.match(s):
|
|
582
|
+
continue # a marker line is structure, not the summary's text
|
|
583
|
+
if s.startswith("#"):
|
|
584
|
+
break # next section began before any summary text
|
|
585
|
+
return clean_summary(s)
|
|
586
|
+
return ""
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def _between_summary_markers(generated_md: str) -> "str | None":
|
|
590
|
+
"""The first non-empty line between SUMMARY markers, or None if unmarked.
|
|
591
|
+
|
|
592
|
+
An unclosed or empty marker pair returns None rather than an empty summary,
|
|
593
|
+
so the caller falls back to the heading scan instead of writing a blank row
|
|
594
|
+
for a day that does have a summary.
|
|
595
|
+
"""
|
|
596
|
+
collecting = False
|
|
597
|
+
for raw in generated_md.splitlines():
|
|
598
|
+
s = raw.strip()
|
|
599
|
+
m = _SUMMARY_MARKER_RE.match(s)
|
|
600
|
+
if m:
|
|
601
|
+
if m.group(1) == "END":
|
|
602
|
+
break # closed without content — treat as unmarked
|
|
603
|
+
collecting = True
|
|
604
|
+
continue
|
|
605
|
+
if collecting and s and not s.startswith("#"):
|
|
606
|
+
return s
|
|
607
|
+
return None
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def has_summary_marker(generated_md: str) -> bool:
|
|
611
|
+
"""True if the summary is bracketed rather than found by heading fallback.
|
|
612
|
+
|
|
613
|
+
The distinction matters: a zh-TW day without the marker reads correctly
|
|
614
|
+
today and loses its index summary the moment it is regenerated in another
|
|
615
|
+
language, so callers want to tell "works" apart from "works for now".
|
|
616
|
+
"""
|
|
617
|
+
return any(_SUMMARY_MARKER_RE.match(line.strip())
|
|
618
|
+
for line in generated_md.splitlines())
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def render_summary(summary: str) -> str:
|
|
622
|
+
"""The SUMMARY-marked block that a day's GENERATED region must carry."""
|
|
623
|
+
return (f"<!-- {PREFIX}:SUMMARY:START -->\n"
|
|
624
|
+
f"{summary.strip()}\n"
|
|
625
|
+
f"<!-- {PREFIX}:SUMMARY:END -->\n")
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def clean_summary(text: str) -> str:
|
|
629
|
+
collapsed = re.sub(r"\s+", " ", text).strip()
|
|
630
|
+
collapsed = collapsed.replace("|", "\\|")
|
|
631
|
+
if len(collapsed) > SUMMARY_MAX_CHARS:
|
|
632
|
+
collapsed = collapsed[:SUMMARY_MAX_CHARS - 1].rstrip() + "…"
|
|
633
|
+
return collapsed
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def render_index(rows: list[tuple[str, str]], manual_inner: str | None = None,
|
|
637
|
+
layout: str | None = None,
|
|
638
|
+
language: "str | None" = None) -> str:
|
|
639
|
+
"""Render index.md from ``rows`` (caller sorts them date-descending).
|
|
640
|
+
|
|
641
|
+
``language`` is stamped onto the GENERATED marker so the next rebuild uses
|
|
642
|
+
the same one (§6.2.12). Without that, an index rebuilt by a zh-TW agent on
|
|
643
|
+
Monday and an English one on Tuesday would flip its headings every run and
|
|
644
|
+
churn the diff of a file teams commit.
|
|
645
|
+
"""
|
|
646
|
+
if language is None:
|
|
647
|
+
language = DEFAULT_INDEX_LANGUAGE
|
|
648
|
+
chrome = index_chrome(language)
|
|
649
|
+
if manual_inner is None:
|
|
650
|
+
manual_inner = chrome["manual_default"]
|
|
651
|
+
table = [f"| {chrome['col_date']} | {chrome['col_summary']} |", "|---|---|"]
|
|
652
|
+
for date, summary in rows:
|
|
653
|
+
table.append(f"| [{date}]({day_link(date, layout)}) | {summary} |")
|
|
654
|
+
gen_body = "\n".join(table)
|
|
655
|
+
parts = [
|
|
656
|
+
f"# {chrome['title']}\n\n{chrome['intro']}\n\n## {chrome['section']}\n\n",
|
|
657
|
+
f"<!-- {PREFIX}:INDEX:GENERATED:START lang={language} -->\n",
|
|
658
|
+
gen_body + "\n",
|
|
659
|
+
f"<!-- {PREFIX}:INDEX:GENERATED:END -->\n",
|
|
660
|
+
f"\n## {chrome['manual_section']}\n\n",
|
|
661
|
+
f"<!-- {PREFIX}:INDEX:MANUAL:START -->\n",
|
|
662
|
+
manual_inner,
|
|
663
|
+
f"<!-- {PREFIX}:INDEX:MANUAL:END -->\n",
|
|
664
|
+
]
|
|
665
|
+
return "".join(parts)
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def index_language_of(text: str) -> "str | None":
|
|
669
|
+
"""The language an existing index.md was built in, or None if unstamped.
|
|
670
|
+
|
|
671
|
+
None means "written before the marker carried a language", not "English":
|
|
672
|
+
those indexes are zh-TW, which is why callers treat an unstamped index as
|
|
673
|
+
already fixed to the default rather than re-deciding it.
|
|
674
|
+
"""
|
|
675
|
+
for raw in text.splitlines():
|
|
676
|
+
m = _INDEX_MARKER_RE.match(raw.strip())
|
|
677
|
+
if m and m.group(1) == "GENERATED" and m.group(2) == "START":
|
|
678
|
+
return m.group(3)
|
|
679
|
+
return None
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def scan_index(text: str) -> tuple[IndexDoc | None, list[dict]]:
|
|
683
|
+
"""Parse index.md, collecting every structural issue."""
|
|
684
|
+
issues: list[dict] = []
|
|
685
|
+
lines = text.splitlines(keepends=True)
|
|
686
|
+
|
|
687
|
+
gen_s = gen_e = man_s = man_e = None
|
|
688
|
+
dup_gen = dup_man = False
|
|
689
|
+
for idx, raw in enumerate(lines):
|
|
690
|
+
m = _INDEX_MARKER_RE.match(raw.strip())
|
|
691
|
+
if not m:
|
|
692
|
+
continue
|
|
693
|
+
region, edge = m.group(1), m.group(2)
|
|
694
|
+
if region == "GENERATED" and edge == "START":
|
|
695
|
+
if gen_s is not None:
|
|
696
|
+
dup_gen = True
|
|
697
|
+
else:
|
|
698
|
+
gen_s = idx
|
|
699
|
+
elif region == "GENERATED" and edge == "END":
|
|
700
|
+
if gen_e is not None:
|
|
701
|
+
dup_gen = True # keep the first END; a second is a duplicate
|
|
702
|
+
else:
|
|
703
|
+
gen_e = idx
|
|
704
|
+
elif region == "MANUAL" and edge == "START":
|
|
705
|
+
if man_s is not None:
|
|
706
|
+
dup_man = True
|
|
707
|
+
else:
|
|
708
|
+
man_s = idx
|
|
709
|
+
elif region == "MANUAL" and edge == "END":
|
|
710
|
+
if man_e is not None:
|
|
711
|
+
dup_man = True
|
|
712
|
+
else:
|
|
713
|
+
man_e = idx
|
|
714
|
+
|
|
715
|
+
if dup_gen:
|
|
716
|
+
issues.append({"code": "INDEX_DUPLICATE_GENERATED", "line": None,
|
|
717
|
+
"message": "Duplicate INDEX GENERATED region."})
|
|
718
|
+
if dup_man:
|
|
719
|
+
issues.append({"code": "INDEX_DUPLICATE_MANUAL", "line": None,
|
|
720
|
+
"message": "Duplicate INDEX MANUAL region."})
|
|
721
|
+
if gen_s is None:
|
|
722
|
+
issues.append({"code": "INDEX_MISSING_GENERATED", "line": None,
|
|
723
|
+
"message": "Missing INDEX GENERATED:START."})
|
|
724
|
+
if gen_s is not None and gen_e is None:
|
|
725
|
+
issues.append({"code": "INDEX_GENERATED_UNCLOSED", "line": None,
|
|
726
|
+
"message": "INDEX GENERATED region is never closed."})
|
|
727
|
+
if man_s is None:
|
|
728
|
+
issues.append({"code": "INDEX_MISSING_MANUAL", "line": None,
|
|
729
|
+
"message": "Missing INDEX MANUAL:START."})
|
|
730
|
+
if man_s is not None and man_e is None:
|
|
731
|
+
issues.append({"code": "INDEX_MANUAL_UNCLOSED", "line": None,
|
|
732
|
+
"message": "INDEX MANUAL region is never closed."})
|
|
733
|
+
|
|
734
|
+
if None in (gen_s, gen_e, man_s, man_e):
|
|
735
|
+
return None, issues
|
|
736
|
+
|
|
737
|
+
generated = "".join(lines[gen_s + 1:gen_e])
|
|
738
|
+
manual = "".join(lines[man_s + 1:man_e])
|
|
739
|
+
rows: list[tuple[str, str]] = []
|
|
740
|
+
seen: set[str] = set()
|
|
741
|
+
for row_line in generated.splitlines():
|
|
742
|
+
rm = _INDEX_ROW_RE.match(row_line.strip())
|
|
743
|
+
if not rm:
|
|
744
|
+
continue
|
|
745
|
+
rdate, summary = rm.group(1), rm.group(2).strip()
|
|
746
|
+
if rdate in seen:
|
|
747
|
+
issues.append({"code": "INDEX_DUPLICATE_DATE", "line": None,
|
|
748
|
+
"message": f"Date {rdate} appears more than once in the index."})
|
|
749
|
+
seen.add(rdate)
|
|
750
|
+
rows.append((rdate, summary))
|
|
751
|
+
|
|
752
|
+
ordered = [d for d, _ in rows]
|
|
753
|
+
if ordered != sorted(ordered, reverse=True):
|
|
754
|
+
issues.append({"code": "INDEX_ORDER", "line": None,
|
|
755
|
+
"message": "Index dates are not in descending order."})
|
|
756
|
+
|
|
757
|
+
return IndexDoc(rows, manual), issues
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
def parse_index(text: str) -> IndexDoc:
|
|
761
|
+
doc, issues = scan_index(text)
|
|
762
|
+
fatal = [i for i in issues if i["code"] in FATAL_CODES]
|
|
763
|
+
if fatal or doc is None:
|
|
764
|
+
raise WorklogFormatError(fatal or issues)
|
|
765
|
+
return doc
|