add CSV export of raw data - resolves #806

Wed, 25 Feb 2026 22:33:46 +0100

author
Mike Becker <universe@uap-core.de>
date
Wed, 25 Feb 2026 22:33:46 +0100
changeset 78
5177d8af5536
parent 77
43a1ba0e11e3
child 79
cd8ad39dda76

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;
 

mercurial