Custom Report for Group Membership Audit

I have created a custom report that captures the following information:

1. A PowerShell script collects Event Viewer data for the following Event IDs: 4732, 4733, 4728, and 4729.

Output Format:- below is the output format
Domain:
Category: Microsoft-Windows-Security-Auditing
Activity: A member was added to a security-enabled local group
Group: Administrators
Member: domain\accountname
**Subject ID: **
Subject Account Name: account name
Computer: host name
Time: 10/22/2025 05:24:41
Message: A member was added to a security-enabled local group.
------------------------------------------------------------
Domain:
Category: Microsoft-Windows-Security-Auditing
Activity: A member was removed to a security-enabled local group
Group: Administrators
Member: domain\accountname
**Subject ID: **
Subject Account Name: account name
Computer: host name
Time: 10/22/2025 05:24:41
Message: A member was added to a security-enabled local group.
------------------------------------------------------------

Relevance: I used the following relevance to extract specific information from the output file:

(it as trimmed string) of (following text of first "Domain: " of it) of ((it as trimmed string) of lines whose ((it as trimmed string) starts with "Domain: ") of file “C:\temp\GroupAudit.txt”)

Show more lines

Additionally, I created a Web Report. I want each property’s data to appear on a separate line. However, when I try to expand column values in the “Edit Columns” section, the data appears inconsistent.

Question: Is there a way to ensure that each set of output values appears in a single row in Web Reports?

I am not exactly following what is the output you aim for based on the sample text file you are reading in? Can you show us the ideal output you would like to see in Web Reports?

1 Like

Each property has multiple entries for each system, so if i expand the results in the web reports edit columns by clicking “+” on each property expecting results to have each entry in each row.

But when expanding each property, the results are inconsistent.

Ok, that makes sense. What I have done in situations like that is rework the initial format. Since you have a powershell script that produces the output and PS has a ton of cmdlets that can easily deal with that where relevance is not great at working within loops, I would start there and get PS to produce each event as a single line with the same exact key/value pair orders, something like this:
Domain: | Category: Microsoft-Windows-Security-Auditing | Activity: A member was added to a security-enabled local group | Group: Administrators | Member: domain\accountname | **Subject ID: ** | Subject Account Name: account name | Computer: host name | Time: 10/22/2025 05:24:41 | Message: A member was added to a security-enabled local group.

From there the property client relevance I would just leave it to return: lines of file “…”

And with the above property output then you can easily create anything you want via custom report and display it with the correct, here is a quick mockup:

<div id="resultsDiv">
    <table id="resultsTable" class="sortable">
                <th>Domain</th>
                <th>Category</th>
                <th>Activity</th>
                <th>Group</th>
                <th>Member</th>
                <th>Subject ID</th>
                <th>Subject Account Name</th>
                <th>Computer</th>
                <th>Time</th>
                <th>Message</th>
    		<?relevance concatenations of (trs of (td of item 0 of it & td of item 1 of it & td of item 2 of it & td of item 3 of it & td of item 4 of it & td of item 5 of it & td of item 6 of it & td of item 7 of it & td of item 8 of it & td of item 9 of it) of (following text of first ": " of tuple string item 0 of it, following text of first ": " of tuple string item 1 of it, following text of first ": " of tuple string item 2 of it, following text of first ": " of tuple string item 3 of it, following text of first ": " of tuple string item 4 of it, following text of first ": " of tuple string item 5 of it, following text of first ": " of tuple string item 6 of it, following text of first ": " of tuple string item 7 of it, following text of first ": " of tuple string item 8 of it, following text of first ": " of tuple string item 9 of it) of (concatenation ", " of substrings separated by " | " of it) of values of results whose (exists values of it) of (bes computers whose (name of it = "A"), bes properties whose (name of it = "Event IDs"))) ?>
    </table>
</div>

I have converted the PowerShell to have the results in this format.

Domain: | Category: Microsoft-Windows-Security-Auditing | Activity: A member was added to a security-enabled local group | Group: Administrators | Member: domain\accountname | **Subject ID: ** | Subject Account Name: account name | Computer: host name | Time: 10/22/2025 05:24:41 | Message: A member was added to a security-enabled local group.

But when i run the custom report with session relevance, it is failing with “The operator “results” is not defined.”

Try this (I didn’t tested and wrote it off top of my head, so had the wrong structure):

<div id="resultsDiv">
    <table id="resultsTable" class="sortable">
                <th>Domain</th>
                <th>Category</th>
                <th>Activity</th>
                <th>Group</th>
                <th>Member</th>
                <th>Subject ID</th>
                <th>Subject Account Name</th>
                <th>Computer</th>
                <th>Time</th>
                <th>Message</th>
    		<?relevance concatenations of (trs of (td of item 0 of it & td of item 1 of it & td of item 2 of it & td of item 3 of it & td of item 4 of it & td of item 5 of it & td of item 6 of it & td of item 7 of it & td of item 8 of it & td of item 9 of it) of (following text of first ": " of tuple string item 0 of it, following text of first ": " of tuple string item 1 of it, following text of first ": " of tuple string item 2 of it, following text of first ": " of tuple string item 3 of it, following text of first ": " of tuple string item 4 of it, following text of first ": " of tuple string item 5 of it, following text of first ": " of tuple string item 6 of it, following text of first ": " of tuple string item 7 of it, following text of first ": " of tuple string item 8 of it, following text of first ": " of tuple string item 9 of it) of (concatenation ", " of substrings separated by " | " of it) of values of results(bes computers whose (name of it = "A"), bes properties whose (name of it = "Event IDs")) whose (exists values of it)) ?>
    </table>
</div>

@ageorgiev Thanks for the inputs, below are the results format for the property.

Property Results: “Event IDs”
Domain: Domain Name | Category: Microsoft-Windows-Security-Auditing | Activity: A member was added to a security-enabled local group | Group: Group Name1| Member: Account1 | Subject ID: | Subject Account Name: BIGFIXTEST$ | Computer: Host Name | Time: 10/23/2025 16:02:51 | Windows Event: 4732
Domain: Domain Name | Category: Microsoft-Windows-Security-Auditing | Activity: A member was added to a security-enabled local group | Group: Group Name1| Member: Account2 | Subject ID: | Subject Account Name: BIGFIXTEST$ | Computer: Host Name | Time: 10/23/2025 16:02:51 | Windows Event: 4732
Domain: Domain Name | Category: Microsoft-Windows-Security-Auditing | Activity: A member was added to a security-enabled local group | Group: Group Name1 | Member: Account3 | Subject ID: | Subject Account Name: BIGFIXTEST$ | Computer: Host Name | Time: 10/23/2025 16:02:51 | Windows Event: 4732
Domain: Domain Name | Category: Microsoft-Windows-Security-Auditing | Activity: A member was added to a security-enabled local group | Group: Group Name1 | Member: Account4 | Subject ID: | Subject Account Name: BIGFIXTEST$ | Computer: Host Name | Time: 10/23/2025 16:02:51 | Windows Event: 4732
Domain: Domain Name | Category: Microsoft-Windows-Security-Auditing | Activity: A member was removed from a security-enabled local group | Group: Group Name1| Member: Account1 | Subject ID: | Subject Account Name: BIGFIXTEST$ | Computer: Host Name | Time: 10/24/2025 07:54:25 | Windows Event: 4733

I am trying to run this code from the BigFix custom web reports, but the results always seems to be empty, am i doing something incorrect here?

<?relevance concatenations of (trs of (td of item 0 of it & td of item 1 of it & td of item 2 of it & td of item 3 of it & td of item 4 of it & td of item 5 of it & td of item 6 of it & td of item 7 of it & td of item 8 of it & td of item 9 of it) of (following text of first ": " of tuple string item 0 of it, following text of first ": " of tuple string item 1 of it, following text of first ": " of tuple string item 2 of it, following text of first ": " of tuple string item 3 of it, following text of first ": " of tuple string item 4 of it, following text of first ": " of tuple string item 5 of it, following text of first ": " of tuple string item 6 of it, following text of first ": " of tuple string item 7 of it, following text of first ": " of tuple string item 8 of it, following text of first ": " of tuple string item 9 of it) of (concatenation ", " of substrings separated by " | " of it) of values of results(bes computers whose (name of it = "Computer Name"), bes properties whose (name of it = "Event IDs")) whose (exists values of it)) ?>
Domain Category Activity Group Member Subject ID Subject Account Name Computer Time Windows Event

values of results(bes computers whose (name of it = “Computer Name”), bes properties whose (name of it = “Event IDs”)) whose (exists values of it)

Is this bit producing results alone (remove the formatting/parsing? If not, did you change the name of the property and the name of the machine? If it is, then we can dig into the actual parsing/formatting…

yes, I can run the find the results

q: values of results(bes computers whose (name of it = “BIGFIXTEST”), bes properties whose (name of it = “Event IDs”)) whose (exists values of it)
A:Domain: Domain Name | Category: Microsoft-Windows-Security-Auditing | Activity: A member was added to a security-enabled local group | Group: Group Name1| Member: Account1 | Subject ID: | Subject Account Name: BIGFIXTEST$ | Computer: Host Name | Time: 10/23/2025 16:02:51 | Windows Event: 4732
A:Domain: Domain Name | Category: Microsoft-Windows-Security-Auditing | Activity: A member was added to a security-enabled local group | Group: Group Name1| Member: Account2 | Subject ID: | Subject Account Name: BIGFIXTEST$ | Computer: Host Name | Time: 10/23/2025 16:02:51 | Windows Event: 4732
A:Domain: Domain Name | Category: Microsoft-Windows-Security-Auditing | Activity: A member was added to a security-enabled local group | Group: Group Name1 | Member: Account3 | Subject ID: | Subject Account Name: BIGFIXTEST$ | Computer: Host Name | Time: 10/23/2025 16:02:51 | Windows Event: 4732
A:Domain: Domain Name | Category: Microsoft-Windows-Security-Auditing | Activity: A member was added to a security-enabled local group | Group: Group Name1 | Member: Account4 | Subject ID: | Subject Account Name: BIGFIXTEST$ | Computer: Host Name | Time: 10/23/2025 16:02:51 | Windows Event: 4732

I have updated the parsing which worked!!

table.grid { width:100%; border-collapse:collapse; font:13px "Segoe UI", Arial, sans-serif; } table.grid th, table.grid td { padding:6px 8px; border:1px solid #999; vertical-align:top; } table.grid thead th { background:#f5f5f7; font-weight:600; }
Domain Category Activity Group Member Subject ID Subject Account Name Computer Time Windows Event

<

1 Like

I have a schedule a report now but csv comes in this format, instead of the exact output.

This report is scheduled to run every 1 day.This report will expire on 10/31/2025 6:09 AM.This report has changed since the last time it was run.<br />

<br />

<style>

  table.grid { width:100%; border-collapse:collapse; font:13px "Segoe UI"

  table.grid th

  table.grid thead th { background:#f5f5f7; font-weight:600; }

</style>



<table class="grid">

  <thead>

    <tr>

      <th>Domain</th>

      <th>Category</th>

      <th>Activity</th>

      <th>Group</th>

      <th>Member</th>

      <th>Subject ID</th>

      <th>Subject Account Name</th>

      <th>Computer</th>

      <th>Time</th>

      <th>Windows Event</th>

    </tr>

  </thead>

  <tbody id="rows"></tbody>

</table>



<!-- 1) Generate the rows via relevance

<script type="text/plain" id="rawRows">

&lt;tr&gt;&lt;td&gt;PRRFT&lt;/td&gt;&lt;td&gt;Microsoft-Windows-Security-Auditing&lt;/td&gt;&lt;td&gt;A member was added to a security-enabled local group&lt;/td&gt;&lt;td&gt;Administrators&lt;/td&gt;&lt;td&gt;PRRFT\RES-SrvMb-Admin&lt;/td&gt;&lt;td&gt;-&lt;/td&gt;&lt;td&gt;BIGFIXTEST$&lt;/td&gt;&lt;td&gt;bigfixtest.prrft.int.inf0.net&lt;/td&gt;&lt;td&gt;10/23/2025 16:02:51&lt;/td&gt;&lt;td&gt;4732&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;PRRFT&lt;/td&gt;&lt;td&gt;Microsoft-Windows-Security-Auditing&lt;/td&gt;&lt;td&gt;A member was added to a security-enabled local group&lt;/td&gt;&lt;td&gt;Administrators&lt;/td&gt;&lt;td&gt;PRRFT\RES-BIGFIXTEST-Admins&lt;/td&gt;&lt;td&gt;-&lt;/td&gt;&lt;td&gt;BIGFIXTEST$&lt;/td&gt;&lt;td&gt;bigfixtest.prrft.int.inf0.net&lt;/td&gt;&lt;td&gt;10/23/2025 16:02:51&lt;/td&gt;&lt;td&gt;4732&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;PRRFT&lt;/td&gt;&lt;td&gt;Microsoft-Windows-Security-Auditing&lt;/td&gt;&lt;td&gt;A member was added to a security-enabled local group&lt;/td&gt;&lt;td&gt;Remote Desktop Users&lt;/td&gt;&lt;td&gt;PRRFT\RES-SrvMB-RemoteDesktop&lt;/td&gt;&lt;td&gt;-&lt;/td&gt;&lt;td&gt;BIGFIXTEST$&lt;/td&gt;&lt;td&gt;bigfixtest.prrft.int.inf0.net&lt;/td&gt;&lt;td&gt;10/23/2025 16:02:51&lt;/td&gt;&lt;td&gt;4732&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;PRRFT&lt;/td&gt;&lt;td&gt;Microsoft-Windows-Security-Auditing&lt;/td&gt;&lt;td&gt;A member was added to a security-enabled local group&lt;/td&gt;&lt;td&gt;Remote Desktop Users&lt;/td&gt;&lt;td&gt;PRRFT\RES-BIGFIXTEST-RemoteDesktop&lt;/td&gt;&lt;td&gt;-&lt;/td&gt;&lt;td&gt;BIGFIXTEST$&lt;/td&gt;&lt;td&gt;bigfixtest.prrft.int.inf0.net&lt;/td&gt;&lt;td&gt;10/23/2025 16:02:51&lt;/td&gt;&lt;td&gt;4732&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;PRRFT&lt;/td&gt;&lt;td&gt;Microsoft-Windows-Security-Auditing&lt;/td&gt;&lt;td&gt;A member was removed from a security-enabled local group&lt;/td&gt;&lt;td&gt;Administrators&lt;/td&gt;&lt;td&gt;PRRFT\RES-SrvMb-Admin&lt;/td&gt;&lt;td&gt;-&lt;/td&gt;&lt;td&gt;BIGFIXTEST$&lt;/td&gt;&lt;td&gt;bigfixtest.prrft.int.inf0.net&lt;/td&gt;&lt;td&gt;10/24/2025 07:54:25&lt;/td&gt;&lt;td&gt;4733&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;PRRFT&lt;/td&gt;&lt;td&gt;Microsoft-Windows-Security-Auditing&lt;/td&gt;&lt;td&gt;A member was removed from a security-enabled local group&lt;/td&gt;&lt;td&gt;Administrators&lt;/td&gt;&lt;td&gt;PRRFT\RES-BIGFIXTEST-Admins&lt;/td&gt;&lt;td&gt;-&lt;/td&gt;&lt;td&gt;BIGFIXTEST$&lt;/td&gt;&lt;td&gt;bigfixtest.prrft.int.inf0.net&lt;/td&gt;&lt;td&gt;10/24/2025 07:54:25&lt;/td&gt;&lt;td&gt;4733&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;PRRFT&lt;/td&gt;&lt;td&gt;Microsoft-Windows-Security-Auditing&lt;/td&gt;&lt;td&gt;A member was removed from a security-enabled local group&lt;/td&gt;&lt;td&gt;Remote Desktop Users&lt;/td&gt;&lt;td&gt;PRRFT\RES-SrvMB-RemoteDesktop&lt;/td&gt;&lt;td&gt;-&lt;/td&gt;&lt;td&gt;BIGFIXTEST$&lt;/td&gt;&lt;td&gt;bigfixtest.prrft.int.inf0.net&lt;/td&gt;&lt;td&gt;10/24/2025 07:54:25&lt;/td&gt;&lt;td&gt;4733&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;PRRFT&lt;/td&gt;&lt;td&gt;Microsoft-Windows-Security-Auditing&lt;/td&gt;&lt;td&gt;A member was removed from a security-enabled local group&lt;/td&gt;&lt;td&gt;Remote Desktop Users&lt;/td&gt;&lt;td&gt;PRRFT\RES-BIGFIXTEST-RemoteDesktop&lt;/td&gt;&lt;td&gt;-&lt;/td&gt;&lt;td&gt;BIGFIXTEST$&lt;/td&gt;&lt;td&gt;bigfixtest.prrft.int.inf0.net&lt;/td&gt;&lt;td&gt;10/24/2025 07:54:25&lt;/td&gt;&lt;td&gt;4733&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;PRRFT&lt;/td&gt;&lt;td&gt;Microsoft-Windows-Security-Auditing&lt;/td&gt;&lt;td&gt;A member was removed from a security-enabled local group&lt;/td&gt;&lt;td&gt;Administrators&lt;/td&gt;&lt;td&gt;PRRFT\RES-SrvMb-Admin&lt;/td&gt;&lt;td&gt;-&lt;/td&gt;&lt;td&gt;BIGFIXTEST$&lt;/td&gt;&lt;td&gt;bigfixtest.prrft.int.inf0.net&lt;/td&gt;&lt;td&gt;10/23/2025 14:08:39&lt;/td&gt;&lt;td&gt;4733&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;PRRFT&lt;/td&gt;&lt;td&gt;Microsoft-Windows-Security-Auditing&lt;/td&gt;&lt;td&gt;A member was removed from a security-enabled local group&lt;/td&gt;&lt;td&gt;Administrators&lt;/td&gt;&lt;td&gt;PRRFT\RES-BIGFIXTEST-Admins&lt;/td&gt;&lt;td&gt;-&lt;/td&gt;&lt;td&gt;BIGFIXTEST$&lt;/td&gt;&lt;td&gt;bigfixtest.prrft.int.inf0.net&lt;/td&gt;&lt;td&gt;10/23/2025 14:08:39&lt;/td&gt;&lt;td&gt;4733&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;PRRFT&lt;/td&gt;&lt;td&gt;Microsoft-Windows-Security-Auditing&lt;/td&gt;&lt;td&gt;A member was removed from a security-enabled local group&lt;/td&gt;&lt;td&gt;Remote Desktop Users&lt;/td&gt;&lt;td&gt;PRRFT\RES-SrvMB-RemoteDesktop&lt;/td&gt;&lt;td&gt;-&lt;/td&gt;&lt;td&gt;BIGFIXTEST$&lt;/td&gt;&lt;td&gt;bigfixtest.prrft.int.inf0.net&lt;/td&gt;&lt;td&gt;10/23/2025 14:08:39&lt;/td&gt;&lt;td&gt;4733&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;PRRFT&lt;/td&gt;&lt;td&gt;Microsoft-Windows-Security-Auditing&lt;/td&gt;&lt;td&gt;A member was removed from a security-enabled local group&lt;/td&gt;&lt;td&gt;Remote Desktop Users&lt;/td&gt;&lt;td&gt;PRRFT\RES-BIGFIXTEST-RemoteDesktop&lt;/td&gt;&lt;td&gt;-&lt;/td&gt;&lt;td&gt;BIGFIXTEST$&lt;/td&gt;&lt;td&gt;bigfixtest.prrft.int.inf0.net&lt;/td&gt;&lt;td&gt;10/23/2025 14:08:39&lt;/td&gt;&lt;td&gt;4733&lt;/td&gt;&lt;/tr&gt;

</script>



<!-- 2) Decode & inject into the table body -->

<script>

(function () {

  var src = document.getElementById('rawRows');

  if (!src) return;



  // Read the rows as plain text (still contains &lt;tr&gt; ... if server escaped them)

  var s = src.text || src.textContent || '';



  // Decode common HTML entities (if any)

  s = s

    .replace(/&amp;/g

    .replace(/&lt;/g

    .replace(/&gt;/g

    .replace(/&quot;/g

    .replace(/&#39;/g



  // Inject into the real table body

  document.getElementById('rows').innerHTML = s;

})();

</script>


Yea, that’s the problem with “custom reports” - you are essentially hardcoding them in one format vs the other. What I would offer as options if it was one of our clients are:

  • Leave the report in HTML and essentially the scheduled report needs to also be in HTML format (they can copy/paste an HTML table from email to Excel and that works fine)
  • Write the report in comma-separated text format and it will come fine in CSV but it will look ugly if someone is to open it in WR directly.. It will look something like this:
<?relevance ("Report created: " & (((day_of_week of it as three letters as string & " " & day_of_month of it as string & " " & month of it as string & " " & year of it as string ) of date (local time zone) of it  & " at " & (two digit hour of it as string & ":" & two digit minute of it as string & ":" & two digit second of it as string) of time (local time zone) of it) of now) & "%0A" & "Domain,Category,Activity,Group,Member,Subject ID,Subject Account Name,Computer,Time,Message") & "%0A" & concatenation "%0A" of (item 0 of it & "," & item 1 of it & "," & item 2 of it & "," & item 3 of it & "," & item 4 of it & "," & item 5 of it & "," & item 6 of it & "," & item 7 of it & "," & item 8 of it & "," & item 9 of it) of (following text of first ": " of tuple string item 0 of it, following text of first ": " of tuple string item 1 of it, following text of first ": " of tuple string item 2 of it, following text of first ": " of tuple string item 3 of it, following text of first ": " of tuple string item 4 of it, following text of first ": " of tuple string item 5 of it, following text of first ": " of tuple string item 6 of it, following text of first ": " of tuple string item 7 of it, following text of first ": " of tuple string item 8 of it, following text of first ": " of tuple string item 9 of it) of (concatenation ", " of substrings separated by " | " of it) of values of results(bes computers whose (name of it = "BIGFIXTEST"), bes properties whose (name of it = "Event IDs")) whose (exists values of it) ?>

I would love there to be a code that can check the expected output of WR and then run the appropriate session relevance (feed both and let the report auto-chose which is required) OR as an alternative somehow create a function where you can pass custom relevance output to the default WR grid, so it handles both HTML & CSV formatting for you, BUT unfortunately I am not aware that either is possible… If anyone else had success with anything along those lines I’d be very interested in it!

If it gets you closer, I have a few examples at BigFix/Test Content/Web Reports at master · Jwalker107/BigFix · GitHub

These use Session Relevance to define column names and query for row data, then use jQuery and dataTables to render the results in HTML - and dataTables provides the buttons to export CSV, Excel, PDF, or print view.

If you email it out, you have to use a mail reader that will execute JavaScript, or export and paste into a browser; what I usually email out is a link to the archived report on the Web Reports server instead of the full HTML report.

If nothing else it.may give you some ideas of what’s possible.

@JasonWalker , that sounds very good and I probably have missed it way back when first released… I did load all the 4 sample reports and none of them seem to be working past the header, not sure if it is me/our environment being locked out or maybe some of the source files/urls changed but thought I’d ask if anybody else is having that problem first..

Ah, yes, since these load JavaScript from an external source (jQuery and dataTables), your Web Reports would have to be configured to not send the CSP Header that was added in Web Reports 11.

Let me find a reference

Description is at Enabling security policy for reports

you need to set EnableCSPHeader to 0 to disable using the CSPHeader.

A future version of Web Reports is supposed to allow us an option to customize the CSP value (allow scripts from some sites and not from others), but for now it’s either On or Off; and if it’s On, no JavaScripts can be loaded from external servers.

An alternative workaround, is for the places in these Dashboards where I have a <SCRIPT src=> tag, to copy those JavaScript files to your Web Reports server directly and change these Script URLs to reference the scripts locally.

That was the bit that I was missing, thanks for that!

I will play around with them and see how they look.

Thanks for the inputs , I've updated the report to include a Download CSV button. When scheduling the report, I also added a link to the current version so users can easily download the CSV directly.

<button id="downloadCsvBtn" type="button">Download CSV</button>
<script>
(function () {
  // Run only when the full page is ready (covers async injection timing too)
  function onReady(fn) {
    if (document.readyState === 'complete' || document.readyState === 'interactive') {
      // Defer to end of event loop to ensure prior scripts finished
      setTimeout(fn, 0);
    } else {
      document.addEventListener('DOMContentLoaded', fn);
    }
  }

  function findResultsTable() {
    // Start from the known tbody id="rows"
    var tbody = document.getElementById('rows');
    if (tbody) {
      // closest() may not exist in very old engines; do manual climb if needed
      if (tbody.closest) {
        var t = tbody.closest('table');
        if (t) return t;
      }
      var n = tbody;
      while (n && n.nodeName !== 'TABLE') n = n.parentNode;
      if (n) return n;
    }
    // Fallback: any table with class "grid"
    var t2 = document.querySelector('table.grid');
    if (t2) return t2;

    return null;
  }

  function escapeCsv(val) {
    var v = val == null ? '' : String(val).trim();
    // Normalize line breaks
    v = v.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
    // Escape quotes and wrap if needed
    var needsQuotes = /[",\n]/.test(v);
    v = v.replace(/"/g, '""');
    return needsQuotes ? '"' + v + '"' : v;
  }

  function tableToCsv(table) {
    var lines = [];

    // Headers (if present)
    var ths = table.querySelectorAll('thead th');
    if (ths && ths.length) {
      var header = Array.prototype.map.call(ths, function (th) {
        return escapeCsv(th.textContent || '');
      });
      lines.push(header.join(','));
    }

    // Body rows
    var trs = table.querySelectorAll('tbody tr');
    Array.prototype.forEach.call(trs, function (tr) {
      var cells = Array.prototype.map.call(tr.querySelectorAll('td'), function (td) {
        return escapeCsv(td.textContent || '');
      });
      lines.push(cells.join(','));
    });

    return lines.join('\n');
  }

  function timestampFilename() {
    var d = new Date();
    var pad = function (n) { return String(n).padStart(2, '0'); };
    return 'results_' +
      d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + '_' +
      pad(d.getHours()) + '-' + pad(d.getMinutes()) + '-' + pad(d.getSeconds()) + '.csv';
  }

  function downloadCsvContent(csv, filename) {
    // Prefer Blob + download attribute
    try {
      var BOM = '\uFEFF'; // Excel-friendly UTF-8 BOM
      var blob = new Blob([BOM + csv], { type: 'text/csv;charset=utf-8;' });

      // IE/Edge Legacy
      if (window.navigator && window.navigator.msSaveOrOpenBlob) {
        window.navigator.msSaveOrOpenBlob(blob, filename);
        return;
      }

      // Modern browsers
      var url = URL.createObjectURL(blob);
      var a = document.createElement('a');
      a.href = url;
      a.download = filename;
      document.body.appendChild(a);
      a.click();
      document.body.removeChild(a);
      URL.revokeObjectURL(url);
      return;
    } catch (e) {
      console.warn('Blob download failed, falling back to data URI:', e);
    }

    // Fallback: data URI (works even if download attr blocked in some contexts)
    var dataUri = 'data:text/csv;charset=utf-8,' + encodeURIComponent('\uFEFF' + csv);
    var link = document.createElement('a');
    link.setAttribute('href', dataUri);
    link.setAttribute('download', filename);

    // If download attribute is ignored, open in a new tab as a last resort
    link.setAttribute('target', '_blank');

    document.body.appendChild(link);
    link.click();
    document.body.removeChild(link);
  }

  onReady(function () {
    var btn = document.getElementById('downloadCsvBtn');
    if (!btn) return;

    btn.addEventListener('click', function () {
      // Re-find the table at click time (in case rows were injected just now)
      var table = findResultsTable();
      if (!table) {
        // As a visual cue if you truly have no table yet
        alert('Table not found. Ensure <tbody id="rows"> exists on this page.');
        return;
      }

      var csv = tableToCsv(table);
      // If there are zero lines, provide a friendly cue
      if (!csv || !csv.trim()) {
        alert('No data to export (tbody is empty).');
        return;
      }

      var filename = timestampFilename();
      downloadCsvContent(csv, filename);
    });
  });
})();
</script>
1 Like