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/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
|
+
"""
|