Wed, 25 Feb 2026 22:33:46 +0100
add CSV export of raw data - resolves #806
| CHANGELOG | file | annotate | diff | comparison | revisions | |
| src/main.cpp | file | annotate | diff | comparison | revisions | |
| src/settings.h | file | annotate | diff | comparison | revisions |
--- a/CHANGELOG Wed Feb 25 22:32:55 2026 +0100 +++ b/CHANGELOG Wed Feb 25 22:33:46 2026 +0100 @@ -3,6 +3,7 @@ - Add --styles-and-script option to output the default CSS and Javascript - Add monthly summaries - Add reading commit hashes from the commit log +- Add options to create a CSV export of the raw data Version 1.1.2 - 2025-12-15
--- a/src/main.cpp Wed Feb 25 22:32:55 2026 +0100 +++ b/src/main.cpp Wed Feb 25 22:33:46 2026 +0100 @@ -35,6 +35,7 @@ #include <cerrno> #include <numeric> +#include <ranges> using namespace std::chrono; @@ -47,8 +48,10 @@ " -a, --author <name> Only report this author\n" " (repeat option to report multiple authors)\n" " -A, --authormap <file> Apply an author mapping file\n" + " --csv Output the gathered data as CSV\n" + " --csv-path <file> Write CSV data to file instead of stdout\n" " -d, --depth <num> The search depth (default: 1, max: 255)\n" - " -f, --fragment [indent] Output as fragment\n" + " -f, --fragment [indent] Output as fragment (HTML only)\n" " -h, --help Print this help message\n" " -p, --pull Try to pull the repositories\n" " -s, --separate Output a separate heat map for each repository\n" @@ -91,7 +94,10 @@ "container (default is 0 and maximum is 12).\n" "When you want to combine this with the default style and scripts, you can use\n" "the \033[1m--styles-and-script\033[22m option print the defaults to stdout and redirect them\n" - "into a file when you are composing your custom HTML page.\n" + "into a file when you are composing your custom HTML page.\n\n" + "In case you want to work with the raw data, you can advise the tool to output\n" + "the data in CSV format with the \033[1m--csv\033[22m option. The CSV data will be written to\n" + "stdout instead of the HTML unless you specify a different file with \033[1m--csv-path\033[22m.\n" , stderr); } @@ -136,6 +142,15 @@ fputs("missing author name\n", stderr); return -1; } + } else if (chk_arg(argv[i], "--csv", nullptr)) { + settings.csv = true; + } else if (chk_arg(argv[i], "--csv-path", nullptr)) { + if (i + 1 < argc) { + settings.csv_path.assign(argv[++i]); + } else { + fputs("missing csv path\n", stderr); + return -1; + } } else if (chk_arg(argv[i], "-p", "--pull")) { settings.update_repos = true; } else if (chk_arg(argv[i], "-f", "--fragment")) { @@ -196,6 +211,147 @@ return 0; } +static void generate_html( + const fm::settings &settings, + year report_year, year_month_day report_begin, year_month_day report_end, + const fm::heatmap &heatmap) { + + html::open(settings.fragment, settings.fragment_indent); + for (const auto &[repo, authors] : heatmap.data()) { + bool h1_rendered = false; + for (const auto &[author, entries] : authors) { + if (settings.exclude_author(author)) continue; + if (!h1_rendered) { + html::heading_repo(repo); + h1_rendered = true; + } + html::chart_begin(repo, author); + + const auto commits_per_month = heatmap.commits_per_month(repo, author, report_year); + const auto total_commits = std::accumulate(commits_per_month.begin(), commits_per_month.end(), 0u, + [](unsigned sum, const auto &summary) { return sum + summary.count(); }); + html::heading_author(author, total_commits); + html::table_begin(report_year, settings.separate, commits_per_month); + + // initialize counters + unsigned column = 0, row = 0; + + // initialize the first day (which must be a Monday, possibly the year before) + sys_days day_to_check = January / Monday[1] / report_year; + if (year_month_day{day_to_check}.day() != 1d) { + day_to_check -= days{7}; + } + + // remember the starting point + auto start = day_to_check; + + // now add all entries for Monday, Tuesdays, etc. always starting back in January + while (true) { + html::row_begin(row); + + // check if we need to add blank cells + while (day_to_check < report_begin) { + html::cell_out_of_range(); + day_to_check += days{7}; + column++; + } + + while (day_to_check <= report_end) { + // get the entry from the heatmap + auto find_result = entries.find(day_to_check); + if (find_result == entries.end()) { + html::cell(day_to_check); + } else { + html::cell(day_to_check, settings.separate, find_result->second); + } + // advance seven days and one column + day_to_check += days{7}; + column++; + } + // fill remaining columns with blank cells + for (unsigned i = column ; i < html::columns ; i++) { + html::cell_out_of_range(); + } + + // terminate the row + html::row_end(); + + // if we have seen all seven weekdays, that's it + if (++row == 7) break; + + // otherwise, advance the starting point by one day, reset, and begin a new row + start += days{1}; + day_to_check = start; + column =0; + } + + html::table_end(); + html::chart_end(); + } + } + html::close(settings.fragment); +} + +static int generate_csv(const fm::settings &settings, const fm::heatmap &heatmap) { + FILE *out; + if (settings.csv_path.empty()) { + out = stdout; + } else { + out = fopen(settings.csv_path.c_str(), "w"); + if (out == nullptr) return 1; + } + + auto escape_csv = [](const std::string &str) -> std::string { + std::string result; + result.reserve(str.size()+2); + result += '"'; + for (char c : str) { + if (c == '"') { + result += '"'; + } + result += c; + } + result += '"'; + return result; + }; + + fprintf(out, "repository,author,date,hash,summary,is_tag\n"); + for (const auto &authors: heatmap.data() | std::views::values) { + for (const auto &[author, entries]: authors) { + for (const auto &[date, commits]: entries) { + for (const auto &[repo, infos] : commits.summaries) { + for (const auto &info : infos) { + fprintf(out, "%s,%s,%d-%02u-%02u,%s,%s,false\n", + repo.c_str(), + author.c_str(), + static_cast<int>(date.year()), + static_cast<unsigned>(date.month()), + static_cast<unsigned>(date.day()), + info.hash.c_str(), + escape_csv(info.message).c_str() + ); + } + } + for (const auto &[repo, infos] : commits.tags) { + for (const auto &info : infos) { + fprintf(out, "%s,%s,%d-%02u-%02u,%s,%s,true\n", + repo.c_str(), + author.c_str(), + static_cast<int>(date.year()), + static_cast<unsigned>(date.month()), + static_cast<unsigned>(date.day()), + info.hash.c_str(), + escape_csv(info.message).c_str() + ); + } + } + } + } + } + fclose(out); + return 0; +} + int main(int argc, char *argv[]) { // parse settings fm::settings settings; @@ -297,80 +453,16 @@ } } - html::open(settings.fragment, settings.fragment_indent); - for (const auto &[repo, authors] : heatmap.data()) { - bool h1_rendered = false; - for (const auto &[author, entries] : authors) { - if (settings.exclude_author(author)) continue; - if (!h1_rendered) { - html::heading_repo(repo); - h1_rendered = true; - } - html::chart_begin(repo, author); - - const auto commits_per_month = heatmap.commits_per_month(repo, author, report_year); - const auto total_commits = std::accumulate(commits_per_month.begin(), commits_per_month.end(), 0u, - [](unsigned sum, const auto &summary) { return sum + summary.count(); }); - html::heading_author(author, total_commits); - html::table_begin(report_year, settings.separate, commits_per_month); - - // initialize counters - unsigned column = 0, row = 0; - - // initialize the first day (which must be a Monday, possibly the year before) - sys_days day_to_check = January / Monday[1] / report_year; - if (year_month_day{day_to_check}.day() != 1d) { - day_to_check -= days{7}; - } - - // remember the starting point - auto start = day_to_check; - - // now add all entries for Monday, Tuesdays, etc. always starting back in January - while (true) { - html::row_begin(row); - - // check if we need to add blank cells - while (day_to_check < report_begin) { - html::cell_out_of_range(); - day_to_check += days{7}; - column++; - } - - while (day_to_check <= report_end) { - // get the entry from the heatmap - auto find_result = entries.find(day_to_check); - if (find_result == entries.end()) { - html::cell(day_to_check); - } else { - html::cell(day_to_check, settings.separate, find_result->second); - } - // advance seven days and one column - day_to_check += days{7}; - column++; - } - // fill remaining columns with blank cells - for (unsigned i = column ; i < html::columns ; i++) { - html::cell_out_of_range(); - } - - // terminate the row - html::row_end(); - - // if we have seen all seven weekdays, that's it - if (++row == 7) break; - - // otherwise, advance the starting point by one day, reset, and begin a new row - start += days{1}; - day_to_check = start; - column =0; - } - - html::table_end(); - html::chart_end(); + bool html = true; + if (settings.csv) { + if (generate_csv(settings, heatmap)) { + return EXIT_FAILURE; } + html = !settings.csv_path.empty(); } - html::close(settings.fragment); + if (html) { + generate_html(settings, report_year, report_begin, report_end, heatmap); + } return EXIT_SUCCESS; }
--- a/src/settings.h Wed Feb 25 22:32:55 2026 +0100 +++ b/src/settings.h Wed Feb 25 22:33:46 2026 +0100 @@ -37,6 +37,7 @@ struct settings { std::string hg{"/usr/bin/hg"}; std::string git{"/usr/bin/git"}; + std::string csv_path{}; std::vector<std::string> paths; std::vector<std::string> authors; std::unordered_map<std::string, std::string> authormap; @@ -46,6 +47,7 @@ bool separate = false; bool fragment = false; bool styles_and_script = false; + bool csv = false; unsigned char fragment_indent = 0; unsigned short year = settings_current_year;