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

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>