repolho 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- checksums.yaml.gz.sig +3 -0
- data/LICENSE +21 -0
- data/README.md +197 -0
- data/certs/taq.pem +27 -0
- data/lib/repolho/container.rb +184 -0
- data/lib/repolho/main.rb +158 -0
- data/lib/repolho/page.rb +77 -0
- data/lib/repolho/report.rb +7 -0
- data/lib/repolho/stats.rb +33 -0
- data/lib/repolho/version.rb +5 -0
- data/lib/repolho.rb +11 -0
- data.tar.gz.sig +3 -0
- metadata +99 -0
- metadata.gz.sig +3 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 2950c91544adda9db0b125b7b73717f20ac7d6535a9f378789c6aa3dadacd59c
|
|
4
|
+
data.tar.gz: dc205200e80ddee22718e5964ce121001da131b7c7545d62cdb4505344b71620
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: d63f3ef00ee5d6ccf72ed10331c09445672150ea41a977ba24cb90e12a321022a10e372aece79db3ac2c23e648fffd3fc77534062067c74ccbb019c3428adc25
|
|
7
|
+
data.tar.gz: 18dc8cc1965a0a5d020f294a6ff47e581a7dbf0008a9eff2d7fb413d410dcea4ef635d806a32fb74207318788782db32a86b676d92689077793b6d7b416b0229
|
checksums.yaml.gz.sig
ADDED
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Eustáquio Rangel
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# Repolho
|
|
2
|
+
|
|
3
|
+

|
|
4
|
+
|
|
5
|
+
Report generator for Ruby and Rails.
|
|
6
|
+
|
|
7
|
+
Given a `.rep.erb` template and a data source (ActiveRecord `exec_query`,
|
|
8
|
+
Relation, array of models, or array of hashes), Repolho produces HTML with:
|
|
9
|
+
|
|
10
|
+
- Nested groupings (`<report-group>`)
|
|
11
|
+
- Page breaks (`<report-page rows="N">`)
|
|
12
|
+
- Headers and footers at every level
|
|
13
|
+
- Per-container statistics (`sum`, `avg`, `min`, `max`, `count`)
|
|
14
|
+
- Optional group-header reprint on page break
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
```ruby
|
|
19
|
+
# Gemfile
|
|
20
|
+
gem "repolho"
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
bundle install
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Usage
|
|
28
|
+
|
|
29
|
+
Plain Ruby:
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
require "repolho"
|
|
33
|
+
|
|
34
|
+
rows = Order.connection.exec_query(sql)
|
|
35
|
+
# or
|
|
36
|
+
# rows = Order.joins(...).select(...).order(...)
|
|
37
|
+
|
|
38
|
+
report = Repolho::Main.new("app/views/reports/sales.rep.erb", rows, { company: "Acme" })
|
|
39
|
+
report.run
|
|
40
|
+
|
|
41
|
+
html = report.content.join
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Or with Rails:
|
|
45
|
+
|
|
46
|
+
```ruby
|
|
47
|
+
render html: html.html_safe
|
|
48
|
+
# or File.write("report.html", html)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Template (`.rep.erb`)
|
|
52
|
+
|
|
53
|
+
Repolho uses a few custom elements inside a normal HTML document. Everything
|
|
54
|
+
outside `<report>…</report>` is copied as-is (the `previous` and `after`
|
|
55
|
+
sections). Everything inside the custom elements is processed as ERB.
|
|
56
|
+
|
|
57
|
+
### Custom elements
|
|
58
|
+
|
|
59
|
+
| Element | Purpose | Attributes |
|
|
60
|
+
|---------|---------|------------|
|
|
61
|
+
| `<report>` | Root container of the report | — |
|
|
62
|
+
| `<report-page>` | Defines a page (required, only one) | `rows` (required): max data rows per page |
|
|
63
|
+
| `<report-group>` | Nested grouping | `id` (required), `attr` (required), `reprint-header-on-page-break` (optional, `"true"`) |
|
|
64
|
+
| `<report-header>` | Header of the current container | — |
|
|
65
|
+
| `<report-footer>` | Footer of the current container | — |
|
|
66
|
+
| `<report-row>` | Template for each data row (inside the innermost group) | — |
|
|
67
|
+
|
|
68
|
+
### Allowed HTML inside page content
|
|
69
|
+
|
|
70
|
+
Each page is emitted as a `<tbody class="repolho-page" data-page="N">`.
|
|
71
|
+
Because of HTML table rules, **only `<tr>` elements** may appear as direct
|
|
72
|
+
children of that `<tbody>`.
|
|
73
|
+
|
|
74
|
+
| Allowed | Not allowed | Why |
|
|
75
|
+
|---------|-------------|-----|
|
|
76
|
+
| `<tr>` (with `<td>` / `<th>`) | `<thead>` | `<thead>` cannot be a child of `<tbody>`. The browser moves it out and empties the page container. |
|
|
77
|
+
| | `<tfoot>` | Same reason. |
|
|
78
|
+
| | `<div>`, `<p>`, `<section>`, etc. | Invalid inside a table. |
|
|
79
|
+
|
|
80
|
+
Use `<tr>` for headers and footers of pages and groups. Reserve `<thead>` /
|
|
81
|
+
`<tfoot>` only for content that lives **outside** the page bodies (for example
|
|
82
|
+
a report-level header that should appear before any page, if you place it
|
|
83
|
+
before the first `<report-page>`).
|
|
84
|
+
|
|
85
|
+
### Page wrapper
|
|
86
|
+
|
|
87
|
+
Repolho automatically wraps each page like this:
|
|
88
|
+
|
|
89
|
+
```html
|
|
90
|
+
<tbody class="repolho-page" data-page="1" style="break-after: page; page-break-after: always;">
|
|
91
|
+
<!-- only <tr> elements here -->
|
|
92
|
+
</tbody>
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
The `data-page` attribute and the `break-after` style are injected for you.
|
|
96
|
+
You do not need to write the `<tbody>` yourself.
|
|
97
|
+
|
|
98
|
+
### Template example
|
|
99
|
+
|
|
100
|
+
```erb
|
|
101
|
+
<!DOCTYPE html>
|
|
102
|
+
<html>
|
|
103
|
+
<head>
|
|
104
|
+
<title>Sales report</title>
|
|
105
|
+
</head>
|
|
106
|
+
<body>
|
|
107
|
+
<table>
|
|
108
|
+
<report>
|
|
109
|
+
<report-header>
|
|
110
|
+
<tr><th colspan="100"><%= @company %></th></tr>
|
|
111
|
+
<tr><th colspan="100">Sales report</th></tr>
|
|
112
|
+
</report-header>
|
|
113
|
+
|
|
114
|
+
<report-page rows="40">
|
|
115
|
+
<report-header>
|
|
116
|
+
<tr>
|
|
117
|
+
<td>Id</td>
|
|
118
|
+
<td>Name</td>
|
|
119
|
+
<td class="text-end">Value</td>
|
|
120
|
+
</tr>
|
|
121
|
+
</report-header>
|
|
122
|
+
|
|
123
|
+
<report-footer>
|
|
124
|
+
<tr>
|
|
125
|
+
<td colspan="2">Page total</td>
|
|
126
|
+
<td><%= format("%.2f", sum(:value)) %></td>
|
|
127
|
+
</tr>
|
|
128
|
+
</report-footer>
|
|
129
|
+
|
|
130
|
+
<report-group id="state" attr="state" reprint-header-on-page-break="true">
|
|
131
|
+
<report-header>
|
|
132
|
+
<tr>
|
|
133
|
+
<td>State:</td>
|
|
134
|
+
<td colspan="100"><%= attr(:state) %></td>
|
|
135
|
+
</tr>
|
|
136
|
+
</report-header>
|
|
137
|
+
<report-footer>
|
|
138
|
+
<tr>
|
|
139
|
+
<td colspan="2"><%= attr(:state) %> total</td>
|
|
140
|
+
<td>
|
|
141
|
+
<%= format("%.2f", sum(:value)) %>
|
|
142
|
+
(max: <%= format("%.2f", max(:value)) %>)
|
|
143
|
+
</td>
|
|
144
|
+
</tr>
|
|
145
|
+
</report-footer>
|
|
146
|
+
|
|
147
|
+
<report-row>
|
|
148
|
+
<tr>
|
|
149
|
+
<td><%= attr(:id) %></td>
|
|
150
|
+
<td><%= attr(:name) %></td>
|
|
151
|
+
<td class="text-end"><%= format("%.2f", attr(:value)) %></td>
|
|
152
|
+
</tr>
|
|
153
|
+
</report-row>
|
|
154
|
+
</report-group>
|
|
155
|
+
</report-page>
|
|
156
|
+
|
|
157
|
+
<report-footer>
|
|
158
|
+
<tr>
|
|
159
|
+
<td colspan="2">Grand total</td>
|
|
160
|
+
<td><%= format("%.2f", sum(:value)) %></td>
|
|
161
|
+
</tr>
|
|
162
|
+
</report-footer>
|
|
163
|
+
</report>
|
|
164
|
+
</table>
|
|
165
|
+
</body>
|
|
166
|
+
</html>
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
To preview the structure, rename the file to `.html` and open it in a browser
|
|
170
|
+
(the custom tags will be ignored).
|
|
171
|
+
|
|
172
|
+
## Methods available in ERB
|
|
173
|
+
|
|
174
|
+
| Method | Description |
|
|
175
|
+
|--------|-------------|
|
|
176
|
+
| `attr(:field)` | Value of the field on the current row |
|
|
177
|
+
| `sum(:field)` | Sum within the current container |
|
|
178
|
+
| `avg(:field)` | Average |
|
|
179
|
+
| `min(:field)` / `max(:field)` | Minimum / maximum |
|
|
180
|
+
| `count(:field)` | Number of values |
|
|
181
|
+
| `number` | Current page or group number |
|
|
182
|
+
|
|
183
|
+
## Signing
|
|
184
|
+
|
|
185
|
+
This gem is cryptographically signed. To verify the signature on install:
|
|
186
|
+
|
|
187
|
+
```bash
|
|
188
|
+
# Trust the public certificate (once)
|
|
189
|
+
gem cert --add <(curl -Ls https://raw.githubusercontent.com/taq/repolho/main/certs/taq.pem)
|
|
190
|
+
|
|
191
|
+
# Install with signature verification
|
|
192
|
+
gem install repolho -P HighSecurity
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
## License
|
|
196
|
+
|
|
197
|
+
MIT
|
data/certs/taq.pem
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
-----BEGIN CERTIFICATE-----
|
|
2
|
+
MIIEhTCCAu2gAwIBAgIBATANBgkqhkiG9w0BAQsFADBEMQwwCgYDVQQDDAN0YXEx
|
|
3
|
+
HzAdBgoJkiaJk/IsZAEZFg9ldXN0YXF1aW9yYW5nZWwxEzARBgoJkiaJk/IsZAEZ
|
|
4
|
+
FgNjb20wHhcNMjYxMDIxMTE0NzI1WhcNMjcxMDIxMTE0NzI1WjBEMQwwCgYDVQQD
|
|
5
|
+
DAN0YXExHzAdBgoJkiaJk/IsZAEZFg9ldXN0YXF1aW9yYW5nZWwxEzARBgoJkiaJ
|
|
6
|
+
k/IsZAEZFgNjb20wggGiMA0GCSqGSIb3DQEBAQUAA4IBjwAwggGKAoIBgQC/IBvw
|
|
7
|
+
KRM7/ta27EE/3J4IMa2gU62Mr7TdpxE6tsakFL6sciN0P3iKUKl3t7nh7Hy24dFi
|
|
8
|
+
eRTojvesAwkLent90rltoVhYAScmPzsbm55phJNVAxGS1H43aAuOTiDskIscxmQ9
|
|
9
|
+
MIQsfP/UBJHfWnNXDykg3yCMkXV+ND7rJwG7NBxP5DL8BUFzxAvtdhi0Yp3BjXR6
|
|
10
|
+
1s7RDfsyoVpIuC9Kv0bZwhGivWF1skTiuuOMSPYdMAVcsap3yaOb59d9QCNB51ov
|
|
11
|
+
Djdunz6jJG8dcTJ8RW1ZoOutTJ9CBtWofi1gfXZeJox7AhpwaPeHiEqD1gTaJd4j
|
|
12
|
+
wGEJiA18O/z00Qw24JwBj3N7EgABRUmlqRuFnq8+1pUX01H/MLKBWbeWj24R/z6C
|
|
13
|
+
Hz472zW9KuJuYu26h3gmcMzm/dVPdRfKo6QuQOwVI5nkIsioMbyA4aw4c0IB7PVU
|
|
14
|
+
uxKm4N2d5FjuG+D9MS4mImUGWt+qaSTlq32hW3vXOalj06JJ7dIbImHzcN0CAwEA
|
|
15
|
+
AaOBgTB/MAkGA1UdEwQCMAAwCwYDVR0PBAQDAgSwMB0GA1UdDgQWBBQjX2PXFvyu
|
|
16
|
+
yyFP4oaoAcl5TRwc1DAiBgNVHREEGzAZgRd0YXFAZXVzdGFxdWlvcmFuZ2VsLmNv
|
|
17
|
+
bTAiBgNVHRIEGzAZgRd0YXFAZXVzdGFxdWlvcmFuZ2VsLmNvbTANBgkqhkiG9w0B
|
|
18
|
+
AQsFAAOCAYEASYzNdAlSlxxO7RDEDELrjlohNVmWJYYGmhe6p2Zis247iBvRwl1V
|
|
19
|
+
Ry9t1Kvp5CTwZFG3aSUCnKdcJ3fCIJfKOus55XHlRD0iNhg9n01LM4ngpYM9yTgi
|
|
20
|
+
T+0lwq0Y8zBW7oBZKhyu+XkkSCDPSWVSyKWlbfHxuSZrP4ccN4geDS76graQRyq+
|
|
21
|
+
U12DiNk5jMYstTMy4C8F09g+h2qoWc6SoeenJNa+GzMFMTkHxtGDYfU5BwPjucJX
|
|
22
|
+
ZH2W0WW/YNLsfevFhi/uIZTZii2AYdAw33lDEADgLVA2s8qqn6TF888jVEPPWV6m
|
|
23
|
+
sUrk8mNBh3BfGv6BP7/mSZrxdriwFbeUxN8oot4zCitSa0nYBqsa97eGmABrPrWz
|
|
24
|
+
5iRFVhCzMkHk+TMT2rAYX41OPS2Xa3YEJeMgmHJfnTIoEfIYQXtbkFqOMUJACISV
|
|
25
|
+
L0XcRn7yJijWxXFMBrnwp/4PHjPT0l6Qci+zxGly0D7LCTHa5WSyWujq4BZw55Yp
|
|
26
|
+
G13dwkTphaMB
|
|
27
|
+
-----END CERTIFICATE-----
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# typed: false
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
require "erb"
|
|
4
|
+
require "cgi"
|
|
5
|
+
require_relative "stats"
|
|
6
|
+
|
|
7
|
+
module Repolho
|
|
8
|
+
class Container
|
|
9
|
+
attr_accessor :header, :footer, :containers, :row_template, :attr, :page, :status, :reprint_header_on_page_break
|
|
10
|
+
attr_reader :id, :number
|
|
11
|
+
|
|
12
|
+
UNSTARTED = 0
|
|
13
|
+
STARTED = 1
|
|
14
|
+
FINISHED = 2
|
|
15
|
+
|
|
16
|
+
def initialize(id, locals, stats)
|
|
17
|
+
@id = id
|
|
18
|
+
@locals = locals
|
|
19
|
+
@stats = stats
|
|
20
|
+
@header = nil
|
|
21
|
+
@footer = nil
|
|
22
|
+
@containers = []
|
|
23
|
+
@status = UNSTARTED
|
|
24
|
+
@row_template = nil
|
|
25
|
+
@cur_row = nil
|
|
26
|
+
@prev_row = nil
|
|
27
|
+
@number = 1
|
|
28
|
+
@attr = nil
|
|
29
|
+
@page = nil
|
|
30
|
+
|
|
31
|
+
create_locals
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def call(type, data)
|
|
35
|
+
@prev_row = @cur_row
|
|
36
|
+
|
|
37
|
+
check_attribute_break(data)
|
|
38
|
+
check_page_break
|
|
39
|
+
|
|
40
|
+
@cur_row = data if data
|
|
41
|
+
update_stats if data && type == :row
|
|
42
|
+
check_started
|
|
43
|
+
|
|
44
|
+
@containers.each do |container|
|
|
45
|
+
container.call(type, data)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
@page << row if type == :row
|
|
49
|
+
check_finish(type)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def start
|
|
53
|
+
return unless @header
|
|
54
|
+
|
|
55
|
+
ERB.new(CGI.unescapeHTML(@header[:content])).result(binding)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def finish
|
|
59
|
+
return unless @footer
|
|
60
|
+
|
|
61
|
+
rst = ERB.new(CGI.unescapeHTML(@footer[:content])).result(binding)
|
|
62
|
+
|
|
63
|
+
@number += 1
|
|
64
|
+
rst
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def row
|
|
68
|
+
ERB.new(CGI.unescapeHTML(@row_template)).result(binding) if @row_template
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def row_number = @page.row_number
|
|
72
|
+
|
|
73
|
+
def sum(attr)
|
|
74
|
+
stat(attr)&.sum || 0.0
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def avg(attr)
|
|
78
|
+
stat(attr)&.avg || 0.0
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def count(attr)
|
|
82
|
+
stat(attr)&.count || 0
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def min(attr)
|
|
86
|
+
stat(attr)&.min
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def max(attr)
|
|
90
|
+
stat(attr)&.max
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def attr(attr)
|
|
94
|
+
return "" unless @cur_row
|
|
95
|
+
|
|
96
|
+
@cur_row[attr.to_s]
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def reset_stats
|
|
100
|
+
@stats.each_value(&:reset)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def force_header_reprint
|
|
104
|
+
@status = FINISHED if @status == STARTED
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def force_header_reprint_on_children
|
|
108
|
+
@containers.each do |container|
|
|
109
|
+
container.force_header_reprint if container.reprint_header_on_page_break
|
|
110
|
+
container.force_header_reprint_on_children
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
private
|
|
115
|
+
|
|
116
|
+
def create_locals
|
|
117
|
+
@locals.each do |name, value|
|
|
118
|
+
instance_variable_set("@#{name}".to_sym, value)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def update_stats
|
|
123
|
+
return unless @cur_row
|
|
124
|
+
|
|
125
|
+
@stats.each do |key, stats|
|
|
126
|
+
stats.add(@cur_row[key])
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def stat(attr)
|
|
131
|
+
@stats[attr.to_s]
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def check_attribute_break(data)
|
|
135
|
+
return unless @attr && @prev_row && data
|
|
136
|
+
return if self.is_a?(Page) || @status == FINISHED
|
|
137
|
+
|
|
138
|
+
prev_attr = @prev_row[@attr]
|
|
139
|
+
cur_attr = data[@attr]
|
|
140
|
+
|
|
141
|
+
if prev_attr != cur_attr
|
|
142
|
+
@containers.each do |container|
|
|
143
|
+
next if container.status == FINISHED
|
|
144
|
+
|
|
145
|
+
@page << container.finish
|
|
146
|
+
container.reset_stats
|
|
147
|
+
container.status = FINISHED
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
@page << finish
|
|
151
|
+
reset_stats
|
|
152
|
+
@status = FINISHED
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def check_page_break
|
|
157
|
+
return unless self.is_a?(Page)
|
|
158
|
+
return unless @row_number >= @valid_rows
|
|
159
|
+
|
|
160
|
+
@page << finish
|
|
161
|
+
reset_stats
|
|
162
|
+
@row_number = 0
|
|
163
|
+
@status = FINISHED
|
|
164
|
+
|
|
165
|
+
# Force header reprint on groups that requested it
|
|
166
|
+
force_header_reprint_on_children
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def check_started
|
|
170
|
+
return unless @status != STARTED
|
|
171
|
+
|
|
172
|
+
@page << start
|
|
173
|
+
@status = STARTED
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def check_finish(type)
|
|
177
|
+
return unless type == :finish && @status != FINISHED
|
|
178
|
+
|
|
179
|
+
@page << finish
|
|
180
|
+
@status = FINISHED
|
|
181
|
+
reset_stats
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
data/lib/repolho/main.rb
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# typed: false
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
require_relative "container"
|
|
4
|
+
require_relative "page"
|
|
5
|
+
require_relative "report"
|
|
6
|
+
require_relative "stats"
|
|
7
|
+
require "nokogiri"
|
|
8
|
+
|
|
9
|
+
module Repolho
|
|
10
|
+
class Main
|
|
11
|
+
def initialize(path, data, locals = {})
|
|
12
|
+
@path = path
|
|
13
|
+
@data = normalize_data(data)
|
|
14
|
+
@locals = locals
|
|
15
|
+
@columns = make_columns
|
|
16
|
+
@page = Page.new("page", locals, make_stats)
|
|
17
|
+
@report = Report.new("report", locals, make_stats)
|
|
18
|
+
@previous = ""
|
|
19
|
+
@after = ""
|
|
20
|
+
@body = ""
|
|
21
|
+
|
|
22
|
+
@report.containers << @page
|
|
23
|
+
@report.page = @page
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def content
|
|
27
|
+
@page.content
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def run
|
|
31
|
+
parse
|
|
32
|
+
|
|
33
|
+
@page.append_raw(@previous)
|
|
34
|
+
@page.append_raw(@body)
|
|
35
|
+
|
|
36
|
+
@data.each do |row|
|
|
37
|
+
@report.call(:row, row)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
@report.call(:finish, nil)
|
|
41
|
+
@page.append_raw(@after)
|
|
42
|
+
@page.finalize
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def make_columns
|
|
48
|
+
return [] if @data.empty?
|
|
49
|
+
|
|
50
|
+
sample = @data.first
|
|
51
|
+
|
|
52
|
+
sample.keys.select do |col|
|
|
53
|
+
val = sample[col]
|
|
54
|
+
val.is_a?(Numeric) || (val.is_a?(String) && val.match?(/\A-?\d+(\.\d+)?\z/))
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def normalize_data(data)
|
|
59
|
+
return [] if data.nil? || (data.respond_to?(:empty?) && data.empty?)
|
|
60
|
+
|
|
61
|
+
# ActiveRecord::Result (exec_query)
|
|
62
|
+
if data.respond_to?(:columns) && data.respond_to?(:each)
|
|
63
|
+
return data.map { |row| row.to_h.transform_keys(&:to_s) }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# ActiveRecord::Relation, array of models, or any Enumerable of objects with attributes
|
|
67
|
+
rows = data.respond_to?(:to_a) ? data.to_a : Array(data)
|
|
68
|
+
return [] if rows.empty?
|
|
69
|
+
|
|
70
|
+
first = rows.first
|
|
71
|
+
if first.respond_to?(:attributes)
|
|
72
|
+
return rows.map { |r| r.attributes.transform_keys(&:to_s) }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Already an array of hashes
|
|
76
|
+
if first.is_a?(Hash)
|
|
77
|
+
return rows.map { |r| r.transform_keys(&:to_s) }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
rows
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def make_stats
|
|
84
|
+
@columns.each_with_object({}) do |column, memo|
|
|
85
|
+
memo[column] = Stats.new
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def parse
|
|
90
|
+
content = File.read(@path)
|
|
91
|
+
extract_sections(content)
|
|
92
|
+
build_report_structure
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def extract_sections(content)
|
|
96
|
+
matches = content.gsub(/\n/, "").match(/(?<previous>\A.*?)(?<body><body[^>]*>.*?)(?<report><report>.*?<\/report>)(?<after>.*)\z/m)
|
|
97
|
+
|
|
98
|
+
@body = matches[:body]
|
|
99
|
+
@previous = matches[:previous]
|
|
100
|
+
@after = matches[:after]
|
|
101
|
+
@report_xml = matches[:report]
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def build_report_structure
|
|
105
|
+
parsed = Nokogiri::HTML.parse(@report_xml)
|
|
106
|
+
report = parsed.search("report")
|
|
107
|
+
|
|
108
|
+
@report.header = info(report.search("> report-header").first)
|
|
109
|
+
@report.footer = info(report.search("> report-footer").first)
|
|
110
|
+
|
|
111
|
+
build_page(report)
|
|
112
|
+
build_groups(report)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def build_page(report)
|
|
116
|
+
page = report.search("report-page").first
|
|
117
|
+
|
|
118
|
+
@page.header = info(page.search("> report-header").first)
|
|
119
|
+
@page.footer = info(page.search("> report-footer").first)
|
|
120
|
+
@page.row_template = report.search("report-row").first&.inner_html
|
|
121
|
+
@page.rows = page["rows"].to_i if page["rows"]
|
|
122
|
+
@page.page = @page
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def build_groups(report)
|
|
126
|
+
page = report.search("report-page").first
|
|
127
|
+
ref_container = @page
|
|
128
|
+
ref_element = page
|
|
129
|
+
|
|
130
|
+
while ref_element
|
|
131
|
+
next_container = ref_element.search("> report-group").first
|
|
132
|
+
break unless next_container
|
|
133
|
+
|
|
134
|
+
container = build_group(next_container)
|
|
135
|
+
ref_container.containers << container
|
|
136
|
+
|
|
137
|
+
ref_container = container
|
|
138
|
+
ref_element = next_container
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def build_group(element)
|
|
143
|
+
container = Container.new(element["id"], @locals, make_stats)
|
|
144
|
+
container.header = info(element.search("> report-header").first)
|
|
145
|
+
container.footer = info(element.search("> report-footer").first)
|
|
146
|
+
container.attr = element["attr"]
|
|
147
|
+
container.reprint_header_on_page_break = element["reprint-header-on-page-break"] == "true"
|
|
148
|
+
container.page = @page
|
|
149
|
+
container
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def info(element)
|
|
153
|
+
return { content: "" } unless element
|
|
154
|
+
|
|
155
|
+
{ content: element.inner_html }
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
data/lib/repolho/page.rb
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# typed: false
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Repolho
|
|
5
|
+
class Page < Container
|
|
6
|
+
attr_reader :content, :row_number, :rows, :page_number
|
|
7
|
+
|
|
8
|
+
def initialize(id, locals, stats)
|
|
9
|
+
super(id, locals, stats)
|
|
10
|
+
|
|
11
|
+
@content = []
|
|
12
|
+
@rows = 60
|
|
13
|
+
@valid_rows = @rows
|
|
14
|
+
@row_number = 1
|
|
15
|
+
@page_number = 1
|
|
16
|
+
@page_open = false
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def rows=(number)
|
|
20
|
+
@rows = number
|
|
21
|
+
@valid_rows = @rows
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def <<(content)
|
|
25
|
+
return unless content
|
|
26
|
+
|
|
27
|
+
open_page unless @page_open
|
|
28
|
+
|
|
29
|
+
content = content.is_a?(String) ? content : Array(content).join
|
|
30
|
+
|
|
31
|
+
parsed = Nokogiri::HTML(content)
|
|
32
|
+
trs = parsed.search("tr")
|
|
33
|
+
@row_number += trs.size
|
|
34
|
+
@content << content
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Appends content outside the page tbodies (previous, body, after)
|
|
38
|
+
def append_raw(content)
|
|
39
|
+
return unless content
|
|
40
|
+
|
|
41
|
+
@content << content
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def open_page
|
|
45
|
+
return if @page_open
|
|
46
|
+
|
|
47
|
+
@content << %(<tbody class="repolho-page" data-page="#{@page_number}" style="break-after: page; page-break-after: always;">)
|
|
48
|
+
@page_open = true
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def close_page
|
|
52
|
+
return unless @page_open
|
|
53
|
+
|
|
54
|
+
@content << "</tbody>"
|
|
55
|
+
@page_open = false
|
|
56
|
+
@page_number += 1
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def finalize
|
|
60
|
+
close_page
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def check_page_break
|
|
66
|
+
return unless @row_number >= @valid_rows
|
|
67
|
+
|
|
68
|
+
@page << finish
|
|
69
|
+
close_page
|
|
70
|
+
reset_stats
|
|
71
|
+
@row_number = 0
|
|
72
|
+
@status = FINISHED
|
|
73
|
+
|
|
74
|
+
force_header_reprint_on_children
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# typed: false
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Repolho
|
|
5
|
+
class Stats
|
|
6
|
+
attr_reader :sum, :count, :min, :max
|
|
7
|
+
|
|
8
|
+
def initialize
|
|
9
|
+
reset
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def add(value)
|
|
13
|
+
return if value.nil?
|
|
14
|
+
|
|
15
|
+
numeric = value.is_a?(Numeric) ? value : value.to_f
|
|
16
|
+
@sum += numeric
|
|
17
|
+
@count += 1
|
|
18
|
+
@min = numeric if @min.nil? || numeric < @min
|
|
19
|
+
@max = numeric if @max.nil? || numeric > @max
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def avg
|
|
23
|
+
@count.zero? ? 0.0 : @sum / @count
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def reset
|
|
27
|
+
@sum = 0.0
|
|
28
|
+
@count = 0
|
|
29
|
+
@min = nil
|
|
30
|
+
@max = nil
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
data/lib/repolho.rb
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "repolho/version"
|
|
4
|
+
require_relative "repolho/stats"
|
|
5
|
+
require_relative "repolho/container"
|
|
6
|
+
require_relative "repolho/page"
|
|
7
|
+
require_relative "repolho/report"
|
|
8
|
+
require_relative "repolho/main"
|
|
9
|
+
|
|
10
|
+
module Repolho
|
|
11
|
+
end
|
data.tar.gz.sig
ADDED
metadata
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: repolho
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Eustáquio Rangel
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain:
|
|
11
|
+
- |
|
|
12
|
+
-----BEGIN CERTIFICATE-----
|
|
13
|
+
MIIEhTCCAu2gAwIBAgIBATANBgkqhkiG9w0BAQsFADBEMQwwCgYDVQQDDAN0YXEx
|
|
14
|
+
HzAdBgoJkiaJk/IsZAEZFg9ldXN0YXF1aW9yYW5nZWwxEzARBgoJkiaJk/IsZAEZ
|
|
15
|
+
FgNjb20wHhcNMjYxMDIxMTE0NzI1WhcNMjcxMDIxMTE0NzI1WjBEMQwwCgYDVQQD
|
|
16
|
+
DAN0YXExHzAdBgoJkiaJk/IsZAEZFg9ldXN0YXF1aW9yYW5nZWwxEzARBgoJkiaJ
|
|
17
|
+
k/IsZAEZFgNjb20wggGiMA0GCSqGSIb3DQEBAQUAA4IBjwAwggGKAoIBgQC/IBvw
|
|
18
|
+
KRM7/ta27EE/3J4IMa2gU62Mr7TdpxE6tsakFL6sciN0P3iKUKl3t7nh7Hy24dFi
|
|
19
|
+
eRTojvesAwkLent90rltoVhYAScmPzsbm55phJNVAxGS1H43aAuOTiDskIscxmQ9
|
|
20
|
+
MIQsfP/UBJHfWnNXDykg3yCMkXV+ND7rJwG7NBxP5DL8BUFzxAvtdhi0Yp3BjXR6
|
|
21
|
+
1s7RDfsyoVpIuC9Kv0bZwhGivWF1skTiuuOMSPYdMAVcsap3yaOb59d9QCNB51ov
|
|
22
|
+
Djdunz6jJG8dcTJ8RW1ZoOutTJ9CBtWofi1gfXZeJox7AhpwaPeHiEqD1gTaJd4j
|
|
23
|
+
wGEJiA18O/z00Qw24JwBj3N7EgABRUmlqRuFnq8+1pUX01H/MLKBWbeWj24R/z6C
|
|
24
|
+
Hz472zW9KuJuYu26h3gmcMzm/dVPdRfKo6QuQOwVI5nkIsioMbyA4aw4c0IB7PVU
|
|
25
|
+
uxKm4N2d5FjuG+D9MS4mImUGWt+qaSTlq32hW3vXOalj06JJ7dIbImHzcN0CAwEA
|
|
26
|
+
AaOBgTB/MAkGA1UdEwQCMAAwCwYDVR0PBAQDAgSwMB0GA1UdDgQWBBQjX2PXFvyu
|
|
27
|
+
yyFP4oaoAcl5TRwc1DAiBgNVHREEGzAZgRd0YXFAZXVzdGFxdWlvcmFuZ2VsLmNv
|
|
28
|
+
bTAiBgNVHRIEGzAZgRd0YXFAZXVzdGFxdWlvcmFuZ2VsLmNvbTANBgkqhkiG9w0B
|
|
29
|
+
AQsFAAOCAYEASYzNdAlSlxxO7RDEDELrjlohNVmWJYYGmhe6p2Zis247iBvRwl1V
|
|
30
|
+
Ry9t1Kvp5CTwZFG3aSUCnKdcJ3fCIJfKOus55XHlRD0iNhg9n01LM4ngpYM9yTgi
|
|
31
|
+
T+0lwq0Y8zBW7oBZKhyu+XkkSCDPSWVSyKWlbfHxuSZrP4ccN4geDS76graQRyq+
|
|
32
|
+
U12DiNk5jMYstTMy4C8F09g+h2qoWc6SoeenJNa+GzMFMTkHxtGDYfU5BwPjucJX
|
|
33
|
+
ZH2W0WW/YNLsfevFhi/uIZTZii2AYdAw33lDEADgLVA2s8qqn6TF888jVEPPWV6m
|
|
34
|
+
sUrk8mNBh3BfGv6BP7/mSZrxdriwFbeUxN8oot4zCitSa0nYBqsa97eGmABrPrWz
|
|
35
|
+
5iRFVhCzMkHk+TMT2rAYX41OPS2Xa3YEJeMgmHJfnTIoEfIYQXtbkFqOMUJACISV
|
|
36
|
+
L0XcRn7yJijWxXFMBrnwp/4PHjPT0l6Qci+zxGly0D7LCTHa5WSyWujq4BZw55Yp
|
|
37
|
+
G13dwkTphaMB
|
|
38
|
+
-----END CERTIFICATE-----
|
|
39
|
+
date: 2026-10-21 00:00:00.000000000 Z
|
|
40
|
+
dependencies:
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: nokogiri
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - "~>"
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '1.16'
|
|
48
|
+
type: :runtime
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - "~>"
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '1.16'
|
|
55
|
+
description: Repolho generates HTML reports from .rep.erb templates with support for
|
|
56
|
+
nested groupings, page breaks, headers/footers, and statistics (sum, avg, min, max).
|
|
57
|
+
Works with ActiveRecord::Result, Relation, or arrays of hashes.
|
|
58
|
+
email:
|
|
59
|
+
- taq@eustaquiorangel.com
|
|
60
|
+
executables: []
|
|
61
|
+
extensions: []
|
|
62
|
+
extra_rdoc_files: []
|
|
63
|
+
files:
|
|
64
|
+
- LICENSE
|
|
65
|
+
- README.md
|
|
66
|
+
- certs/taq.pem
|
|
67
|
+
- lib/repolho.rb
|
|
68
|
+
- lib/repolho/container.rb
|
|
69
|
+
- lib/repolho/main.rb
|
|
70
|
+
- lib/repolho/page.rb
|
|
71
|
+
- lib/repolho/report.rb
|
|
72
|
+
- lib/repolho/stats.rb
|
|
73
|
+
- lib/repolho/version.rb
|
|
74
|
+
homepage: https://github.com/taq/repolho
|
|
75
|
+
licenses:
|
|
76
|
+
- MIT
|
|
77
|
+
metadata:
|
|
78
|
+
homepage_uri: https://github.com/taq/repolho
|
|
79
|
+
source_code_uri: https://github.com/taq/repolho
|
|
80
|
+
post_install_message:
|
|
81
|
+
rdoc_options: []
|
|
82
|
+
require_paths:
|
|
83
|
+
- lib
|
|
84
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
85
|
+
requirements:
|
|
86
|
+
- - ">="
|
|
87
|
+
- !ruby/object:Gem::Version
|
|
88
|
+
version: 3.2.0
|
|
89
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
90
|
+
requirements:
|
|
91
|
+
- - ">="
|
|
92
|
+
- !ruby/object:Gem::Version
|
|
93
|
+
version: '0'
|
|
94
|
+
requirements: []
|
|
95
|
+
rubygems_version: 3.5.3
|
|
96
|
+
signing_key:
|
|
97
|
+
specification_version: 4
|
|
98
|
+
summary: Report generator for Ruby and Rails from .rep.erb templates
|
|
99
|
+
test_files: []
|
metadata.gz.sig
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
;�n���W;�Q1�B���i&�`=L6]X���ژ�������i����oU�U���~�D�.IvU���}/�lpW�NH/��Ù����>W�}�$��lya[Ae��1�%���`Ck&g��������D=�Bm���.c�^�q%�Y9�Y���t��YQ����%2 ��G�w<��l3��f�.�ddޟ�9�:{���i�7n�i�c+��.�ę���d�O}�s�7��MD&�>���/�����M��r�t����h��
|
|
2
|
+
C7�v������m`4�d�_�}\~��J�b0K
|
|
3
|
+
JI{be!��XN�*lRBհ겓o�!f٘'�"k�\b�\��b$�(��Ǥp�
|