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/viewer.py ADDED
@@ -0,0 +1,1198 @@
1
+ """The worklog as one self-contained HTML page (``git-worklog view``).
2
+
3
+ Read-only by construction: this module opens day files, the index and config
4
+ for reading and returns a string. Writing that string somewhere and opening a
5
+ browser on it is the CLI's job (:mod:`git_worklog.cli.view`), so everything
6
+ here can be tested without side effects.
7
+
8
+ **Structure, never wording.** Headings are written in each run's language
9
+ (worklog-format.md §3), so nothing here compares heading text. A day is split
10
+ on ``##`` (sections) and ``###`` (work items) outside code fences; its lead is
11
+ the first paragraph of its first section, which is where the template puts the
12
+ summary with or without SUMMARY markers; and its commits are counted with
13
+ :func:`git_worklog.analysis.reconcile.cited_hashes`, the scan ``report`` uses,
14
+ so the two commands cannot disagree about what a day cites.
15
+
16
+ **Cited, not made.** Every number on the page comes from the day files, so it
17
+ describes the worklog, not the repository. "Commits cited" is what the
18
+ prose mentions -- on 2026-07-17 in this repository, 41 of the 52 commits made --
19
+ and an empty calendar cell means "no day file", which a gap and a day without
20
+ commits look exactly alike as. Telling those apart needs git: see ``coverage``.
21
+
22
+ **The day header is not shown.** Its Branch/HEAD lines record the run that wrote
23
+ the file (worklog-format.md §2); two days analysed together carry the same HEAD,
24
+ so printing it beside a date would say something false about that date.
25
+
26
+ **Commits, when Git has them.** Given the repository, the page also carries
27
+ what Git records about each commit the worklog cites
28
+ (:mod:`git_worklog.analysis.commits`): message, author, date and the files it
29
+ touched. A hash badge then opens that commit in a drawer instead of copying
30
+ itself. Only cited commits are embedded -- the page is not a history browser --
31
+ and never their diffs, which are source code by the megabyte. Those are fetched
32
+ on demand, and only by a page that ``view --serve`` is serving: such a page is
33
+ told where (``data-diff``) and is allowed to ask its own origin, nothing wider.
34
+
35
+ **One script, pinned.** Content goes through :mod:`git_worklog.mdhtml`, which
36
+ never lets input become markup. The page's Content-Security-Policy then allows
37
+ exactly one script, by hash, and no network access at all -- a backstop, so an
38
+ escape that was ever missed still could not run or send anything anywhere.
39
+ Navigation is CSS (``:target``), so the page reads fully without the script;
40
+ the script adds search, keyboard shortcuts, the light/dark switch and the
41
+ commit drawer. The commit data sits in a second ``<script>`` whose type makes
42
+ it data, which no browser runs; ``<`` is escaped in it, so a commit message
43
+ cannot end the block early.
44
+ """
45
+
46
+ from __future__ import annotations
47
+
48
+ import base64
49
+ import datetime as dt
50
+ import hashlib
51
+ import html
52
+ import json
53
+ import os
54
+ import re
55
+ from dataclasses import dataclass, field
56
+
57
+ from git_worklog import config as gwconfig
58
+ from git_worklog import markers as wm
59
+ from git_worklog import mdhtml
60
+ from git_worklog.analysis import commits as gitcommits
61
+ from git_worklog.analysis.reconcile import cited_hashes
62
+
63
+ _WEEKDAYS = ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")
64
+ _WEEKDAYS_LONG = ("Monday", "Tuesday", "Wednesday", "Thursday", "Friday",
65
+ "Saturday", "Sunday")
66
+ _MONTHS = ("January", "February", "March", "April", "May", "June", "July",
67
+ "August", "September", "October", "November", "December")
68
+
69
+ _FENCE_RE = re.compile(r"^ {0,3}(`{3,}|~{3,})")
70
+ _H2_RE = re.compile(r"^##\s+(.+?)(?:\s+#+)?\s*$")
71
+ _H3_RE = re.compile(r"^###\s+(.+?)(?:\s+#+)?\s*$")
72
+
73
+
74
+ @dataclass
75
+ class Section:
76
+ title: str
77
+ body: str = "" # Markdown before its first ###
78
+ items: "list[tuple[str, str]]" = field(default_factory=list) # (title, Markdown)
79
+
80
+
81
+ @dataclass
82
+ class Day:
83
+ date: str
84
+ path: str
85
+ sections: "list[Section]"
86
+ manual: str
87
+ raw: "str | None" # the whole file, when its regions could not be found
88
+ issues: "list[dict]"
89
+ hashes: "frozenset[str]" # cited commits, first 7 characters
90
+ lead: str
91
+ tokens: "frozenset[str]" = frozenset() # everything that may name a commit
92
+
93
+ @property
94
+ def items(self) -> int:
95
+ return sum(len(s.items) for s in self.sections)
96
+
97
+
98
+ def split_sections(generated: str) -> "list[Section]":
99
+ """Cut a GENERATED region into ``##`` sections holding ``###`` work items.
100
+
101
+ Fence-aware: a ``##`` line inside a code block is code. Text before the
102
+ first ``##`` -- the template has none -- becomes an untitled section rather
103
+ than being dropped.
104
+ """
105
+ sections: "list[Section]" = []
106
+ lines: "list[str]" = []
107
+ item: "str | None" = None
108
+ fence: "str | None" = None
109
+
110
+ def flush():
111
+ text = "\n".join(lines).strip("\n")
112
+ lines.clear()
113
+ if item is not None:
114
+ sections[-1].items.append((item, text))
115
+ elif sections:
116
+ sections[-1].body = text
117
+ elif text.strip():
118
+ sections.append(Section("", text))
119
+
120
+ for line in generated.split("\n"):
121
+ opened = _FENCE_RE.match(line)
122
+ if fence:
123
+ if (opened and opened.group(1)[0] == fence[0]
124
+ and len(opened.group(1)) >= len(fence)
125
+ and line.strip() == opened.group(1)):
126
+ fence = None
127
+ lines.append(line)
128
+ continue
129
+ if opened:
130
+ fence = opened.group(1)
131
+ lines.append(line)
132
+ continue
133
+ h2 = _H2_RE.match(line)
134
+ h3 = None if h2 else _H3_RE.match(line)
135
+ if not (h2 or h3):
136
+ lines.append(line)
137
+ continue
138
+ flush()
139
+ if h2:
140
+ sections.append(Section(h2.group(1)))
141
+ item = None
142
+ else:
143
+ if not sections:
144
+ sections.append(Section(""))
145
+ item = h3.group(1)
146
+ flush()
147
+ return sections
148
+
149
+
150
+ def lead_of(sections: "list[Section]", generated: str) -> str:
151
+ """The first paragraph of the first section: the day's summary lead."""
152
+ para: "list[str]" = []
153
+ for line in (sections[0].body.split("\n") if sections else []):
154
+ if not line.strip() or mdhtml.COMMENT_RE.match(line):
155
+ if para:
156
+ break
157
+ continue
158
+ para.append(line.strip())
159
+ if para:
160
+ return " ".join(para)
161
+ # No section at all: fall back to the index's own rule (which escapes pipes
162
+ # for its table -- undone here, this is not a table).
163
+ return wm.summarise_generated(generated).replace("\\|", "|")
164
+
165
+
166
+ def _tokens(text: str) -> "frozenset[str]":
167
+ """Every token in ``text`` that Git should be asked about.
168
+
169
+ Two scans, because they disagree at the edges: ``report``'s finds a hash in
170
+ capitals, the renderer's finds one written directly against Chinese text.
171
+ The union covers whatever the renderer will turn into a badge.
172
+ """
173
+ return frozenset(cited_hashes(text)) | frozenset(
174
+ m.group(0) for m in mdhtml.HASH_RE.finditer(text))
175
+
176
+
177
+ def load_day(worklog_dir: str, date: str, layout: str) -> Day:
178
+ path = wm.day_path(worklog_dir, date, layout)
179
+ issues: "list[dict]" = []
180
+ try:
181
+ with open(path, "rb") as fh:
182
+ data = fh.read()
183
+ except OSError as exc:
184
+ issues.append({"code": "UNREADABLE", "message": f"{path}: {exc}",
185
+ "date": date, "target": path})
186
+ return Day(date, path, [], "", "", issues, frozenset(), "")
187
+ try:
188
+ text = data.decode("utf-8")
189
+ except UnicodeDecodeError as exc:
190
+ # One bad file must not take the whole page down; validate reports the
191
+ # same code, and the replacement characters show where the damage is.
192
+ text = data.decode("utf-8", errors="replace")
193
+ issues.append({"code": "NON_UTF8", "message": f"{path} is not valid UTF-8: {exc}",
194
+ "date": date, "target": path})
195
+ parsed, found = wm.scan_day(text, date)
196
+ issues.extend(dict(i, date=date, target=path) for i in found)
197
+ if parsed is None:
198
+ return Day(date, path, [], "", text, issues, frozenset(), "")
199
+ sections = split_sections(parsed.generated)
200
+ hashes = frozenset(h[:7] for h in cited_hashes(parsed.generated))
201
+ return Day(date, path, sections, parsed.manual, None, issues, hashes,
202
+ lead_of(sections, parsed.generated),
203
+ _tokens(parsed.generated) | _tokens(parsed.manual))
204
+
205
+
206
+ def _index(worklog_dir: str) -> "tuple[str, str | None, list[dict]]":
207
+ """(project notes, content language, warnings) from index.md.
208
+
209
+ A missing or broken index is nothing to show, not a failure: the page is
210
+ built from the day files, and the index is only asked for its human notes
211
+ and the language its summaries are in.
212
+ """
213
+ path = wm.index_path(worklog_dir)
214
+ try:
215
+ with open(path, encoding="utf-8") as fh:
216
+ text = fh.read()
217
+ except FileNotFoundError:
218
+ return "", None, []
219
+ except (OSError, UnicodeDecodeError) as exc:
220
+ return "", None, [{"code": "INDEX_UNREADABLE", "message": f"{path}: {exc}",
221
+ "target": path}]
222
+ # Unstamped means "written before the stamp existed", which means zh-TW.
223
+ lang = wm.index_language_of(text) or wm.DEFAULT_INDEX_LANGUAGE
224
+ doc, _issues = wm.scan_index(text)
225
+ if doc is None:
226
+ return "", lang, [{"code": "INDEX_UNREADABLE",
227
+ "message": f"{path} could not be parsed; its notes are not shown.",
228
+ "target": path}]
229
+ notes = doc.manual.strip()
230
+ placeholders = {wm.index_chrome(code)["manual_default"].strip()
231
+ for code in wm.INDEX_CHROME_LANGUAGES}
232
+ return ("" if notes in placeholders else notes), lang, []
233
+
234
+
235
+ def build(worklog_dir: str, *, repo: "str | None" = None,
236
+ diff_base: "str | None" = None) -> "tuple[str, dict]":
237
+ """The page for ``worklog_dir``, and what the CLI reports about it.
238
+
239
+ ``repo`` is the repository to ask about the commits the worklog cites; with
240
+ none, hash badges only copy. ``diff_base`` is the URL, relative to the page,
241
+ under which a server answers with a commit's diff.
242
+ """
243
+ layout = wm.detect_layout(worklog_dir)
244
+ dates = wm.list_day_dates(worklog_dir, layout)
245
+ days = [load_day(worklog_dir, d, layout) for d in dates]
246
+ notes, lang, warnings = _index(worklog_dir)
247
+ if lang is None:
248
+ lang = gwconfig.language(gwconfig.load(worklog_dir))
249
+ for day in days:
250
+ warnings.extend(day.issues)
251
+ real = os.path.realpath(worklog_dir)
252
+ project = os.path.basename(os.path.dirname(real)) or "worklog"
253
+ commits, tokens = {}, {}
254
+ if repo is not None:
255
+ cited = _tokens(notes).union(*(d.tokens for d in days))
256
+ commits, tokens = gitcommits.collect(repo, cited)
257
+ page = _page(project, os.path.dirname(real), days, notes, lang,
258
+ commits, tokens, diff_base)
259
+ return page, {
260
+ "layout": layout,
261
+ "day_count": len(days),
262
+ "commit_count": len(commits),
263
+ "commits": frozenset(commits),
264
+ "range": {"from": dates[0], "to": dates[-1]} if dates else None,
265
+ "warnings": warnings,
266
+ }
267
+
268
+
269
+ # --- rendering ---------------------------------------------------------------
270
+
271
+
272
+ def _e(text) -> str:
273
+ return html.escape(str(text), quote=True)
274
+
275
+
276
+ def _n(count: int, noun: str) -> str:
277
+ return f"{count} {noun}" if count == 1 else f"{count} {noun}s"
278
+
279
+
280
+ def _weekday(date: str, long: bool = False) -> str:
281
+ # A fixed table, not strftime("%a"), which follows the process locale.
282
+ table = _WEEKDAYS_LONG if long else _WEEKDAYS
283
+ return table[dt.date.fromisoformat(date).weekday()]
284
+
285
+
286
+ def _lang(lang: "str | None") -> str:
287
+ return f' lang="{_e(lang)}"' if lang else ""
288
+
289
+
290
+ def _level(items: int) -> int:
291
+ return 1 if items <= 2 else 2 if items <= 5 else 3 if items <= 9 else 4
292
+
293
+
294
+ def script_hash() -> str:
295
+ """The CSP source that allows the page's one script, and nothing else."""
296
+ digest = hashlib.sha256(_JS.encode("utf-8")).digest()
297
+ return "sha256-" + base64.b64encode(digest).decode("ascii")
298
+
299
+
300
+ def _commit_data(commits: dict, tokens: dict) -> str:
301
+ """The cited commits as a JSON data block, or nothing when there are none."""
302
+ if not commits:
303
+ return ""
304
+ blob = json.dumps({"commits": commits, "tokens": tokens}, ensure_ascii=False,
305
+ sort_keys=True, separators=(",", ":"))
306
+ # No "<" survives, so nothing a commit says can close the block or open a tag.
307
+ return ('<script type="application/json" id="commits">'
308
+ + blob.replace("<", "\\u003c") + "</script>\n")
309
+
310
+
311
+ def _page(project: str, root: str, days: "list[Day]", notes: str,
312
+ lang: "str | None", commits: dict, tokens: dict,
313
+ diff_base: "str | None") -> str:
314
+ known = frozenset(d.date for d in days)
315
+ csp = (f"default-src 'none'; script-src '{script_hash()}'; "
316
+ "style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'")
317
+ if diff_base:
318
+ csp += "; connect-src 'self'"
319
+ articles = []
320
+ for i in range(len(days) - 1, -1, -1): # newest first, like the index
321
+ older = days[i - 1].date if i > 0 else None
322
+ newer = days[i + 1].date if i + 1 < len(days) else None
323
+ articles.append(_day(days[i], older, newer, root, lang, known))
324
+ body = "".join([
325
+ '<header class="top">',
326
+ '<a class="brand" href="#overview">Git Worklog</a>',
327
+ '<input id="q" type="search" placeholder="Search the worklog (press /)" '
328
+ 'aria-label="Search the worklog" aria-keyshortcuts="/" autocomplete="off" '
329
+ 'spellcheck="false">',
330
+ '<button id="theme" class="theme" type="button" aria-label="Switch theme" '
331
+ 'title="Switch theme">'
332
+ '<svg class="moon" viewBox="0 0 24 24" aria-hidden="true">'
333
+ '<path d="M20 14.5A8 8 0 1 1 9.5 4a6.5 6.5 0 0 0 10.5 10.5z"/></svg>'
334
+ '<svg class="sun" viewBox="0 0 24 24" aria-hidden="true">'
335
+ '<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4'
336
+ 'M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>'
337
+ '</button>',
338
+ "</header>",
339
+ '<div class="layout">',
340
+ _sidebar(days),
341
+ "<main>",
342
+ '<section id="results" class="results" hidden aria-live="polite"></section>',
343
+ _overview(project, days, notes, lang, known),
344
+ "".join(articles),
345
+ "</main>",
346
+ "</div>",
347
+ f'<footer class="foot">Rendered from {_n(len(days), "day file")}. '
348
+ "Re-run <code>git-worklog view</code> to refresh.</footer>",
349
+ '<aside id="commit" class="drawer" role="dialog" aria-labelledby="commit-title" '
350
+ 'tabindex="-1" hidden></aside>',
351
+ ])
352
+ served = f' data-diff="{_e(diff_base)}"' if diff_base else ""
353
+ return ("<!DOCTYPE html>\n"
354
+ f'<html lang="en"{served}>\n<head>\n<meta charset="utf-8">\n'
355
+ f'<meta http-equiv="Content-Security-Policy" content="{csp}">\n'
356
+ '<meta name="viewport" content="width=device-width, initial-scale=1">\n'
357
+ '<meta name="referrer" content="no-referrer">\n'
358
+ f"<title>{_e(project)} · Git Worklog</title>\n"
359
+ f"<style>{_CSS}</style>\n"
360
+ # In <head>, so a remembered theme applies before the body paints.
361
+ f"<script>{_JS}</script>\n"
362
+ "</head>\n<body>\n"
363
+ f"{body}\n"
364
+ f"{_commit_data(commits, tokens)}"
365
+ "</body>\n</html>\n")
366
+
367
+
368
+ def _sidebar(days: "list[Day]") -> str:
369
+ parts = ['<nav class="side" aria-label="Days">'
370
+ '<a class="nav-overview" href="#overview">Overview</a>']
371
+ month = None
372
+ for d in reversed(days):
373
+ if d.date[:7] != month:
374
+ month = d.date[:7]
375
+ parts.append(f'<p class="month">{month}</p>')
376
+ flag = ' <span class="flag" title="Format issue">!</span>' if d.issues else ""
377
+ parts.append(f'<a href="#d-{d.date}"><span>{d.date[5:]} {_weekday(d.date)}'
378
+ f'{flag}</span><span class="n" title="Work items">{d.items}</span></a>')
379
+ parts.append("</nav>")
380
+ return "".join(parts)
381
+
382
+
383
+ def _tile(label: str, value) -> str:
384
+ return f'<div class="tile"><span>{label}</span><b>{value}</b></div>'
385
+
386
+
387
+ def _overview(project: str, days: "list[Day]", notes: str, lang: "str | None",
388
+ known) -> str:
389
+ parts = [f'<section id="overview" class="view overview"><h1>{_e(project)}</h1>']
390
+ if not days:
391
+ parts.append('<p class="empty">No worklog days yet. Generate some with '
392
+ "<code>/git-worklog</code>.</p></section>")
393
+ return "".join(parts)
394
+ first, last = days[0].date, days[-1].date
395
+ span = (dt.date.fromisoformat(last) - dt.date.fromisoformat(first)).days + 1
396
+ cited = frozenset().union(*(d.hashes for d in days))
397
+ parts.append(f'<p class="sub">{_n(len(days), "day")} logged · {first} → {last}</p>')
398
+ parts.append('<div class="tiles">'
399
+ + _tile("Days logged", len(days))
400
+ + _tile("Work items", sum(d.items for d in days))
401
+ + _tile("Commits cited", len(cited))
402
+ + _tile("Span", _n(span, "day"))
403
+ + "</div>")
404
+ parts.append("<h2>Activity</h2>" + _calendar(days))
405
+ if notes:
406
+ parts.append('<section class="notes"><h2>Project notes</h2>'
407
+ f'<div class="md"{_lang(lang)}>{mdhtml.render(notes, dates=known)}</div>'
408
+ "</section>")
409
+ parts.append("<h2>Timeline</h2>" + _timeline(days, lang))
410
+ parts.append("</section>")
411
+ return "".join(parts)
412
+
413
+
414
+ def _calendar(days: "list[Day]") -> str:
415
+ by_date = {d.date: d for d in days}
416
+ first = dt.date.fromisoformat(days[0].date)
417
+ last = dt.date.fromisoformat(days[-1].date)
418
+ week = first - dt.timedelta(days=first.weekday())
419
+ end = last + dt.timedelta(days=6 - last.weekday())
420
+ cols = ['<div class="wk days"><span></span><span>Mon</span><span></span>'
421
+ "<span>Wed</span><span></span><span>Fri</span><span></span><span></span></div>"]
422
+ while week <= end:
423
+ label, cells = "", []
424
+ for k in range(7):
425
+ day = week + dt.timedelta(days=k)
426
+ if day.day == 1 or (k == 0 and len(cols) == 1): # first week, or a new month
427
+ label = _MONTHS[day.month - 1][:3]
428
+ iso = day.isoformat()
429
+ if day < first or day > last:
430
+ cells.append('<span class="c pad"></span>')
431
+ elif iso in by_date:
432
+ d = by_date[iso]
433
+ tip = (f"{iso} · {_n(d.items, 'work item')} · "
434
+ f"{_n(len(d.hashes), 'commit')} cited")
435
+ cells.append(f'<a class="c l{_level(d.items)}" href="#d-{iso}" '
436
+ f'title="{tip}" aria-label="{tip}"></a>')
437
+ else:
438
+ cells.append(f'<span class="c" title="{iso} · no day file"></span>')
439
+ cols.append(f'<div class="wk"><span class="mo">{label}</span>'
440
+ + "".join(cells) + "</div>")
441
+ week += dt.timedelta(days=7)
442
+ legend = ('<p class="legend"><span>Fewer</span><span class="c l1"></span>'
443
+ '<span class="c l2"></span><span class="c l3"></span><span class="c l4"></span>'
444
+ "<span>more work items</span><span>·</span><span class=\"c\"></span>"
445
+ "<span>no day file (not checked against git, so a day nobody logged "
446
+ "looks the same as a day without commits)</span></p>")
447
+ return '<div class="cal-wrap"><div class="cal">' + "".join(cols) + "</div></div>" + legend
448
+
449
+
450
+ def _timeline(days: "list[Day]", lang: "str | None") -> str:
451
+ parts, month = [], None
452
+ for d in reversed(days):
453
+ if d.date[:7] != month:
454
+ month = d.date[:7]
455
+ parts.append(f'<h3 class="month">{_MONTHS[int(month[5:]) - 1]} {month[:4]}</h3>')
456
+ lead = (mdhtml.inline(d.lead, links=False) if d.lead
457
+ else '<span class="muted">No summary</span>')
458
+ badges = ""
459
+ if d.manual.strip():
460
+ badges += '<span class="badge">Notes</span>'
461
+ if d.issues:
462
+ badges += '<span class="badge warn">Format issue</span>'
463
+ tags = "".join(f'<span class="tag">{mdhtml.inline(s.title, links=False)}</span>'
464
+ for s in d.sections[1:] if s.title)
465
+ parts.append(
466
+ f'<a class="card" href="#d-{d.date}">'
467
+ f'<span class="card-date">{d.date} <span class="wd">{_weekday(d.date)}</span></span>'
468
+ f'<span class="lead"{_lang(lang)}>{lead}</span>'
469
+ '<span class="facts">'
470
+ f'<span class="chip">{_n(d.items, "work item")}</span>'
471
+ f'<span class="chip">{_n(len(d.hashes), "commit")} cited</span>'
472
+ f"{badges}</span>"
473
+ + (f'<span class="tags"{_lang(lang)}>{tags}</span>' if tags else "")
474
+ + "</a>")
475
+ return "".join(parts)
476
+
477
+
478
+ def _toc(d: Day) -> str:
479
+ if not any(s.items for s in d.sections):
480
+ return ""
481
+ parts = ['<nav class="toc" aria-label="On this day"><p class="toc-h">On this day</p><ol>']
482
+ n = 0
483
+ for k, s in enumerate(d.sections, 1):
484
+ sub = []
485
+ for title, _body in s.items:
486
+ n += 1
487
+ sub.append(f'<li><a href="#d-{d.date}-i{n}">{mdhtml.inline(title, links=False)}</a></li>')
488
+ name = mdhtml.inline(s.title, links=False) if s.title else "Untitled"
489
+ parts.append(f'<li><a href="#d-{d.date}-s{k}">{name}</a>'
490
+ + ("<ol>" + "".join(sub) + "</ol>" if sub else "") + "</li>")
491
+ parts.append("</ol></nav>")
492
+ return "".join(parts)
493
+
494
+
495
+ def _sections(d: Day, known) -> str:
496
+ out, n = [], 0
497
+ for k, s in enumerate(d.sections, 1):
498
+ head = f"<h2>{mdhtml.inline(s.title, dates=known)}</h2>" if s.title else ""
499
+ intro = ""
500
+ if s.body.strip():
501
+ intro = f'<div class="intro" data-unit>{mdhtml.render(s.body, dates=known)}</div>'
502
+ items = []
503
+ for title, body in s.items:
504
+ n += 1
505
+ items.append(
506
+ f'<details class="item" id="d-{d.date}-i{n}" open data-unit>'
507
+ f'<summary><span class="no">{n:02d}</span>'
508
+ f'<span class="t">{mdhtml.inline(title, dates=known)}</span></summary>'
509
+ f'<div class="body">{mdhtml.render(body, dates=known)}</div></details>')
510
+ cls = "block summary" if k == 1 else "block"
511
+ out.append(f'<section class="{cls}" id="d-{d.date}-s{k}">{head}{intro}'
512
+ + "".join(items) + "</section>")
513
+ return "".join(out)
514
+
515
+
516
+ def _day(d: Day, older: "str | None", newer: "str | None", root: str,
517
+ lang: "str | None", known) -> str:
518
+ rel = os.path.relpath(os.path.realpath(d.path), root).replace(os.sep, "/")
519
+ pager = ('<nav class="pager">'
520
+ + (f'<a href="#d-{older}" rel="prev">← {older}</a>' if older else "<span></span>")
521
+ + (f'<a href="#d-{newer}" rel="next">{newer} →</a>' if newer else "<span></span>")
522
+ + "</nav>")
523
+ banner = ""
524
+ if d.issues:
525
+ codes = ", ".join(sorted({i["code"] for i in d.issues}))
526
+ banner = (f'<p class="banner">This day file has format issues ({_e(codes)}). '
527
+ "Run <code>git-worklog validate</code> for details.</p>")
528
+ if d.raw is not None:
529
+ content = f'<pre class="raw">{html.escape(d.raw, quote=False)}</pre>'
530
+ else:
531
+ content = _toc(d) + '<div class="sections">' + _sections(d, known)
532
+ if d.manual.strip():
533
+ content += (f'<section class="block manual" id="d-{d.date}-notes"><h2>Notes</h2>'
534
+ f'<div class="intro" data-unit>{mdhtml.render(d.manual, dates=known)}'
535
+ "</div></section>")
536
+ content += "</div>"
537
+ return (f'<article id="d-{d.date}" class="view day" data-date="{d.date}" '
538
+ f'data-prev="{"d-" + older if older else ""}" '
539
+ f'data-next="{"d-" + newer if newer else ""}">'
540
+ '<header class="day-head">'
541
+ f'<h1>{d.date} <span class="wd">{_weekday(d.date, long=True)}</span></h1>'
542
+ f"{pager}"
543
+ '<p class="facts">'
544
+ f'<span class="chip">{_n(d.items, "work item")}</span>'
545
+ f'<span class="chip">{_n(len(d.hashes), "commit")} cited</span></p>'
546
+ "</header>"
547
+ f'{banner}<div class="md day-body"{_lang(lang)}>{content}</div>'
548
+ f'<footer class="day-foot"><code>{_e(rel)}</code></footer>'
549
+ "</article>")
550
+
551
+
552
+ # --- page assets ---------------------------------------------------------------
553
+ # Kept as strings rather than files: package data is exactly the kind of thing
554
+ # that silently stops shipping, and a module cannot be left out of the wheel or
555
+ # skill.zip without the import failing loudly.
556
+
557
+ # The dark palette. It applies while the system prefers dark and the reader
558
+ # has not picked light, and whenever the reader has picked dark: one list for
559
+ # both, so the two cannot drift apart.
560
+ _DARK = """
561
+ color-scheme: dark;
562
+ --bg: #161615; --panel: #1e1e1c; --line: #34322e; --text: #ebe9e4;
563
+ --muted: #aaa59c; --faint: #7c776f; --accent: #5dcaa5; --accent-bg: #11352b;
564
+ --warn: #f3c77a; --warn-bg: #3a2a0c; --code: #292826; --mark: #6b5314;
565
+ --empty: #282725; --l1: #1a4a3b; --l2: #1f7a5c; --l3: #35b389; --l4: #86e2c0;
566
+ --del: #f09595; --del-bg: #3d1a1a;
567
+ """
568
+
569
+ _CSS = r"""
570
+ :root {
571
+ color-scheme: light;
572
+ --bg: #fbfaf8; --panel: #ffffff; --line: #e5e1da; --text: #1f1e1c;
573
+ --muted: #66625b; --faint: #99948b; --accent: #0f6e56; --accent-bg: #e3f4ed;
574
+ --warn: #854f0b; --warn-bg: #faeeda; --code: #f3f1ec; --mark: #fad68a;
575
+ --empty: #ece9e3; --l1: #bfe6d5; --l2: #77cda9; --l3: #2e9e77; --l4: #0f6e56;
576
+ --del: #a32d2d; --del-bg: #fbe9e9;
577
+ --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang TC",
578
+ "Noto Sans TC", "Microsoft JhengHei", "Hiragino Sans", sans-serif;
579
+ --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
580
+ }
581
+ @media (prefers-color-scheme: dark) {
582
+ :root:not([data-theme="light"]) {""" + _DARK + r""" }
583
+ }
584
+ :root[data-theme="dark"] {""" + _DARK + r"""}
585
+ * { box-sizing: border-box; }
586
+ html { background: var(--bg); }
587
+ body { margin: 0; color: var(--text); font: 15px/1.7 var(--sans); }
588
+ a { color: var(--accent); }
589
+ [id] { scroll-margin-top: 72px; }
590
+ .top { position: sticky; top: 0; z-index: 2; display: flex; align-items: center;
591
+ gap: 12px; padding: 10px 20px; background: var(--panel);
592
+ border-bottom: 1px solid var(--line); }
593
+ .brand { color: var(--text); font-weight: 600; text-decoration: none; }
594
+ #q { margin-left: auto; width: min(360px, 46vw); padding: 6px 10px; font: inherit;
595
+ font-size: 14px; color: var(--text); background: var(--bg);
596
+ border: 1px solid var(--line); border-radius: 8px; }
597
+ #q:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
598
+
599
+ .theme { display: inline-flex; align-items: center; justify-content: center;
600
+ width: 32px; height: 32px; padding: 0; color: var(--muted); background: var(--bg);
601
+ border: 1px solid var(--line); border-radius: 8px; cursor: pointer; }
602
+ .theme:hover { color: var(--text); border-color: var(--faint); }
603
+ .theme:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
604
+ .theme svg { width: 16px; height: 16px; fill: none; stroke: currentColor;
605
+ stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
606
+ .theme .sun, .theme.is-dark .moon { display: none; }
607
+ .theme.is-dark .sun { display: block; }
608
+ html:not(.js) #q, html:not(.js) .theme { display: none; }
609
+ .layout { display: grid; grid-template-columns: 200px minmax(0, 1fr);
610
+ max-width: 1240px; margin: 0 auto; }
611
+ .side { position: sticky; top: 53px; align-self: start; max-height: calc(100vh - 53px);
612
+ overflow: auto; padding: 16px 10px 24px; font-size: 13px; }
613
+ .side a { display: flex; justify-content: space-between; gap: 8px; padding: 3px 8px;
614
+ border-radius: 6px; color: var(--muted); text-decoration: none; }
615
+ .side a:hover { background: var(--code); }
616
+ .side a[aria-current] { background: var(--accent-bg); color: var(--accent); }
617
+ .side .nav-overview { color: var(--text); font-weight: 600; }
618
+ .side .month { margin: 14px 8px 4px; font-size: 11px; color: var(--faint); }
619
+ .side .n { color: var(--faint); font-variant-numeric: tabular-nums; }
620
+ .flag { color: var(--warn); font-weight: 700; }
621
+ main { min-width: 0; padding: 24px 28px 64px; }
622
+ h1 { font-size: 24px; line-height: 1.3; margin: 0 0 4px; }
623
+ h2 { font-size: 18px; line-height: 1.4; margin: 28px 0 10px; }
624
+ h3 { font-size: 15px; margin: 22px 0 8px; }
625
+ .sub, .muted, .wd { color: var(--muted); }
626
+ .wd { font-weight: 400; }
627
+ .empty { color: var(--muted); }
628
+ .day { display: none; }
629
+ .day:target, .day:has(:target) { display: block; }
630
+ main:has(.day:target) #overview, main:has(.day :target) #overview { display: none; }
631
+ .searching .view { display: none !important; }
632
+ .results[hidden] { display: none; }
633
+ .tiles { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px;
634
+ margin: 16px 0 4px; }
635
+ .tile { background: var(--panel); border: 1px solid var(--line); border-radius: 10px;
636
+ padding: 10px 14px; }
637
+ .tile span { display: block; font-size: 12px; color: var(--muted); }
638
+ .tile b { font-size: 22px; font-weight: 600; font-variant-numeric: tabular-nums; }
639
+ .cal-wrap { overflow-x: auto; padding: 2px 0 6px; }
640
+ .cal { display: flex; gap: 3px; }
641
+ .wk { display: grid; grid-template-rows: 16px repeat(7, 12px); gap: 3px; }
642
+ .days span { font-size: 10px; line-height: 12px; color: var(--faint); padding-right: 4px; }
643
+ .mo { font-size: 10px; line-height: 14px; color: var(--faint); width: 12px;
644
+ overflow: visible; white-space: nowrap; }
645
+ .c { display: block; width: 12px; height: 12px; border-radius: 3px; background: var(--empty); }
646
+ .c.pad { background: transparent; }
647
+ .c.l1 { background: var(--l1); } .c.l2 { background: var(--l2); }
648
+ .c.l3 { background: var(--l3); } .c.l4 { background: var(--l4); }
649
+ a.c:hover, a.c:focus { outline: 2px solid var(--text); outline-offset: -1px; }
650
+ .legend { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 6px;
651
+ margin: 6px 0 0; font-size: 12px; color: var(--muted); }
652
+ .legend .c { display: inline-block; }
653
+ .month { color: var(--muted); font-weight: 500; }
654
+ .card { display: block; margin: 0 0 10px; padding: 12px 16px; color: inherit;
655
+ text-decoration: none; background: var(--panel); border: 1px solid var(--line);
656
+ border-radius: 10px; }
657
+ .card:hover, .card:focus { border-color: var(--accent); }
658
+ .card-date { display: block; font-weight: 600; }
659
+ .lead { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical;
660
+ overflow: hidden; margin: 2px 0 6px; }
661
+ .facts, .tags { display: flex; flex-wrap: wrap; gap: 4px 6px; margin: 4px 0 0; }
662
+ .chip, .tag, .badge { font-size: 12px; padding: 0 8px; border-radius: 999px;
663
+ border: 1px solid var(--line); color: var(--muted); white-space: nowrap; }
664
+ .tag { background: var(--code); border-color: transparent; }
665
+ .badge { color: var(--accent); border-color: var(--accent); }
666
+ .badge.warn, .banner { color: var(--warn); border-color: var(--warn); }
667
+ .banner { background: var(--warn-bg); border-radius: 8px; padding: 8px 12px; }
668
+ .day-head { margin: 0 0 18px; }
669
+ .pager { display: flex; justify-content: space-between; font-size: 13px; margin: 4px 0; }
670
+ .pager a { text-decoration: none; }
671
+ .day-body { display: block; }
672
+ .toc { font-size: 13px; background: var(--panel); border: 1px solid var(--line);
673
+ border-radius: 10px; padding: 10px 14px; margin: 0 0 18px; }
674
+ .toc-h { margin: 0 0 4px; font-weight: 600; color: var(--muted); }
675
+ .toc ol { margin: 0; padding-left: 18px; }
676
+ .toc a { color: var(--text); text-decoration: none; }
677
+ .toc a:hover { color: var(--accent); }
678
+ .block.summary { border-left: 3px solid var(--accent); border-radius: 0;
679
+ padding: 2px 0 2px 16px; margin: 0 0 24px; }
680
+ .block.summary h2 { font-size: 13px; color: var(--muted); margin: 0 0 4px; }
681
+ .block.summary .intro > p:first-child { font-size: 17px; line-height: 1.65; }
682
+ .item { background: var(--panel); border: 1px solid var(--line); border-radius: 10px;
683
+ margin: 0 0 12px; }
684
+ .item > summary { display: flex; gap: 10px; align-items: baseline; padding: 12px 16px;
685
+ cursor: pointer; font-weight: 600; list-style: none; }
686
+ .item > summary::-webkit-details-marker { display: none; }
687
+ .item > summary::after { content: "▾"; margin-left: auto; color: var(--faint); }
688
+ .item:not([open]) > summary::after { content: "▸"; }
689
+ .item .no { color: var(--faint); font: 12px var(--mono); }
690
+ .item .body { padding: 0 16px 10px; border-top: 1px solid var(--line); }
691
+ .item .body > ul { list-style: none; padding: 0; margin: 0; }
692
+ .item .body > ul > li { padding: 8px 0; border-top: 1px solid var(--line); }
693
+ .item .body > ul > li:first-child { border-top: 0; }
694
+ li.kv > strong:first-child { display: block; font-size: 12px; font-weight: 600;
695
+ color: var(--muted); }
696
+ .md ul ul, .md ol ol, .md ul ol, .md ol ul { margin: 2px 0; padding-left: 20px; }
697
+ .md p { margin: 8px 0; }
698
+ .md code { font: 0.86em/1.4 var(--mono); background: var(--code);
699
+ border: 1px solid var(--line); border-radius: 5px; padding: 0.05em 0.35em;
700
+ overflow-wrap: anywhere; }
701
+ .md pre, pre.raw { overflow: auto; background: var(--code); padding: 10px 12px;
702
+ border-radius: 8px; font: 13px/1.5 var(--mono); }
703
+ .md pre code { border: 0; padding: 0; background: none; font-size: inherit; }
704
+ .hash { display: inline-block; margin: 0 0.1em; padding: 0 0.5em;
705
+ font: 0.8em/1.6 var(--mono); color: var(--accent); background: var(--accent-bg);
706
+ border: 1px solid transparent; border-radius: 999px; white-space: nowrap; }
707
+ button.hash { cursor: pointer; }
708
+ button.hash:hover { border-color: var(--accent); }
709
+ button.hash:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
710
+ button.hash.copied { color: var(--panel); background: var(--accent); }
711
+ button.hash.copied::after { content: " \2713"; }
712
+ .drawer { position: fixed; top: 0; right: 0; bottom: 0; z-index: 3;
713
+ width: min(640px, 100vw); overflow: auto; padding: 14px 20px 32px;
714
+ background: var(--panel); border-left: 1px solid var(--line);
715
+ box-shadow: -10px 0 30px rgba(0, 0, 0, 0.14); }
716
+ .drawer[hidden] { display: none; }
717
+ .drawer:focus { outline: none; }
718
+ .drawer-head { display: flex; align-items: center; justify-content: space-between; }
719
+ .drawer .hash { display: inline-flex; align-items: center; gap: 6px; }
720
+ .drawer .hash svg { width: 12px; height: 12px; fill: none; stroke: currentColor;
721
+ stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
722
+ .drawer .hash.copied svg { display: none; }
723
+ .drawer .close { width: 32px; height: 32px; padding: 0; font: 20px/1 var(--sans);
724
+ color: var(--muted); background: var(--bg); border: 1px solid var(--line);
725
+ border-radius: 8px; cursor: pointer; }
726
+ .drawer .close:hover { color: var(--text); border-color: var(--faint); }
727
+ .drawer .close:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
728
+ .drawer h2 { margin: 12px 0 2px; font-size: 17px; overflow-wrap: anywhere; }
729
+ .drawer p { margin: 0; font-size: 13px; color: var(--muted); }
730
+ .drawer .stat { margin: 18px 0 6px; }
731
+ .drawer .note { margin: 10px 0 0; }
732
+ .drawer pre { margin: 12px 0 0; padding: 10px 12px; overflow: auto;
733
+ font: 12px/1.55 var(--mono); background: var(--code); border-radius: 8px; }
734
+ .drawer .msg { white-space: pre-wrap; overflow-wrap: anywhere; }
735
+ .file { border-top: 1px solid var(--line); font-size: 13px; }
736
+ .file > summary, div.file { display: flex; gap: 8px; align-items: baseline;
737
+ padding: 6px 2px; }
738
+ .file > summary { cursor: pointer; list-style: none; }
739
+ .file > summary::-webkit-details-marker { display: none; }
740
+ .file > summary::before { content: "▸"; color: var(--faint); }
741
+ .file[open] > summary::before { content: "▾"; }
742
+ .file .path { margin-right: auto; font-family: var(--mono); font-size: 12px;
743
+ overflow-wrap: anywhere; }
744
+ .file .add, .file .del, .file .bin { font: 12px var(--mono); white-space: nowrap; }
745
+ .add { color: var(--accent); }
746
+ .del { color: var(--del); }
747
+ .drawer pre.diff { margin: 0 0 10px; padding: 8px 0; }
748
+ .diff code { display: inline-block; min-width: 100%; }
749
+ .diff span { display: block; padding: 0 12px; white-space: pre; }
750
+ .diff .a { background: var(--accent-bg); }
751
+ .diff .d { background: var(--del-bg); }
752
+ .diff .h { color: var(--muted); }
753
+ .diff .m { color: var(--faint); }
754
+ .manual { border: 1px dashed var(--faint); border-radius: 10px; padding: 4px 16px 8px; }
755
+ .manual h2 { font-size: 13px; color: var(--muted); margin: 10px 0 0; }
756
+ .day-foot { margin: 24px 0 0; font-size: 12px; color: var(--faint); }
757
+ .day-foot code, .foot code { font-family: var(--mono); }
758
+ .results h1 { font-size: 18px; }
759
+ .results h2 { font-size: 13px; color: var(--muted); margin: 18px 0 6px; }
760
+ .hit { display: block; padding: 8px 12px; margin: 0 0 6px; color: inherit;
761
+ text-decoration: none; background: var(--panel); border: 1px solid var(--line);
762
+ border-radius: 8px; }
763
+ .hit:hover, .hit:focus { border-color: var(--accent); }
764
+ .hit-title { display: block; font-weight: 600; }
765
+ .snip { display: block; font-size: 13px; color: var(--muted); }
766
+ mark { background: var(--mark); color: inherit; border-radius: 2px; }
767
+ .foot { max-width: 1240px; margin: 0 auto; padding: 16px 28px 32px; font-size: 12px;
768
+ color: var(--faint); }
769
+ @media (min-width: 1200px) {
770
+ .day-body { display: grid; grid-template-columns: minmax(0, 1fr) 230px; column-gap: 28px; }
771
+ .day-body > .toc { grid-column: 2; grid-row: 1; position: sticky; top: 72px;
772
+ align-self: start; max-height: calc(100vh - 96px); overflow: auto; margin: 0; }
773
+ .day-body > .sections { grid-column: 1; grid-row: 1; }
774
+ }
775
+ @media (max-width: 760px) {
776
+ .layout { display: block; }
777
+ .side { position: static; max-height: none; display: flex; flex-wrap: wrap;
778
+ gap: 2px 4px; padding: 10px 12px; border-bottom: 1px solid var(--line); }
779
+ .side .month { flex-basis: 100%; margin: 6px 4px 0; }
780
+ main { padding: 16px; }
781
+ .tiles { grid-template-columns: repeat(2, minmax(0, 1fr)); }
782
+ #q { width: auto; flex: 1; }
783
+ }
784
+ @media print {
785
+ .top, .side, .toc, .pager, .results, .foot, .drawer { display: none !important; }
786
+ .layout { display: block; }
787
+ main { padding: 0; }
788
+ .card, .item { break-inside: avoid; }
789
+ }
790
+ """
791
+
792
+ _JS = r"""
793
+ (function () {
794
+ "use strict";
795
+ var root = document.documentElement;
796
+ var THEME_KEY = "git-worklog-theme";
797
+ root.className += " js";
798
+ // This runs in <head>, before the body is painted, so a remembered theme
799
+ // never flashes the other one first.
800
+ try {
801
+ var saved = localStorage.getItem(THEME_KEY);
802
+ if (saved === "light" || saved === "dark") { root.setAttribute("data-theme", saved); }
803
+ } catch (e) {
804
+ // Storage refused (a file:// policy, a private window): follow the system.
805
+ }
806
+
807
+ document.addEventListener("DOMContentLoaded", function () {
808
+ var q = document.getElementById("q");
809
+ var results = document.getElementById("results");
810
+ var units = null;
811
+ var timer = null;
812
+
813
+ function collect() {
814
+ units = [];
815
+ var found = document.querySelectorAll("article.day [data-unit]");
816
+ for (var i = 0; i < found.length; i++) {
817
+ var el = found[i];
818
+ var day = el.closest("article.day");
819
+ var item = el.closest("details.item");
820
+ var block = el.closest("section");
821
+ var title = item ? item.querySelector("summary .t") : block && block.querySelector("h2");
822
+ var unit = {
823
+ date: day.getAttribute("data-date"),
824
+ target: item ? item.id : (block && block.id) || day.id,
825
+ title: title ? title.textContent.trim() : "",
826
+ text: el.textContent.replace(/\s+/g, " ").trim()
827
+ };
828
+ // Lowercased once, here: a year of worklog is megabytes of text, and a
829
+ // case-insensitive regex over all of it on every keystroke is felt.
830
+ unit.hay = (unit.title + " " + unit.text).toLowerCase();
831
+ units.push(unit);
832
+ }
833
+ }
834
+
835
+ function escapeRe(s) {
836
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
837
+ }
838
+
839
+ function snippet(text, any) {
840
+ any.lastIndex = 0;
841
+ var first = any.exec(text);
842
+ var at = first ? first.index : 0;
843
+ var start = Math.max(0, at - 60);
844
+ var end = Math.min(text.length, at + 160);
845
+ var piece = (start > 0 ? "…" : "") + text.slice(start, end) + (end < text.length ? "…" : "");
846
+ var span = document.createElement("span");
847
+ span.className = "snip";
848
+ var last = 0;
849
+ var m;
850
+ any.lastIndex = 0;
851
+ while ((m = any.exec(piece)) !== null) {
852
+ span.appendChild(document.createTextNode(piece.slice(last, m.index)));
853
+ var mark = document.createElement("mark");
854
+ mark.textContent = m[0];
855
+ span.appendChild(mark);
856
+ last = m.index + m[0].length;
857
+ }
858
+ span.appendChild(document.createTextNode(piece.slice(last)));
859
+ return span;
860
+ }
861
+
862
+ function line(tag, cls, text) {
863
+ var el = document.createElement(tag);
864
+ if (cls) { el.className = cls; }
865
+ el.textContent = text;
866
+ return el;
867
+ }
868
+
869
+ function search(query) {
870
+ var terms = query.split(/\s+/).filter(function (t) { return t; });
871
+ while (results.firstChild) { results.removeChild(results.firstChild); }
872
+ if (!terms.length) {
873
+ document.body.classList.remove("searching");
874
+ results.hidden = true;
875
+ return;
876
+ }
877
+ if (!units) { collect(); }
878
+ var needles = terms.map(function (t) { return t.toLowerCase(); });
879
+ var any = new RegExp(terms.map(escapeRe).join("|"), "gi");
880
+ var hits = units.filter(function (u) {
881
+ return needles.every(function (t) { return u.hay.indexOf(t) !== -1; });
882
+ });
883
+ document.body.classList.add("searching");
884
+ results.hidden = false;
885
+ results.appendChild(line("h1", "", hits.length + (hits.length === 1 ? " match" : " matches")
886
+ + " for “" + query + "”"));
887
+ var date = null;
888
+ hits.slice(0, 200).forEach(function (u) {
889
+ if (u.date !== date) {
890
+ date = u.date;
891
+ results.appendChild(line("h2", "", date));
892
+ }
893
+ var a = document.createElement("a");
894
+ a.className = "hit";
895
+ a.href = "#" + u.target;
896
+ a.appendChild(line("span", "hit-title", u.title || date));
897
+ a.appendChild(snippet(u.text, any));
898
+ results.appendChild(a);
899
+ });
900
+ if (!hits.length) {
901
+ results.appendChild(line("p", "muted", "No matches. Every word has to appear, in any order."));
902
+ } else if (hits.length > 200) {
903
+ results.appendChild(line("p", "muted", "Showing the first 200. Add a word to narrow it down."));
904
+ }
905
+ }
906
+
907
+ function currentDay() {
908
+ var el = location.hash.length > 1 && document.getElementById(location.hash.slice(1));
909
+ return el ? el.closest("article.day") : null;
910
+ }
911
+
912
+ function markNav() {
913
+ var day = currentDay();
914
+ var want = "#" + (day ? day.id : "overview");
915
+ var links = document.querySelectorAll(".side a");
916
+ for (var i = 0; i < links.length; i++) {
917
+ if (links[i].getAttribute("href") === want) {
918
+ links[i].setAttribute("aria-current", "page");
919
+ } else {
920
+ links[i].removeAttribute("aria-current");
921
+ }
922
+ }
923
+ }
924
+
925
+ function clearSearch() {
926
+ if (q.value) { q.value = ""; search(""); }
927
+ }
928
+
929
+ q.addEventListener("input", function () {
930
+ clearTimeout(timer);
931
+ timer = setTimeout(function () { search(q.value.trim()); }, 120);
932
+ });
933
+ q.addEventListener("keydown", function (e) {
934
+ if (e.key === "Escape") { clearSearch(); q.blur(); }
935
+ });
936
+ function reveal(scroll) {
937
+ var el = location.hash.length > 1 && document.getElementById(location.hash.slice(1));
938
+ if (el && el.tagName === "DETAILS") { el.open = true; }
939
+ // Coming from search, the browser scrolled while the target was still
940
+ // hidden under the results, so it did not move at all. Scroll again now.
941
+ if (el && scroll) { el.scrollIntoView(); }
942
+ }
943
+
944
+ results.addEventListener("click", function (e) {
945
+ var a = e.target.closest("a.hit");
946
+ if (a && a.hash === location.hash) { clearSearch(); reveal(true); }
947
+ });
948
+ document.addEventListener("keydown", function (e) {
949
+ if (e.key === "Escape" && !drawer.hidden) { closeCommit(); return; }
950
+ var tag = e.target && e.target.tagName;
951
+ if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") { return; }
952
+ if (e.metaKey || e.ctrlKey || e.altKey) { return; }
953
+ if (e.key === "/") { e.preventDefault(); q.focus(); return; }
954
+ if (!drawer.hidden) { return; } // the arrows scroll a diff, not the days
955
+ if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") { return; }
956
+ var day = currentDay();
957
+ var to = day && day.getAttribute(e.key === "ArrowLeft" ? "data-prev" : "data-next");
958
+ if (to) { e.preventDefault(); location.hash = to; }
959
+ });
960
+ window.addEventListener("hashchange", function () {
961
+ var searching = !!q.value;
962
+ closeCommit();
963
+ clearSearch();
964
+ reveal(searching);
965
+ markNav();
966
+ });
967
+ // A commit hash is a button the renderer made. Clicking it opens the commit
968
+ // when Git knew the hash as this page was built, and copies it otherwise.
969
+ var data = { commits: {}, tokens: {} };
970
+ var drawer = document.getElementById("commit");
971
+ var diffBase = root.getAttribute("data-diff");
972
+ var diffs = {};
973
+ var opener = null;
974
+ var shown = null;
975
+ try {
976
+ var blob = document.getElementById("commits");
977
+ if (blob) { data = JSON.parse(blob.textContent); }
978
+ } catch (e) {
979
+ // Unreadable data: every badge keeps copying.
980
+ }
981
+
982
+ function commitOf(badge) {
983
+ var sha = data.tokens[badge.getAttribute("data-hash")];
984
+ return sha ? data.commits[sha] : null;
985
+ }
986
+
987
+ function closeCommit() {
988
+ if (drawer.hidden) { return; }
989
+ drawer.hidden = true;
990
+ shown = null;
991
+ while (drawer.firstChild) { drawer.removeChild(drawer.firstChild); }
992
+ if (opener && document.body.contains(opener)) { opener.focus({ preventScroll: true }); }
993
+ opener = null;
994
+ }
995
+
996
+ function patchInto(pre, text) {
997
+ var code = document.createElement("code");
998
+ var hunk = false;
999
+ text.replace(/\n$/, "").split("\n").forEach(function (l) {
1000
+ var cls = "m";
1001
+ if (l.slice(0, 2) === "@@") { hunk = true; cls = "h"; }
1002
+ else if (hunk) { cls = l[0] === "+" ? "a" : l[0] === "-" ? "d" : ""; }
1003
+ code.appendChild(line("span", cls, l || " "));
1004
+ });
1005
+ pre.appendChild(code);
1006
+ }
1007
+
1008
+ function loadDiff(sha) {
1009
+ if (!diffs[sha]) {
1010
+ diffs[sha] = fetch(diffBase + sha).then(function (r) {
1011
+ if (!r.ok) { throw new Error(r.status); }
1012
+ return r.json();
1013
+ });
1014
+ diffs[sha].catch(function () { delete diffs[sha]; }); // let a reopen retry
1015
+ }
1016
+ return diffs[sha];
1017
+ }
1018
+
1019
+ function fileRow(f, served) {
1020
+ var row = document.createElement(served ? "summary" : "div");
1021
+ row.appendChild(line("span", "path", f.old_path ? f.old_path + " → " + f.path : f.path));
1022
+ if (f.additions === null) {
1023
+ row.appendChild(line("span", "bin", "bin"));
1024
+ } else {
1025
+ row.appendChild(line("span", "add", "+" + f.additions));
1026
+ row.appendChild(line("span", "del", "−" + f.deletions));
1027
+ }
1028
+ if (!served) { row.className = "file"; return row; }
1029
+ var box = document.createElement("details");
1030
+ box.className = "file";
1031
+ box.appendChild(row);
1032
+ return box;
1033
+ }
1034
+
1035
+ function openCommit(c, badge) {
1036
+ closeCommit();
1037
+ opener = badge;
1038
+ shown = c.sha;
1039
+ var head = line("div", "drawer-head", "");
1040
+ var copy = line("button", "hash", c.short);
1041
+ copy.type = "button";
1042
+ copy.setAttribute("data-hash", c.sha);
1043
+ copy.title = "Copy " + c.sha;
1044
+ // Two overlapping sheets: here the hash is copied, not opened.
1045
+ var svg = "http://www.w3.org/2000/svg";
1046
+ var icon = document.createElementNS(svg, "svg");
1047
+ var sheets = document.createElementNS(svg, "path");
1048
+ icon.setAttribute("viewBox", "0 0 24 24");
1049
+ icon.setAttribute("aria-hidden", "true");
1050
+ sheets.setAttribute("d", "M9 9h11v11H9zM5 15H4V4h11v1");
1051
+ icon.appendChild(sheets);
1052
+ copy.appendChild(icon);
1053
+ var close = line("button", "close", "×");
1054
+ close.type = "button";
1055
+ close.setAttribute("aria-label", "Close");
1056
+ head.appendChild(copy);
1057
+ head.appendChild(close);
1058
+ drawer.appendChild(head);
1059
+ var title = line("h2", "", c.subject);
1060
+ title.id = "commit-title";
1061
+ drawer.appendChild(title);
1062
+ drawer.appendChild(line("p", "", c.author + " · " + c.date.slice(0, 16).replace("T", " ")));
1063
+ if (c.body) {
1064
+ drawer.appendChild(line("pre", "msg", c.body + (c.body_truncated ? "\n…" : "")));
1065
+ }
1066
+ drawer.appendChild(line("p", "stat", c.file_count + (c.file_count === 1 ? " file" : " files")
1067
+ + " · +" + c.additions + " −" + c.deletions));
1068
+ var boxes = c.files.map(function (f) {
1069
+ var box = fileRow(f, !!diffBase);
1070
+ drawer.appendChild(box);
1071
+ return box;
1072
+ });
1073
+ if (c.file_count > c.files.length) {
1074
+ drawer.appendChild(line("p", "note", (c.file_count - c.files.length) + " more files are not listed."));
1075
+ }
1076
+ drawer.hidden = false;
1077
+ drawer.scrollTop = 0;
1078
+ drawer.focus();
1079
+ if (!diffBase) {
1080
+ if (c.file_count) {
1081
+ drawer.appendChild(line("p", "note", "Diffs are served live — run git-worklog view --serve."));
1082
+ }
1083
+ return;
1084
+ }
1085
+ loadDiff(c.sha).then(function (res) {
1086
+ if (shown !== c.sha) { return; }
1087
+ if (res.truncated) {
1088
+ drawer.appendChild(line("p", "note", "This diff is too large to show in full. Run git show " + c.short + "."));
1089
+ }
1090
+ if (!res.truncated && res.files.length === boxes.length) {
1091
+ // One patch per listed file, in Git's order. Lines become elements
1092
+ // only when a file is opened: a commit can carry tens of thousands.
1093
+ boxes.forEach(function (box, i) {
1094
+ box.addEventListener("toggle", function () {
1095
+ if (!box.open || box.lastChild.tagName === "PRE") { return; }
1096
+ var pre = line("pre", "diff", "");
1097
+ patchInto(pre, res.files[i]);
1098
+ box.appendChild(pre);
1099
+ });
1100
+ });
1101
+ return;
1102
+ }
1103
+ boxes.forEach(function (box) { box.appendChild(line("p", "note", "Shown in the full patch below.")); });
1104
+ var all = line("pre", "diff", "");
1105
+ patchInto(all, res.files.join(""));
1106
+ drawer.appendChild(all);
1107
+ }, function () {
1108
+ if (shown !== c.sha) { return; }
1109
+ drawer.appendChild(line("p", "note", "The diff could not be loaded. Is git-worklog view --serve still running?"));
1110
+ });
1111
+ }
1112
+
1113
+ function copyText(text) {
1114
+ if (navigator.clipboard && window.isSecureContext) {
1115
+ return navigator.clipboard.writeText(text);
1116
+ }
1117
+ return new Promise(function (resolve, reject) {
1118
+ var area = document.createElement("textarea");
1119
+ area.value = text;
1120
+ area.setAttribute("readonly", "");
1121
+ area.style.position = "fixed";
1122
+ area.style.opacity = "0";
1123
+ document.body.appendChild(area);
1124
+ area.select();
1125
+ var done = false;
1126
+ try { done = document.execCommand("copy"); } catch (e) { done = false; }
1127
+ document.body.removeChild(area);
1128
+ if (done) { resolve(); } else { reject(); }
1129
+ });
1130
+ }
1131
+
1132
+ document.addEventListener("click", function (e) {
1133
+ if (!e.target.closest) { return; }
1134
+ var inside = e.target.closest("#commit");
1135
+ if (e.target.closest("#commit .close")) { closeCommit(); return; }
1136
+ var badge = e.target.closest("button.hash");
1137
+ if (!badge) {
1138
+ if (!inside) { closeCommit(); }
1139
+ return;
1140
+ }
1141
+ e.preventDefault(); // in a work item's title, act without folding the card
1142
+ var commit = inside ? null : commitOf(badge);
1143
+ if (commit) { openCommit(commit, badge); return; }
1144
+ var hash = badge.getAttribute("data-hash");
1145
+ copyText(hash).then(function () {
1146
+ badge.classList.add("copied");
1147
+ badge.title = "Copied " + hash;
1148
+ clearTimeout(badge.copiedTimer);
1149
+ badge.copiedTimer = setTimeout(function () {
1150
+ badge.classList.remove("copied");
1151
+ badge.title = "Copy " + hash;
1152
+ }, 1200);
1153
+ }, function () {
1154
+ // No clipboard here: leave the hash selected so Ctrl/Cmd+C finishes it.
1155
+ var range = document.createRange();
1156
+ range.selectNodeContents(badge);
1157
+ var selection = window.getSelection();
1158
+ selection.removeAllRanges();
1159
+ selection.addRange(range);
1160
+ badge.title = "Press Ctrl+C or Cmd+C to copy " + hash;
1161
+ });
1162
+ });
1163
+
1164
+ var toggle = document.getElementById("theme");
1165
+ var system = window.matchMedia("(prefers-color-scheme: dark)");
1166
+
1167
+ function isDark() {
1168
+ var chosen = root.getAttribute("data-theme");
1169
+ return chosen ? chosen === "dark" : system.matches;
1170
+ }
1171
+
1172
+ function paintToggle() {
1173
+ var dark = isDark();
1174
+ var label = dark ? "Switch to light theme" : "Switch to dark theme";
1175
+ toggle.classList.toggle("is-dark", dark);
1176
+ toggle.setAttribute("aria-label", label);
1177
+ toggle.title = label;
1178
+ }
1179
+
1180
+ toggle.addEventListener("click", function () {
1181
+ var next = isDark() ? "light" : "dark";
1182
+ root.setAttribute("data-theme", next);
1183
+ try { localStorage.setItem(THEME_KEY, next); } catch (e) { /* not remembered */ }
1184
+ paintToggle();
1185
+ });
1186
+ // Until the reader picks, the page follows the system; keep the icon honest.
1187
+ if (system.addEventListener) { system.addEventListener("change", paintToggle); }
1188
+ paintToggle();
1189
+ markNav();
1190
+ var badges = document.querySelectorAll("button.hash");
1191
+ for (var b = 0; b < badges.length; b++) {
1192
+ if (commitOf(badges[b])) {
1193
+ badges[b].title = "Show commit " + badges[b].getAttribute("data-hash");
1194
+ }
1195
+ }
1196
+ });
1197
+ })();
1198
+ """