src/html.cpp

changeset 75
857af79337d5
parent 73
707f42bb0484
equal deleted inserted replaced
74:bae9922f4681 75:857af79337d5
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 {
94 width: 1rem; 179 width: 1rem;
95 } 180 }
96 181
97 table.heatmap td:hover, table.heatmap td.popup-open { 182 table.heatmap td:hover, table.heatmap td.popup-open {
98 filter: hue-rotate(90deg); 183 filter: hue-rotate(90deg);
184 }
185
186 table.heatmap th:hover[data-total], table.heatmap th.popup-open {
187 background-color: #B3E7F2;
99 } 188 }
100 189
101 table.heatmap td.out-of-range { 190 table.heatmap td.out-of-range {
102 background-color: gray; 191 background-color: gray;
103 } 192 }
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
234 popup.style.left = rect.left + window.scrollX + 'px'; 357 popup.style.left = rect.left + window.scrollX + 'px';
235 popup.style.top = (rect.bottom + window.scrollY + 5) + 'px'; 358 popup.style.top = (rect.bottom + window.scrollY + 5) + 'px';
236 popup.style.display = 'block'; 359 popup.style.display = 'block';
237 360
238 // Highlight the cell to which the popup belongs 361 // Highlight the cell to which the popup belongs
239 document.querySelectorAll('table.heatmap td.popup-open').forEach(old_cell => { 362 document.querySelectorAll('table.heatmap .popup-open').forEach(old_cell => {
240 old_cell.classList.toggle("popup-open"); 363 old_cell.classList.toggle("popup-open");
241 }); 364 });
242 cell.classList.toggle("popup-open"); 365 cell.classList.toggle("popup-open");
243 366
244 // Add close button handler 367 // Add close button handler
256 379
257 // Close popup when clicking elsewhere 380 // Close popup when clicking elsewhere
258 document.addEventListener('click', function(e) { 381 document.addEventListener('click', function(e) {
259 if (!popup.contains(e.target)) { 382 if (!popup.contains(e.target)) {
260 popup.style.display = 'none'; 383 popup.style.display = 'none';
261 document.querySelectorAll('table.heatmap td.popup-open').forEach(cell => { 384 document.querySelectorAll('table.heatmap .popup-open').forEach(cell => {
262 cell.classList.toggle("popup-open"); 385 cell.classList.toggle("popup-open");
263 }); 386 });
264 } 387 }
265 }); 388 });
266 }); 389 });
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 }

mercurial