feat: html template

This commit is contained in:
u80864958
2025-08-22 11:47:34 +02:00
parent 68b0256f77
commit 2e279c9b13
14 changed files with 763 additions and 13 deletions

10
go.mod
View File

@@ -2,13 +2,21 @@ module git.schreifuchs.ch/lou-taylor/accounting
go 1.24.5 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 ( require (
github.com/42wim/httpsig v1.2.2 // indirect github.com/42wim/httpsig v1.2.2 // indirect
github.com/davidmz/go-pageant v1.0.2 // indirect github.com/davidmz/go-pageant v1.0.2 // indirect
github.com/go-fed/httpsig v1.1.0 // indirect github.com/go-fed/httpsig v1.1.0 // indirect
github.com/hashicorp/go-version v1.7.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/crypto v0.33.0 // indirect
golang.org/x/sys v0.30.0 // indirect golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.22.0 // indirect
) )

13
go.sum
View File

@@ -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/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 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI=
github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM= 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 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY=
github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= 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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 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= 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/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-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 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-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-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/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/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.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 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= 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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

210
index.html Normal file
View 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 &#43;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&rsquo;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
View 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
View 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
View File

@@ -1,11 +1,12 @@
package main package main
import ( import (
"encoding/json" "fmt"
"os"
"time" "time"
"code.gitea.io/sdk/gitea" "code.gitea.io/sdk/gitea"
"git.schreifuchs.ch/lou-taylor/accounting/issue"
"git.schreifuchs.ch/lou-taylor/accounting/report"
) )
type Repo struct { type Repo struct {
@@ -22,7 +23,7 @@ func main() {
panic(err) panic(err)
} }
var issues []*gitea.Issue var is []*gitea.Issue
for _, repo := range []Repo{ for _, repo := range []Repo{
{"lou-taylor", "lou-taylor-web"}, {"lou-taylor", "lou-taylor-web"},
{"lou-taylor", "lou-taylor-api"}, {"lou-taylor", "lou-taylor-api"},
@@ -35,26 +36,25 @@ func main() {
ListOptions: gitea.ListOptions{Page: 0, PageSize: 99999}, ListOptions: gitea.ListOptions{Page: 0, PageSize: 99999},
Since: time.Now().AddDate(0, -1, 0), Since: time.Now().AddDate(0, -1, 0),
Before: time.Now(), Before: time.Now(),
State: gitea.StateClosed,
}, },
) )
if err != nil { if err != nil {
panic(err) panic(err)
} }
issues = append(issues, iss...) is = append(is, iss...)
} }
// for _, issue := range issues {
// fmt.Println(issue.Body) is = Filter(
// } is,
//
issues = Filter(
issues,
func(i *gitea.Issue) bool { func(i *gitea.Issue) bool {
return i.Closed != nil && i.Closed.After(time.Now().AddDate(0, -1, 0)) return i.Closed != nil && i.Closed.After(time.Now().AddDate(0, -1, 0))
}, },
) )
issues := issue.FromGiteas(is, time.Minute*15)
json.NewEncoder(os.Stdout).Encode(issues) r := report.Report{Issues: issues}
fmt.Print(r.ToHTML())
} }
func Filter[T any](slice []T, ok func(T) bool) []T { func Filter[T any](slice []T, ok func(T) bool) []T {

42
report/html.go Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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>