From 2e279c9b1334bafa9f9d8d74007848e50734326a Mon Sep 17 00:00:00 2001 From: u80864958 Date: Fri, 22 Aug 2025 11:47:34 +0200 Subject: [PATCH] feat: html template --- go.mod | 10 +- go.sum | 13 +++ index.html | 210 ++++++++++++++++++++++++++++++++++++++++ issue/issue.go | 76 +++++++++++++++ issue/resource.go | 12 +++ main.go | 24 ++--- report/html.go | 42 ++++++++ report/html_test.go | 79 +++++++++++++++ report/markdown.go | 49 ++++++++++ report/markdown_test.go | 27 ++++++ report/resource.go | 15 +++ report/style.css | 89 +++++++++++++++++ report/template.go | 45 +++++++++ report/template.html | 85 ++++++++++++++++ 14 files changed, 763 insertions(+), 13 deletions(-) create mode 100644 index.html create mode 100644 issue/issue.go create mode 100644 issue/resource.go create mode 100644 report/html.go create mode 100644 report/html_test.go create mode 100644 report/markdown.go create mode 100644 report/markdown_test.go create mode 100644 report/resource.go create mode 100644 report/style.css create mode 100644 report/template.go create mode 100644 report/template.html diff --git a/go.mod b/go.mod index 811921a..f9e115d 100644 --- a/go.mod +++ b/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 ) diff --git a/go.sum b/go.sum index 13077cc..1077c1f 100644 --- a/go.sum +++ b/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= diff --git a/index.html b/index.html new file mode 100644 index 0000000..d0e6187 --- /dev/null +++ b/index.html @@ -0,0 +1,210 @@ + + + + + + Rechnung vom 0001-01-01 00:00:00 +0000 UTC + + + + +
+
+

.CompanyName

+

.CompanyAddress

+

.CompanyContact

+

+
+

Rechnung: .InvoiceNumber

+

Datum: .Date

+

Fällig am: .DueDate

+
+
+
+

Rechnung an:

+

.ClientName

+

.ClientAddress

+

.ClientContact

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FIDNameZeitaufwandStundensatzPreis CHFFertiggestellt
6Multilingual2.50 h16 CHF/h40.00 CHF21.08.2025 18:05
1asdf1.72 h16 CHF/h27.47 CHF01.08.2025 14:04
+ +
+ + + + + +
Gesamtbetrag: CHF
+
+ +
+ + +

Details zu den Features

+ + +
+

6: Multilingual

+
duration: 2h 30min
+
+ +

The application must support English and German.

+ +
TODO’s
+ +
    +
  • Add multiple languages to Pages
  • +
  • Add multiple languages to Events
  • +
+ +
+ +
+

1: asdf

+

Hello

+ +
duration: 1h 43min
+
+ +

adökfjaösldkjflaa

+ +
ASDFADS
+ +
adllglggl
+ +
+ + +
+ + + diff --git a/issue/issue.go b/issue/issue.go new file mode 100644 index 0000000..79f34ef --- /dev/null +++ b/issue/issue.go @@ -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() +} diff --git a/issue/resource.go b/issue/resource.go new file mode 100644 index 0000000..0fc9154 --- /dev/null +++ b/issue/resource.go @@ -0,0 +1,12 @@ +package issue + +import ( + "time" + + "code.gitea.io/sdk/gitea" +) + +type Issue struct { + gitea.Issue + Duration time.Duration +} diff --git a/main.go b/main.go index 03c1931..d27774b 100644 --- a/main.go +++ b/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 { diff --git a/report/html.go b/report/html.go new file mode 100644 index 0000000..6acd0b4 --- /dev/null +++ b/report/html.go @@ -0,0 +1,42 @@ +package report + +import ( + "fmt" + "html/template" + "regexp" + "strconv" +) + +// OffsetHTags offsets all

-

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.,

or

+ 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) +} diff --git a/report/html_test.go b/report/html_test.go new file mode 100644 index 0000000..e709feb --- /dev/null +++ b/report/html_test.go @@ -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: `

Main

Sub

`, + amount: 2, + expected: `

Main

Sub

`, + }, + { + name: "offset beyond h6", + html: `
Almost max
Max
`, + amount: 3, + expected: `
Almost max
Max
`, + }, + { + name: "negative offset", + html: `

Heading

Subheading

`, + amount: -2, + expected: `

Heading

Subheading

`, + }, + { + name: "no h tags", + html: `

Paragraph

`, + amount: 2, + expected: `

Paragraph

`, + }, + { + name: "mixed content", + html: `

Title

Inner

`, + amount: 1, + expected: `

Title

Inner

`, + }, + { + name: "real one", + html: `

Hello

+ +
duration: 1h 43min
+
+ +

adökfjaösldkjflaa

+ +

ASDFADS

+ +

adllglggl

`, + amount: 3, + expected: `

Hello

+ +
duration: 1h 43min
+
+ +

adökfjaösldkjflaa

+ +
ASDFADS
+ +
adllglggl
`, + }, + } + + 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) + } + }) + } +} diff --git a/report/markdown.go b/report/markdown.go new file mode 100644 index 0000000..5e3b623 --- /dev/null +++ b/report/markdown.go @@ -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 . +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 %s`, prefix, content) + } else { + lines[i] = fmt.Sprintf(`%s %s`, prefix, content) + } + } + } + + return strings.Join(lines, "\n") +} diff --git a/report/markdown_test.go b/report/markdown_test.go new file mode 100644 index 0000000..a6de701 --- /dev/null +++ b/report/markdown_test.go @@ -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 + - Add multiple languages to Pages + - Add multiple languages to Events` + + output := markdownCheckboxesToHTML(input) + + if output != expected { + t.Errorf("unexpected output:\nGot:\n%s\n\nExpected:\n%s", output, expected) + } +} diff --git a/report/resource.go b/report/resource.go new file mode 100644 index 0000000..a72d051 --- /dev/null +++ b/report/resource.go @@ -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 +} diff --git a/report/style.css b/report/style.css new file mode 100644 index 0000000..faf3e66 --- /dev/null +++ b/report/style.css @@ -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; +} diff --git a/report/template.go b/report/template.go new file mode 100644 index 0000000..dc7233f --- /dev/null +++ b/report/template.go @@ -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()) +} diff --git a/report/template.html b/report/template.html new file mode 100644 index 0000000..23dc0ee --- /dev/null +++ b/report/template.html @@ -0,0 +1,85 @@ + + + + + + Rechnung vom {{ .Date }} + + + + +
+
+

.CompanyName

+

.CompanyAddress

+

.CompanyContact

+

+
+

Rechnung: .InvoiceNumber

+

Datum: .Date

+

Fällig am: .DueDate

+
+
+
+

Rechnung an:

+

.ClientName

+

.ClientAddress

+

.ClientContact

+
+ + + + + + + + + + + + + {{ range .Issues }} + + + + + + + + + + {{ end }} + +
FIDNameZeitaufwandStundensatzPreis CHFFertiggestellt
{{ .Index }}{{ .Title }}{{ .Duration | duration }}16 CHF/h{{ .Duration | price }} CHF{{ .Closed | time }}
+ +
+ + + + + +
Gesamtbetrag:{{ .Total }} CHF
+
+ +
+ + +

Details zu den Features

+ + {{ range .Issues }} +
+

{{ .Index }}: {{ .Title }}

+ {{ .Body | md | oh 3 }} +
+ {{ end }} + +
+ + +