feat: html template
This commit is contained in:
10
go.mod
10
go.mod
@@ -2,13 +2,21 @@ module git.schreifuchs.ch/lou-taylor/accounting
|
||||
|
||||
go 1.24.5
|
||||
|
||||
require code.gitea.io/sdk/gitea v0.21.0
|
||||
require (
|
||||
code.gitea.io/sdk/gitea v0.21.0
|
||||
github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a
|
||||
github.com/jedib0t/go-pretty/v6 v6.6.8
|
||||
golang.org/x/net v0.21.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/42wim/httpsig v1.2.2 // indirect
|
||||
github.com/davidmz/go-pageant v1.0.2 // indirect
|
||||
github.com/go-fed/httpsig v1.1.0 // indirect
|
||||
github.com/hashicorp/go-version v1.7.0 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
golang.org/x/crypto v0.33.0 // indirect
|
||||
golang.org/x/sys v0.30.0 // indirect
|
||||
golang.org/x/text v0.22.0 // indirect
|
||||
)
|
||||
|
||||
13
go.sum
13
go.sum
@@ -8,10 +8,19 @@ github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454Wv
|
||||
github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE=
|
||||
github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI=
|
||||
github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM=
|
||||
github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a h1:l7A0loSszR5zHd/qK53ZIHMO8b3bBSmENnQ6eKnUT0A=
|
||||
github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
|
||||
github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY=
|
||||
github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||
github.com/jedib0t/go-pretty/v6 v6.6.8 h1:JnnzQeRz2bACBobIaa/r+nqjvws4yEhcmaZ4n1QzsEc=
|
||||
github.com/jedib0t/go-pretty/v6 v6.6.8/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
@@ -21,6 +30,8 @@ golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
|
||||
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -31,6 +42,8 @@ golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU=
|
||||
golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
210
index.html
Normal file
210
index.html
Normal file
@@ -0,0 +1,210 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Rechnung vom 0001-01-01 00:00:00 +0000 UTC</title>
|
||||
|
||||
<style>
|
||||
/* body { */
|
||||
/* font-family: sans-serif; */
|
||||
/* font-size: 14px; */
|
||||
/* } */
|
||||
/**/
|
||||
/* table { */
|
||||
/* width: 100%; */
|
||||
/* border-collapse: collapse; */
|
||||
/* } */
|
||||
/**/
|
||||
/* th, */
|
||||
/* td { */
|
||||
/* border: 1px solid #ddd; */
|
||||
/* padding: 8px; */
|
||||
/* text-align: left; */
|
||||
/* } */
|
||||
/**/
|
||||
/* th { */
|
||||
/* background-color: #f4f4f4; */
|
||||
/* font-weight: bold; */
|
||||
/* } */
|
||||
/**/
|
||||
/* tr:nth-child(even) { */
|
||||
/* background-color: #f9f9f9; */
|
||||
/* } */
|
||||
body {
|
||||
font-family: sans-serif;
|
||||
margin: 40px;
|
||||
color: #333;
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 2em;
|
||||
}
|
||||
.company,
|
||||
.client {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
table,
|
||||
th,
|
||||
td {
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
th,
|
||||
td {
|
||||
padding: 10px;
|
||||
text-align: left;
|
||||
}
|
||||
th {
|
||||
background-color: #f2f2f2;
|
||||
}
|
||||
tfoot td {
|
||||
font-weight: bold;
|
||||
}
|
||||
h2 {
|
||||
margin-top: 40px;
|
||||
}
|
||||
article {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
footer {
|
||||
margin-top: 40px;
|
||||
font-size: 0.9em;
|
||||
text-align: center;
|
||||
color: #666;
|
||||
}
|
||||
.totals {
|
||||
float: right;
|
||||
width: 300px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.totals table {
|
||||
border: none;
|
||||
}
|
||||
.totals td {
|
||||
border: none;
|
||||
padding: 5px 10px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="company">
|
||||
<h2>.CompanyName <h2>
|
||||
<p>.CompanyAddress <p>
|
||||
<p>.CompanyContact <p>
|
||||
</div>
|
||||
<div class="invoice-info">
|
||||
<p><strong>Rechnung:</strong> .InvoiceNumber </p>
|
||||
<p><strong>Datum:</strong> .Date </p>
|
||||
<p><strong>Fällig am:</strong> .DueDate </p>
|
||||
</div>
|
||||
</header>
|
||||
<section class="client">
|
||||
<h2>Rechnung an:</h2>
|
||||
<p> .ClientName </p>
|
||||
<p> .ClientAddress </p>
|
||||
<p> .ClientContact </p>
|
||||
</section>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>FID</th>
|
||||
<th>Name</th>
|
||||
<th>Zeitaufwand</th>
|
||||
<th>Stundensatz</th>
|
||||
<th>Preis CHF</th>
|
||||
<th>Fertiggestellt</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td>6</td>
|
||||
<td>Multilingual</td>
|
||||
<td>2.50 h</td>
|
||||
<td>16 CHF/h</td>
|
||||
<td>40.00 CHF</td>
|
||||
|
||||
<td>21.08.2025 18:05</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>1</td>
|
||||
<td>asdf</td>
|
||||
<td>1.72 h</td>
|
||||
<td>16 CHF/h</td>
|
||||
<td>27.47 CHF</td>
|
||||
|
||||
<td>01.08.2025 14:04</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="totals">
|
||||
<table>
|
||||
<tr>
|
||||
<td>Gesamtbetrag:</td>
|
||||
<td> CHF</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
|
||||
|
||||
<h2>Details zu den Features</h2>
|
||||
|
||||
|
||||
<article>
|
||||
<h3>6: Multilingual</h3>
|
||||
<pre><code class="language-info">duration: 2h 30min
|
||||
</code></pre>
|
||||
|
||||
<p>The application must support English and German.</p>
|
||||
|
||||
<h5 id="todo-s">TODO’s</h5>
|
||||
|
||||
<ul>
|
||||
<li><input type="checkbox" checked disabled> Add multiple languages to Pages</li>
|
||||
<li><input type="checkbox" checked disabled> Add multiple languages to Events</li>
|
||||
</ul>
|
||||
|
||||
</article>
|
||||
|
||||
<article>
|
||||
<h3>1: asdf</h3>
|
||||
<p>Hello</p>
|
||||
|
||||
<pre><code class="language-info">duration: 1h 43min
|
||||
</code></pre>
|
||||
|
||||
<h4 id="adökfjaösldkjflaa">adökfjaösldkjflaa</h4>
|
||||
|
||||
<h5 id="asdfads">ASDFADS</h5>
|
||||
|
||||
<h6 id="adllglggl">adllglggl</h6>
|
||||
|
||||
</article>
|
||||
|
||||
|
||||
</section>
|
||||
<footer>
|
||||
<p>Bitte überweisen Sie den Gesamtbetrag bis zum Fälligkeitsdatum auf folgendes Konto:</p>
|
||||
<p> .BankDetails </p>
|
||||
<p>Vielen Dank für Ihr Vertrauen!</p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
76
issue/issue.go
Normal file
76
issue/issue.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package issue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.gitea.io/sdk/gitea"
|
||||
"github.com/jedib0t/go-pretty/v6/table"
|
||||
)
|
||||
|
||||
func FromGiteas(is []*gitea.Issue, mindur time.Duration) []Issue {
|
||||
issues := make([]Issue, 0, len(is))
|
||||
|
||||
for _, i := range is {
|
||||
if i == nil {
|
||||
continue
|
||||
}
|
||||
issue := FromGitea(*i)
|
||||
|
||||
if issue.Duration < mindur {
|
||||
continue
|
||||
}
|
||||
|
||||
issues = append(issues, issue)
|
||||
}
|
||||
return issues
|
||||
}
|
||||
|
||||
func FromGitea(i gitea.Issue) Issue {
|
||||
issue := Issue{Issue: i}
|
||||
|
||||
issue.Duration, _ = ExtractDuration(i.Body)
|
||||
|
||||
return issue
|
||||
}
|
||||
|
||||
func ExtractDuration(text string) (duration time.Duration, err error) {
|
||||
// First capture only the content inside ```info ... ```
|
||||
reBlock := regexp.MustCompile("(?s)```info(.*?)```")
|
||||
block := reBlock.FindStringSubmatch(text)
|
||||
if len(block) < 2 {
|
||||
err = fmt.Errorf("no info block found")
|
||||
return
|
||||
}
|
||||
|
||||
// Now extract the duration line from inside that block
|
||||
reDuration := regexp.MustCompile(`duration:\s*([0-9hmin\s]+)`)
|
||||
match := reDuration.FindStringSubmatch(block[1])
|
||||
if len(match) < 2 {
|
||||
err = fmt.Errorf("no duration found inside info block")
|
||||
return
|
||||
}
|
||||
dur := strings.TrimSpace(match[1])
|
||||
dur = strings.ReplaceAll(dur, "min", "m")
|
||||
dur = strings.ReplaceAll(dur, " ", "") // remove spaces
|
||||
|
||||
return time.ParseDuration(dur)
|
||||
}
|
||||
|
||||
func Print(issues []Issue) {
|
||||
t := table.NewWriter()
|
||||
t.SetOutputMirror(os.Stdout)
|
||||
t.AppendHeader(table.Row{"#", "Title", "Body", "Duration", "Finished by"})
|
||||
|
||||
for i, iss := range issues {
|
||||
if i != 0 {
|
||||
t.AppendSeparator()
|
||||
}
|
||||
t.AppendRow(table.Row{iss.Index, iss.Title, iss.Body, iss.Duration, iss.Closed})
|
||||
}
|
||||
|
||||
t.Render()
|
||||
}
|
||||
12
issue/resource.go
Normal file
12
issue/resource.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package issue
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"code.gitea.io/sdk/gitea"
|
||||
)
|
||||
|
||||
type Issue struct {
|
||||
gitea.Issue
|
||||
Duration time.Duration
|
||||
}
|
||||
24
main.go
24
main.go
@@ -1,11 +1,12 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"code.gitea.io/sdk/gitea"
|
||||
"git.schreifuchs.ch/lou-taylor/accounting/issue"
|
||||
"git.schreifuchs.ch/lou-taylor/accounting/report"
|
||||
)
|
||||
|
||||
type Repo struct {
|
||||
@@ -22,7 +23,7 @@ func main() {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
var issues []*gitea.Issue
|
||||
var is []*gitea.Issue
|
||||
for _, repo := range []Repo{
|
||||
{"lou-taylor", "lou-taylor-web"},
|
||||
{"lou-taylor", "lou-taylor-api"},
|
||||
@@ -35,26 +36,25 @@ func main() {
|
||||
ListOptions: gitea.ListOptions{Page: 0, PageSize: 99999},
|
||||
Since: time.Now().AddDate(0, -1, 0),
|
||||
Before: time.Now(),
|
||||
State: gitea.StateClosed,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
issues = append(issues, iss...)
|
||||
is = append(is, iss...)
|
||||
}
|
||||
// for _, issue := range issues {
|
||||
// fmt.Println(issue.Body)
|
||||
// }
|
||||
//
|
||||
issues = Filter(
|
||||
issues,
|
||||
|
||||
is = Filter(
|
||||
is,
|
||||
func(i *gitea.Issue) bool {
|
||||
return i.Closed != nil && i.Closed.After(time.Now().AddDate(0, -1, 0))
|
||||
},
|
||||
)
|
||||
|
||||
json.NewEncoder(os.Stdout).Encode(issues)
|
||||
issues := issue.FromGiteas(is, time.Minute*15)
|
||||
r := report.Report{Issues: issues}
|
||||
fmt.Print(r.ToHTML())
|
||||
}
|
||||
|
||||
func Filter[T any](slice []T, ok func(T) bool) []T {
|
||||
|
||||
42
report/html.go
Normal file
42
report/html.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package report
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"regexp"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// OffsetHTags offsets all <h1>-<h6> tags in the HTML by the given amount.
|
||||
// If the resulting heading level exceeds 6, it is capped at 6.
|
||||
func offsetHTags(amount int, html template.HTML) template.HTML {
|
||||
// Regular expression to match opening and closing h tags, e.g., <h1> or </h2>
|
||||
re := regexp.MustCompile(`(?i)<(/?)h([1-6])(\s[^>]*)?>`)
|
||||
|
||||
// Replace all matches
|
||||
result := re.ReplaceAllStringFunc(string(html), func(tag string) string {
|
||||
matches := re.FindStringSubmatch(tag)
|
||||
if len(matches) < 4 {
|
||||
return tag
|
||||
}
|
||||
|
||||
closingSlash := matches[1] // "/" if closing tag
|
||||
levelStr := matches[2] // heading level
|
||||
attrs := matches[3] // attributes like ' class="foo"'
|
||||
|
||||
level, err := strconv.Atoi(levelStr)
|
||||
if err != nil {
|
||||
return tag
|
||||
}
|
||||
|
||||
newLevel := level + amount
|
||||
if newLevel < 1 {
|
||||
newLevel = 1
|
||||
} else if newLevel > 6 {
|
||||
newLevel = 6
|
||||
}
|
||||
|
||||
return fmt.Sprintf("<%sh%d%s>", closingSlash, newLevel, attrs)
|
||||
})
|
||||
return template.HTML(result)
|
||||
}
|
||||
79
report/html_test.go
Normal file
79
report/html_test.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package report
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOffsetHTags(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
html string
|
||||
amount int
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "simple offset",
|
||||
html: `<h1>Main</h1><h2>Sub</h2>`,
|
||||
amount: 2,
|
||||
expected: `<h3>Main</h3><h4>Sub</h4>`,
|
||||
},
|
||||
{
|
||||
name: "offset beyond h6",
|
||||
html: `<h5>Almost max</h5><h6>Max</h6>`,
|
||||
amount: 3,
|
||||
expected: `<h6>Almost max</h6><h6>Max</h6>`,
|
||||
},
|
||||
{
|
||||
name: "negative offset",
|
||||
html: `<h2>Heading</h2><h3>Subheading</h3>`,
|
||||
amount: -2,
|
||||
expected: `<h1>Heading</h1><h1>Subheading</h1>`,
|
||||
},
|
||||
{
|
||||
name: "no h tags",
|
||||
html: `<p>Paragraph</p>`,
|
||||
amount: 2,
|
||||
expected: `<p>Paragraph</p>`,
|
||||
},
|
||||
{
|
||||
name: "mixed content",
|
||||
html: `<h1>Title</h1><div><h2>Inner</h2></div>`,
|
||||
amount: 1,
|
||||
expected: `<h2>Title</h2><div><h3>Inner</h3></div>`,
|
||||
},
|
||||
{
|
||||
name: "real one",
|
||||
html: `<p>Hello</p>
|
||||
|
||||
<pre><code class="language-info">duration: 1h 43min
|
||||
</code></pre>
|
||||
|
||||
<h1 id="adökfjaösldkjflaa">adökfjaösldkjflaa</h1>
|
||||
|
||||
<h2 id="asdfads">ASDFADS</h2>
|
||||
|
||||
<h3 id="adllglggl">adllglggl</h3>`,
|
||||
amount: 3,
|
||||
expected: `<p>Hello</p>
|
||||
|
||||
<pre><code class="language-info">duration: 1h 43min
|
||||
</code></pre>
|
||||
|
||||
<h4 id="adökfjaösldkjflaa">adökfjaösldkjflaa</h4>
|
||||
|
||||
<h5 id="asdfads">ASDFADS</h5>
|
||||
|
||||
<h6 id="adllglggl">adllglggl</h6>`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := offsetHTags(tt.amount, template.HTML(tt.html))
|
||||
if string(got) != tt.expected {
|
||||
t.Errorf("OffsetHTags() = %q, want %q", got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
49
report/markdown.go
Normal file
49
report/markdown.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package report
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/gomarkdown/markdown"
|
||||
"github.com/gomarkdown/markdown/html"
|
||||
"github.com/gomarkdown/markdown/parser"
|
||||
)
|
||||
|
||||
func mdToHTML(md string) template.HTML {
|
||||
md = markdownCheckboxesToHTML(md)
|
||||
|
||||
// create markdown parser with extensions
|
||||
extensions := parser.CommonExtensions | parser.AutoHeadingIDs | parser.NoEmptyLineBeforeBlock
|
||||
p := parser.NewWithExtensions(extensions)
|
||||
doc := p.Parse([]byte(md))
|
||||
|
||||
// create HTML renderer with extensions
|
||||
htmlFlags := html.CommonFlags | html.HrefTargetBlank
|
||||
opts := html.RendererOptions{Flags: htmlFlags}
|
||||
renderer := html.NewRenderer(opts)
|
||||
|
||||
return template.HTML(markdown.Render(doc, renderer))
|
||||
}
|
||||
|
||||
// markdownCheckboxesToHTML keeps a Markdown list but replaces checkboxes with <input>.
|
||||
func markdownCheckboxesToHTML(markdown string) string {
|
||||
lines := strings.Split(markdown, "\n")
|
||||
checkboxPattern := regexp.MustCompile(`^(\s*[-*]\s+)\[( |x|X)\][\p{Zs}\s]*(.*)$`)
|
||||
|
||||
for i, line := range lines {
|
||||
if matches := checkboxPattern.FindStringSubmatch(line); matches != nil {
|
||||
prefix := matches[1] // "- " or "* "
|
||||
checked := strings.ToLower(matches[2]) == "x"
|
||||
content := matches[3] // task text
|
||||
if checked {
|
||||
lines[i] = fmt.Sprintf(`%s<input type="checkbox" checked disabled> %s`, prefix, content)
|
||||
} else {
|
||||
lines[i] = fmt.Sprintf(`%s<input type="checkbox" disabled> %s`, prefix, content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
27
report/markdown_test.go
Normal file
27
report/markdown_test.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package report
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMarkdownCheckboxesToHTML(t *testing.T) {
|
||||
input := `
|
||||
The application must support English and German.
|
||||
|
||||
## TODO's
|
||||
- [x] Add multiple languages to Pages
|
||||
- [x] Add multiple languages to Events`
|
||||
|
||||
expected := `
|
||||
The application must support English and German.
|
||||
|
||||
## TODO's
|
||||
- <input type="checkbox" checked disabled> Add multiple languages to Pages
|
||||
- <input type="checkbox" checked disabled> Add multiple languages to Events`
|
||||
|
||||
output := markdownCheckboxesToHTML(input)
|
||||
|
||||
if output != expected {
|
||||
t.Errorf("unexpected output:\nGot:\n%s\n\nExpected:\n%s", output, expected)
|
||||
}
|
||||
}
|
||||
15
report/resource.go
Normal file
15
report/resource.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package report
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"time"
|
||||
|
||||
"git.schreifuchs.ch/lou-taylor/accounting/issue"
|
||||
)
|
||||
|
||||
type Report struct {
|
||||
Date time.Time
|
||||
Issues []issue.Issue
|
||||
Style template.CSS
|
||||
Total string
|
||||
}
|
||||
89
report/style.css
Normal file
89
report/style.css
Normal file
@@ -0,0 +1,89 @@
|
||||
/* body { */
|
||||
/* font-family: sans-serif; */
|
||||
/* font-size: 14px; */
|
||||
/* } */
|
||||
/**/
|
||||
/* table { */
|
||||
/* width: 100%; */
|
||||
/* border-collapse: collapse; */
|
||||
/* } */
|
||||
/**/
|
||||
/* th, */
|
||||
/* td { */
|
||||
/* border: 1px solid #ddd; */
|
||||
/* padding: 8px; */
|
||||
/* text-align: left; */
|
||||
/* } */
|
||||
/**/
|
||||
/* th { */
|
||||
/* background-color: #f4f4f4; */
|
||||
/* font-weight: bold; */
|
||||
/* } */
|
||||
/**/
|
||||
/* tr:nth-child(even) { */
|
||||
/* background-color: #f9f9f9; */
|
||||
/* } */
|
||||
body {
|
||||
font-family: sans-serif;
|
||||
margin: 40px;
|
||||
color: #333;
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 2em;
|
||||
}
|
||||
.company,
|
||||
.client {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
table,
|
||||
th,
|
||||
td {
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
th,
|
||||
td {
|
||||
padding: 10px;
|
||||
text-align: left;
|
||||
}
|
||||
th {
|
||||
background-color: #f2f2f2;
|
||||
}
|
||||
tfoot td {
|
||||
font-weight: bold;
|
||||
}
|
||||
h2 {
|
||||
margin-top: 40px;
|
||||
}
|
||||
article {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
footer {
|
||||
margin-top: 40px;
|
||||
font-size: 0.9em;
|
||||
text-align: center;
|
||||
color: #666;
|
||||
}
|
||||
.totals {
|
||||
float: right;
|
||||
width: 300px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.totals table {
|
||||
border: none;
|
||||
}
|
||||
.totals td {
|
||||
border: none;
|
||||
padding: 5px 10px;
|
||||
text-align: right;
|
||||
}
|
||||
45
report/template.go
Normal file
45
report/template.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package report
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"time"
|
||||
)
|
||||
|
||||
//go:embed template.html
|
||||
var htmlTemplate string
|
||||
|
||||
//go:embed style.css
|
||||
var style template.CSS
|
||||
|
||||
func (r Report) ToHTML() string {
|
||||
tmpl := template.Must(
|
||||
template.New("report").Funcs(template.FuncMap{
|
||||
"md": mdToHTML,
|
||||
"oh": offsetHTags,
|
||||
"price": applyRate,
|
||||
"time": fmtTime,
|
||||
"duration": fmtDuration,
|
||||
}).Parse(htmlTemplate))
|
||||
|
||||
r.Style = style
|
||||
|
||||
buf := new(bytes.Buffer)
|
||||
tmpl.Execute(buf, r)
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func applyRate(dur time.Duration) string {
|
||||
cost := dur.Hours() * 16
|
||||
return fmt.Sprintf("%.2f", cost)
|
||||
}
|
||||
|
||||
func fmtTime(t time.Time) string {
|
||||
return t.Format("02.08.2006 15:04")
|
||||
}
|
||||
|
||||
func fmtDuration(d time.Duration) string {
|
||||
return fmt.Sprintf("%.2f h", d.Hours())
|
||||
}
|
||||
85
report/template.html
Normal file
85
report/template.html
Normal file
@@ -0,0 +1,85 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Rechnung vom {{ .Date }}</title>
|
||||
<!-- <link href="css/style.css" rel="stylesheet" /> -->
|
||||
<style>
|
||||
{{ .Style }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="company">
|
||||
<h2>.CompanyName <h2>
|
||||
<p>.CompanyAddress <p>
|
||||
<p>.CompanyContact <p>
|
||||
</div>
|
||||
<div class="invoice-info">
|
||||
<p><strong>Rechnung:</strong> .InvoiceNumber </p>
|
||||
<p><strong>Datum:</strong> .Date </p>
|
||||
<p><strong>Fällig am:</strong> .DueDate </p>
|
||||
</div>
|
||||
</header>
|
||||
<section class="client">
|
||||
<h2>Rechnung an:</h2>
|
||||
<p> .ClientName </p>
|
||||
<p> .ClientAddress </p>
|
||||
<p> .ClientContact </p>
|
||||
</section>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>FID</th>
|
||||
<th>Name</th>
|
||||
<th>Zeitaufwand</th>
|
||||
<th>Stundensatz</th>
|
||||
<th>Preis CHF</th>
|
||||
<th>Fertiggestellt</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{ range .Issues }}
|
||||
<tr>
|
||||
<td>{{ .Index }}</td>
|
||||
<td>{{ .Title }}</td>
|
||||
<td>{{ .Duration | duration }}</td>
|
||||
<td>16 CHF/h</td>
|
||||
<td>{{ .Duration | price }} CHF</td>
|
||||
|
||||
<td>{{ .Closed | time }}</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="totals">
|
||||
<table>
|
||||
<tr>
|
||||
<td>Gesamtbetrag:</td>
|
||||
<td>{{ .Total }} CHF</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
|
||||
|
||||
<h2>Details zu den Features</h2>
|
||||
|
||||
{{ range .Issues }}
|
||||
<article>
|
||||
<h3>{{ .Index }}: {{ .Title }}</h3>
|
||||
{{ .Body | md | oh 3 }}
|
||||
</article>
|
||||
{{ end }}
|
||||
|
||||
</section>
|
||||
<footer>
|
||||
<p>Bitte überweisen Sie den Gesamtbetrag bis zum Fälligkeitsdatum auf folgendes Konto:</p>
|
||||
<p> .BankDetails </p>
|
||||
<p>Vielen Dank für Ihr Vertrauen!</p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user