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.
Files changed (44) hide show
  1. git_worklog/__init__.py +58 -0
  2. git_worklog/__main__.py +10 -0
  3. git_worklog/analysis/__init__.py +48 -0
  4. git_worklog/analysis/commits.py +118 -0
  5. git_worklog/analysis/coverage.py +149 -0
  6. git_worklog/analysis/history.py +339 -0
  7. git_worklog/analysis/manifest.py +432 -0
  8. git_worklog/analysis/reconcile.py +211 -0
  9. git_worklog/analysis/refs.py +178 -0
  10. git_worklog/analysis/results.py +871 -0
  11. git_worklog/analysis/worktree.py +114 -0
  12. git_worklog/cli/__init__.py +414 -0
  13. git_worklog/cli/analyze.py +340 -0
  14. git_worklog/cli/apply.py +61 -0
  15. git_worklog/cli/coverage.py +62 -0
  16. git_worklog/cli/doctor.py +348 -0
  17. git_worklog/cli/migrate.py +43 -0
  18. git_worklog/cli/models.py +112 -0
  19. git_worklog/cli/preview.py +124 -0
  20. git_worklog/cli/refs.py +55 -0
  21. git_worklog/cli/reindex.py +72 -0
  22. git_worklog/cli/report.py +227 -0
  23. git_worklog/cli/validate.py +252 -0
  24. git_worklog/cli/version.py +47 -0
  25. git_worklog/cli/view.py +218 -0
  26. git_worklog/config.py +79 -0
  27. git_worklog/data/provider_models.json +34 -0
  28. git_worklog/dates.py +237 -0
  29. git_worklog/language.py +295 -0
  30. git_worklog/markers.py +765 -0
  31. git_worklog/mdhtml.py +266 -0
  32. git_worklog/migrate.py +305 -0
  33. git_worklog/paths.py +85 -0
  34. git_worklog/preview.py +776 -0
  35. git_worklog/providers.py +337 -0
  36. git_worklog/viewer.py +1198 -0
  37. git_worklog/viewserver.py +120 -0
  38. git_worklog/writer.py +420 -0
  39. git_worklog-1.6.0.dist-info/METADATA +352 -0
  40. git_worklog-1.6.0.dist-info/RECORD +44 -0
  41. git_worklog-1.6.0.dist-info/WHEEL +5 -0
  42. git_worklog-1.6.0.dist-info/entry_points.txt +2 -0
  43. git_worklog-1.6.0.dist-info/licenses/LICENSE +21 -0
  44. 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