| 69 break; |
69 break; |
| 70 } |
70 } |
| 71 } |
71 } |
| 72 return buffer; |
72 return buffer; |
| 73 } |
73 } |
| |
74 |
| |
75 static std::string escape_json(const std::string &raw) { |
| |
76 using std::string_view_literals::operator ""sv; |
| |
77 auto replace_all = [](std::string str, char chr, std::string_view repl) static { |
| |
78 size_t pos = str.find(chr); |
| |
79 if (pos == std::string::npos) return str; |
| |
80 std::string result = std::move(str); |
| |
81 do { |
| |
82 result.replace(pos, 1, repl); |
| |
83 pos += repl.length(); |
| |
84 } while ((pos = result.find(chr, pos)) != std::string::npos); |
| |
85 return result; |
| |
86 }; |
| |
87 return replace_all(replace_all(raw, '\\', "\\\\"), '\"', "\\\""sv); |
| |
88 } |
| |
89 |
| |
90 static std::string build_tag_list(fm::tag_lists tags, bool hide_repo_names) { |
| |
91 std::string tags_json; |
| |
92 if (hide_repo_names) { |
| |
93 for (const auto &tags_vector: tags | std::views::values) { |
| |
94 for (const auto &tag: tags_vector) { |
| |
95 tags_json += escape_json(tag); |
| |
96 tags_json += ' '; |
| |
97 } |
| |
98 } |
| |
99 if (!tags_json.empty()) { |
| |
100 tags_json.pop_back(); |
| |
101 } |
| |
102 } else { |
| |
103 tags_json += '{'; |
| |
104 for (const auto &[repo, tags_vector] : tags) { |
| |
105 tags_json += "\"" + escape_json(repo) + "\":\""; |
| |
106 for (const auto &tag: tags_vector) { |
| |
107 tags_json += escape_json(tag); |
| |
108 tags_json += ' '; |
| |
109 } |
| |
110 if (!tags_vector.empty()) { |
| |
111 tags_json.pop_back(); |
| |
112 } |
| |
113 tags_json += "\","; |
| |
114 } |
| |
115 tags_json.pop_back(); |
| |
116 if (!tags.empty()) { |
| |
117 tags_json += '}'; |
| |
118 } |
| |
119 } |
| |
120 return tags_json; |
| |
121 } |
| |
122 |
| |
123 static std::string build_tag_array(fm::tag_lists tags, bool hide_repo_names) { |
| |
124 std::string tags_json; |
| |
125 if (hide_repo_names) { |
| |
126 tags_json += '['; |
| |
127 for (const auto &tags_vector: tags | std::views::values) { |
| |
128 for (const auto &tag: tags_vector) { |
| |
129 tags_json += '"'; |
| |
130 tags_json += escape_json(tag); |
| |
131 tags_json += "\","; |
| |
132 } |
| |
133 } |
| |
134 if (!tags.empty()) { |
| |
135 tags_json.pop_back(); |
| |
136 } |
| |
137 tags_json += ']'; |
| |
138 } else { |
| |
139 tags_json += '{'; |
| |
140 for (const auto &[repo, tags_vector] : tags) { |
| |
141 tags_json += "\"" + escape_json(repo) + "\":["; |
| |
142 for (const auto &tag: tags_vector) { |
| |
143 tags_json += '"'; |
| |
144 tags_json += escape_json(tag); |
| |
145 tags_json += "\","; |
| |
146 } |
| |
147 if (!tags_vector.empty()) { |
| |
148 tags_json.pop_back(); |
| |
149 } |
| |
150 tags_json += "],"; |
| |
151 } |
| |
152 tags_json.pop_back(); |
| |
153 if (!tags.empty()) { |
| |
154 tags_json += '}'; |
| |
155 } |
| |
156 } |
| |
157 return tags_json; |
| |
158 } |
| 74 } |
159 } |
| 75 |
160 |
| 76 void html::styles_and_script() { |
161 void html::styles_and_script() { |
| 77 puts(R"( <style> |
162 puts(R"( <style> |
| 78 table.heatmap { |
163 table.heatmap { |
| 184 const popup = document.createElement('div'); |
273 const popup = document.createElement('div'); |
| 185 popup.className = 'commit-popup'; |
274 popup.className = 'commit-popup'; |
| 186 document.body.appendChild(popup); |
275 document.body.appendChild(popup); |
| 187 |
276 |
| 188 // Add click event listeners to all commit cells |
277 // Add click event listeners to all commit cells |
| 189 const cells = document.querySelectorAll('table.heatmap td:not(.out-of-range, .zero-commits)'); |
278 const cells = document.querySelectorAll('table.heatmap td:not(.out-of-range, .zero-commits), table.heatmap th:not(.zero-commits)'); |
| 190 cells.forEach(cell => { |
279 cells.forEach(cell => { |
| 191 cell.addEventListener('click', function(e) { |
280 cell.addEventListener('click', function(e) { |
| 192 const date = this.dataset.date; |
281 const date = this.dataset.date; |
| 193 const summaries = JSON.parse(this.dataset.summaries); |
|
| 194 |
282 |
| 195 // Create popup content |
283 // Create popup content |
| 196 let content = '<span class="close-btn">×</span>'; |
284 let content = '<span class="close-btn">×</span>'; |
| 197 if (Array.isArray(summaries)) { |
285 if (date === undefined) { |
| 198 content += `<h3>${date}: ${summaries.length} commit${summaries.length !== 1 ? 's' : ''}</h3>`; |
286 // monthly summary |
| 199 if (this.dataset.tags) { |
287 const total_commits = this.dataset.total; |
| 200 content += `<h5>Tags: ${this.dataset.tags}</h5>`; |
288 content += `<h3>Total: ${total_commits} commit${total_commits !== 1 ? 's' : ''}</h3>`; |
| |
289 if (this.dataset.commits === undefined) { |
| |
290 const tags = this.dataset.tags ? JSON.parse(this.dataset.tags) : []; |
| |
291 if (tags.length === 0) { |
| |
292 content += 'No tags.'; |
| |
293 } else { |
| |
294 content += '<ul>'; |
| |
295 tags.forEach(tag => { |
| |
296 content += `<li>${tag}</li>`; |
| |
297 }); |
| |
298 content += '</ul>'; |
| |
299 } |
| |
300 } else { |
| |
301 content += '<ul>'; |
| |
302 const commits = JSON.parse(this.dataset.commits); |
| |
303 const repos = Object.keys(commits).sort(); |
| |
304 const tags = this.dataset.tags ? JSON.parse(this.dataset.tags) : {}; |
| |
305 for (const repo of repos) { |
| |
306 content += `<li>${repo} (${commits[repo]} commit${commits[repo] !== 1 ? 's' : ''})</li>`; |
| |
307 if (tags[repo] && tags[repo].length > 0) { |
| |
308 content += '<ul>'; |
| |
309 tags[repo].forEach(tag => { |
| |
310 content += `<li>${tag}</li>`; |
| |
311 }); |
| |
312 content += '</ul>'; |
| |
313 } |
| |
314 } |
| |
315 content += '</ul>'; |
| 201 } |
316 } |
| 202 content += '<ul>'; |
|
| 203 summaries.forEach(summary => { |
|
| 204 content += `<li>${summary}</li>`; |
|
| 205 }); |
|
| 206 content += '</ul>'; |
|
| 207 } else { |
317 } else { |
| 208 const repos = Object.keys(summaries).sort(); |
318 const summaries = JSON.parse(this.dataset.summaries); |
| 209 const tags = this.dataset.tags ? JSON.parse(this.dataset.tags) : {}; |
319 if (Array.isArray(summaries)) { |
| 210 let total_commits = 0; |
320 const summaries = this.dataset.summaries ? JSON.parse(this.dataset.summaries) : undefined; |
| 211 for (const repo of repos) total_commits += summaries[repo].length; |
321 content += `<h3>${date}: ${summaries.length} commit${summaries.length !== 1 ? 's' : ''}</h3>`; |
| 212 content += `<h3>${date}: ${total_commits} commit${total_commits !== 1 ? 's' : ''}</h3>`; |
322 if (this.dataset.tags) { |
| 213 for (const repo of repos) { |
323 content += `<h5>Tags: ${this.dataset.tags}</h5>`; |
| 214 const commits = summaries[repo]; |
|
| 215 if (repos.length > 1) { |
|
| 216 content += `<h4>${repo} (${commits.length} commit${commits.length !== 1 ? 's' : ''})</h4>`; |
|
| 217 } else { |
|
| 218 content += `<h4>${repo}</h4>`; |
|
| 219 } |
|
| 220 if (tags[repo]) { |
|
| 221 content += `<h5>Tags: ${tags[repo]}</h5>`; |
|
| 222 } |
324 } |
| 223 content += '<ul>'; |
325 content += '<ul>'; |
| 224 commits.forEach(commit => { |
326 summaries.forEach(summary => { |
| 225 content += `<li>${commit}</li>`; |
327 content += `<li>${summary}</li>`; |
| 226 }); |
328 }); |
| 227 content += '</ul>'; |
329 content += '</ul>'; |
| |
330 } else { |
| |
331 const repos = Object.keys(summaries).sort(); |
| |
332 const tags = this.dataset.tags ? JSON.parse(this.dataset.tags) : {}; |
| |
333 const total_commits = this.dataset.total; |
| |
334 content += `<h3>${date}: ${total_commits} commit${total_commits !== 1 ? 's' : ''}</h3>`; |
| |
335 for (const repo of repos) { |
| |
336 const commits = summaries[repo]; |
| |
337 if (repos.length > 1) { |
| |
338 content += `<h4>${repo} (${commits.length} commit${commits.length !== 1 ? 's' : ''})</h4>`; |
| |
339 } else { |
| |
340 content += `<h4>${repo}</h4>`; |
| |
341 } |
| |
342 if (tags[repo]) { |
| |
343 content += `<h5>Tags: ${tags[repo]}</h5>`; |
| |
344 } |
| |
345 content += '<ul>'; |
| |
346 commits.forEach(commit => { |
| |
347 content += `<li>${commit}</li>`; |
| |
348 }); |
| |
349 content += '</ul>'; |
| |
350 } |
| 228 } |
351 } |
| 229 } |
352 } |
| 230 popup.innerHTML = content; |
353 popup.innerHTML = content; |
| 231 |
354 |
| 232 // Position popup near the cell |
355 // Position popup near the cell |
| 316 printf("<h2 title=\"Total commits: %u\">%s</h2>\n", |
439 printf("<h2 title=\"Total commits: %u\">%s</h2>\n", |
| 317 total_commits, |
440 total_commits, |
| 318 encode(author).c_str()); |
441 encode(author).c_str()); |
| 319 } |
442 } |
| 320 |
443 |
| 321 void html::table_begin(year y, const std::array<unsigned int, 12> &commits_per_month) { |
444 void html::table_begin(year y, bool hide_repo_names, const std::array<fm::commit_summary, 12> &commits_per_month) { |
| 322 static constexpr const char* months[] = {"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"}; |
445 static constexpr const char* months[] = {"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"}; |
| 323 // compute the column spans, first |
446 // compute the column spans, first |
| 324 unsigned colspans[12] = {}; |
447 unsigned colspans[12] = {}; |
| 325 { |
448 { |
| 326 unsigned total_cols = 0; |
449 unsigned total_cols = 0; |
| 327 sys_days day{year_month_day{y, January, 1d}}; |
450 sys_days day{year_month_day{y, January, 1d}}; |
| 328 for (unsigned col = 0; col < 12; ++col) { |
451 for (unsigned col = 0; col < 12; ++col) { |
| 329 while (total_cols < html::columns && year_month_day{day}.month() <= month{col + 1}) { |
452 while (total_cols < columns && year_month_day{day}.month() <= month{col + 1}) { |
| 330 ++total_cols; |
453 ++total_cols; |
| 331 ++colspans[col]; |
454 ++colspans[col]; |
| 332 day += days{7}; |
455 day += days{7}; |
| 333 } |
456 } |
| 334 } |
457 } |
| 341 puts("<tr>"); |
464 puts("<tr>"); |
| 342 indent(1); |
465 indent(1); |
| 343 puts("<th></th>"); |
466 puts("<th></th>"); |
| 344 for (unsigned i = 0 ; i < 12 ; i++) { |
467 for (unsigned i = 0 ; i < 12 ; i++) { |
| 345 indent(); |
468 indent(); |
| 346 printf("<th scope=\"col\" title=\"Total commits: %u\" colspan=\"%d\">%s</th>\n", |
469 const fm::commit_summary &summary = commits_per_month[i]; |
| 347 commits_per_month[i], colspans[i], months[i]); |
470 const unsigned total = summary.count(); |
| |
471 if (total > 0) { |
| |
472 std::string commit_summary; |
| |
473 if (!hide_repo_names) { |
| |
474 std::string commit_summary_json; |
| |
475 commit_summary_json += '{'; |
| |
476 for (const auto &[repo, count] : summary.commits) { |
| |
477 commit_summary_json += std::format("\"{}\": {},", escape_json(repo), count); |
| |
478 } |
| |
479 if (!summary.commits.empty()) { |
| |
480 commit_summary_json.pop_back(); |
| |
481 } |
| |
482 commit_summary_json += '}'; |
| |
483 commit_summary = std::format("data-commits=\"{}\"", encode(commit_summary_json)); |
| |
484 } |
| |
485 std::string tags = build_tag_array(summary.tags_with_date, hide_repo_names); |
| |
486 printf("<th scope=\"col\" title=\"Total commits: %u\" colspan=\"%d\" data-total=\"%u\" %s data-tags=\"%s\">%s</th>\n", |
| |
487 total, colspans[i], total, |
| |
488 commit_summary.c_str(), |
| |
489 encode(tags).c_str(), months[i]); |
| |
490 } else { |
| |
491 printf("<th scope=\"col\" class=\"zero-commits\" colspan=\"%d\">%s</th>\n", |
| |
492 colspans[i], months[i]); |
| |
493 } |
| 348 } |
494 } |
| 349 indent(-1); |
495 indent(-1); |
| 350 puts("</tr>"); |
496 puts("</tr>"); |
| 351 } |
497 } |
| 352 |
498 |
| 386 color_class = "up-to-20-commits"; |
532 color_class = "up-to-20-commits"; |
| 387 } else { |
533 } else { |
| 388 color_class = "commit-spam"; |
534 color_class = "commit-spam"; |
| 389 } |
535 } |
| 390 |
536 |
| 391 |
|
| 392 // Format date for display |
|
| 393 char date_str[32]; |
|
| 394 sprintf(date_str, "%s, %d-%02u-%02u", |
|
| 395 weekdays[weekday(ymd).iso_encoding() - 1], |
|
| 396 static_cast<int>(ymd.year()), |
|
| 397 static_cast<unsigned>(ymd.month()), |
|
| 398 static_cast<unsigned>(ymd.day())); |
|
| 399 |
|
| 400 // Utility function to escape strings in JSON |
|
| 401 auto escape_json = [](std::string raw) static { |
|
| 402 using std::string_view_literals::operator ""sv; |
|
| 403 auto replace_all = [](std::string str, char chr, std::string_view repl) static { |
|
| 404 size_t pos = str.find(chr); |
|
| 405 if (pos == std::string::npos) return str; |
|
| 406 std::string result = std::move(str); |
|
| 407 do { |
|
| 408 result.replace(pos, 1, repl); |
|
| 409 pos += repl.length(); |
|
| 410 } while ((pos = result.find(chr, pos)) != std::string::npos); |
|
| 411 return result; |
|
| 412 }; |
|
| 413 return replace_all(replace_all(std::move(raw), '\\', "\\\\"), '\"', "\\\""sv); |
|
| 414 }; |
|
| 415 |
|
| 416 // Build a JSON object of commit summaries |
537 // Build a JSON object of commit summaries |
| 417 auto add_summaries = [escape_json](std::string &json, const std::vector<std::string> &summaries) { |
538 auto add_summaries = [](std::string &json, const std::vector<std::string> &summaries) static { |
| 418 // We have to iterate in reverse order to sort the summaries chronologically |
539 // We have to iterate in reverse order to sort the summaries chronologically |
| 419 for (const auto &summary : summaries | std::views::reverse) { |
540 for (const auto &summary : summaries | std::views::reverse) { |
| 420 json += "\"" + escape_json(summary) + "\","; |
541 json += "\"" + escape_json(summary) + "\","; |
| 421 } |
542 } |
| 422 json.pop_back(); |
543 json.pop_back(); |
| 440 } |
561 } |
| 441 summaries_json += '}'; |
562 summaries_json += '}'; |
| 442 } |
563 } |
| 443 |
564 |
| 444 // Build a JSON object of tags |
565 // Build a JSON object of tags |
| 445 std::string tags_json; |
566 std::string tags_json = build_tag_list(commits.tags, hide_repo_names); |
| 446 if (hide_repo_names) { |
|
| 447 for (const auto &tags_vector: commits.tags | std::views::values) { |
|
| 448 for (const auto &tag: tags_vector) { |
|
| 449 tags_json += escape_json(tag); |
|
| 450 tags_json += ' '; |
|
| 451 } |
|
| 452 } |
|
| 453 if (!tags_json.empty()) { |
|
| 454 tags_json.pop_back(); |
|
| 455 } |
|
| 456 } else { |
|
| 457 tags_json += '{'; |
|
| 458 for (const auto &[repo, tags_vector] : commits.tags) { |
|
| 459 tags_json += "\"" + escape_json(repo) + "\":\""; |
|
| 460 for (const auto &tag: tags_vector) { |
|
| 461 tags_json += escape_json(tag); |
|
| 462 tags_json += ' '; |
|
| 463 } |
|
| 464 if (!tags_vector.empty()) { |
|
| 465 tags_json.pop_back(); |
|
| 466 } |
|
| 467 tags_json += "\","; |
|
| 468 } |
|
| 469 // note: in contrast to summaries, we want an empty string here when there's nothing to report |
|
| 470 tags_json.pop_back(); |
|
| 471 if (!commits.tags.empty()) { |
|
| 472 tags_json += '}'; |
|
| 473 } |
|
| 474 } |
|
| 475 |
567 |
| 476 // Output |
568 // Output |
| 477 indent(); |
569 std::string date_str = std::format("{}, {}-{:02d}-{:02d}", |
| 478 printf("<td class=\"%s\" title=\"%s: %u %s\" data-date=\"%s\" data-summaries=\"%s\" data-tags=\"%s\"></td>\n", |
570 weekdays[weekday(ymd).iso_encoding() - 1], |
| |
571 static_cast<int>(ymd.year()), |
| |
572 static_cast<unsigned>(ymd.month()), |
| |
573 static_cast<unsigned>(ymd.day())); |
| |
574 const unsigned total = commits.count(); |
| |
575 indent(); |
| |
576 printf("<td class=\"%s\" title=\"%s: %u %s\" data-total=\"%u\" data-date=\"%s\" data-summaries=\"%s\" data-tags=\"%s\"></td>\n", |
| 479 color_class, |
577 color_class, |
| 480 date_str, |
578 date_str.c_str(), |
| 481 commits.count(), |
579 total, |
| 482 commits.count() == 1 ? "commit" : "commits", |
580 total == 1 ? "commit" : "commits", |
| 483 date_str, |
581 total, |
| |
582 date_str.c_str(), |
| 484 encode(summaries_json).c_str(), |
583 encode(summaries_json).c_str(), |
| 485 encode(tags_json).c_str() |
584 encode(tags_json).c_str() |
| 486 ); |
585 ); |
| 487 } |
586 } |