init
release-tag / release-image (push) Has been cancelled

This commit is contained in:
2026-08-14 17:42:54 +02:00
parent 18a48d2285
commit 222b5d2413
22 changed files with 3013 additions and 1 deletions
+253
View File
@@ -0,0 +1,253 @@
package app
import (
"bytes"
"encoding/csv"
"fmt"
"strconv"
"strings"
"time"
)
func makeCSV(entries []Entry, cfg Settings, compact bool) ([]byte, error) {
loc := location(cfg.Timezone)
var b bytes.Buffer
b.Write([]byte{0xEF, 0xBB, 0xBF}) // Excel-friendly UTF-8 BOM.
w := csv.NewWriter(&b)
w.Comma = ';'
if compact {
_ = w.Write([]string{"Datum", "Kunde", "Start", "Ende", "Dauer"})
} else {
_ = w.Write([]string{"Datum", "Kunde", "Tätigkeit", "Start", "Ende", "Dauer"})
}
for _, e := range entries {
if e.EndMS == nil {
continue
}
start := time.UnixMilli(e.StartMS).In(loc)
end := time.UnixMilli(*e.EndMS).In(loc)
dur := roundedDuration(*e.EndMS-e.StartMS, cfg.RoundingMinutes, cfg.RoundUp)
if compact {
_ = w.Write([]string{start.Format("02.01.2006"), e.Client, formatClock(start, cfg.TimeFormat), formatClock(end, cfg.TimeFormat), formatDuration(dur)})
} else {
_ = w.Write([]string{start.Format("02.01.2006"), e.Client, e.Activity, formatClock(start, cfg.TimeFormat), formatClock(end, cfg.TimeFormat), formatDuration(dur)})
}
}
w.Flush()
return b.Bytes(), w.Error()
}
type pdfPageLine struct {
x, y, size float64
bold bool
text string
}
func makePDF(entries []Entry, cfg Settings, owner User, compact bool) []byte {
loc := location(cfg.Timezone)
name := strings.TrimSpace(cfg.ExportName)
if name == "" {
name = owner.DisplayName
}
const pageW, pageH = 595.0, 842.0 // A4 points.
var pages [][]pdfPageLine
var page []pdfPageLine
y := 795.0
newPage := func() {
if len(page) > 0 {
pages = append(pages, page)
}
page = []pdfPageLine{}
y = 795
page = append(page, pdfPageLine{50, y, 19, true, "Zeiterfassung"})
y -= 24
page = append(page, pdfPageLine{50, y, 10, false, name})
y -= 22
if compact {
page = append(page,
pdfPageLine{50, y, 9, true, "Datum"},
pdfPageLine{112, y, 9, true, "Kunde"},
pdfPageLine{430, y, 9, true, "Zeit"},
pdfPageLine{515, y, 9, true, "Dauer"},
)
} else {
page = append(page,
pdfPageLine{50, y, 9, true, "Datum"},
pdfPageLine{112, y, 9, true, "Kunde"},
pdfPageLine{255, y, 9, true, "Tätigkeit"},
pdfPageLine{430, y, 9, true, "Zeit"},
pdfPageLine{515, y, 9, true, "Dauer"},
)
}
y -= 16
}
newPage()
var total int64
for _, e := range entries {
if e.EndMS == nil {
continue
}
if y < 65 {
newPage()
}
start := time.UnixMilli(e.StartMS).In(loc)
end := time.UnixMilli(*e.EndMS).In(loc)
d := roundedDuration(*e.EndMS-e.StartMS, cfg.RoundingMinutes, cfg.RoundUp)
total += d
page = append(page,
pdfPageLine{50, y, 8.5, false, start.Format("02.01.06")},
pdfPageLine{112, y, 8.5, false, clipRunes(e.Client, func() int {
if compact {
return 50
}
return 25
}())},
)
if !compact {
page = append(page, pdfPageLine{255, y, 8.5, false, clipRunes(e.Activity, 29)})
}
page = append(page,
pdfPageLine{430, y, 8.5, false, formatClock(start, cfg.TimeFormat) + "-" + formatClock(end, cfg.TimeFormat)},
pdfPageLine{515, y, 8.5, false, formatDuration(d)},
)
y -= 15
}
if y < 60 {
newPage()
}
page = append(page, pdfPageLine{430, y - 4, 10, true, "Gesamt"}, pdfPageLine{515, y - 4, 10, true, formatDuration(total)})
pages = append(pages, page)
return buildSimplePDF(pageW, pageH, pages, cfg.ExportDate, loc)
}
// buildSimplePDF writes a small, standards-compliant PDF using only built-in Type 1
// fonts. This avoids pulling a PDF framework into the server binary.
func buildSimplePDF(pageW, pageH float64, pages [][]pdfPageLine, exportDate bool, loc *time.Location) []byte {
objs := make([][]byte, 0)
add := func(s string) int {
objs = append(objs, []byte(s))
return len(objs)
}
catalogID := add("")
pagesID := add("")
fontID := add(`<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>`)
boldID := add(`<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>`)
pageIDs := make([]int, 0, len(pages))
for i, lines := range pages {
var c strings.Builder
for _, ln := range lines {
font := "F1"
if ln.bold {
font = "F2"
}
fmt.Fprintf(&c, "BT /%s %.1f Tf %.1f %.1f Td (%s) Tj ET\n", font, ln.size, ln.x, ln.y, pdfEscape(ln.text))
}
footer := "Seite " + strconv.Itoa(i+1) + "/" + strconv.Itoa(len(pages))
if exportDate {
footer = "Export: " + time.Now().In(loc).Format("02.01.2006") + " - " + footer
}
fmt.Fprintf(&c, "BT /F1 7.5 Tf 50 28 Td (%s) Tj ET\n", pdfEscape(footer))
content := c.String()
contentID := add(fmt.Sprintf("<< /Length %d >>\nstream\n%sendstream", len(content), content))
pageID := add(fmt.Sprintf(
"<< /Type /Page /Parent %d 0 R /MediaBox [0 0 %.0f %.0f] /Resources << /Font << /F1 %d 0 R /F2 %d 0 R >> >> /Contents %d 0 R >>",
pagesID, pageW, pageH, fontID, boldID, contentID,
))
pageIDs = append(pageIDs, pageID)
}
kids := make([]string, len(pageIDs))
for i, id := range pageIDs {
kids[i] = fmt.Sprintf("%d 0 R", id)
}
objs[catalogID-1] = []byte(fmt.Sprintf("<< /Type /Catalog /Pages %d 0 R >>", pagesID))
objs[pagesID-1] = []byte(fmt.Sprintf("<< /Type /Pages /Count %d /Kids [%s] >>", len(pageIDs), strings.Join(kids, " ")))
var out bytes.Buffer
out.WriteString("%PDF-1.4\n%\xE2\xE3\xCF\xD3\n")
offsets := make([]int, len(objs)+1)
for i, obj := range objs {
offsets[i+1] = out.Len()
fmt.Fprintf(&out, "%d 0 obj\n", i+1)
out.Write(obj)
out.WriteString("\nendobj\n")
}
xref := out.Len()
fmt.Fprintf(&out, "xref\n0 %d\n", len(objs)+1)
out.WriteString("0000000000 65535 f \n")
for i := 1; i <= len(objs); i++ {
fmt.Fprintf(&out, "%010d 00000 n \n", offsets[i])
}
fmt.Fprintf(&out, "trailer\n<< /Size %d /Root %d 0 R >>\nstartxref\n%d\n%%%%EOF\n", len(objs)+1, catalogID, xref)
return out.Bytes()
}
func pdfEscape(s string) string {
var b strings.Builder
for _, r := range s {
var c byte
switch r {
case '–', '—':
c = '-'
case '€':
c = 0x80
case '“', '”':
c = '"'
case '’':
c = '\''
default:
if r >= 32 && r <= 255 {
c = byte(r)
} else if r == '\n' || r == '\r' {
c = ' '
} else {
c = '?'
}
}
if c == '(' || c == ')' || c == '\\' {
b.WriteByte('\\')
}
b.WriteByte(c)
}
return b.String()
}
func formatClock(t time.Time, f string) string {
if f == "12" {
return t.Format("03:04 PM")
}
return t.Format("15:04")
}
func formatDuration(ms int64) string {
if ms < 0 {
ms = 0
}
mins := (ms + 30_000) / 60_000
return fmt.Sprintf("%d:%02d", mins/60, mins%60)
}
func location(name string) *time.Location {
if name != "" {
if x, err := time.LoadLocation(name); err == nil {
return x
}
}
return time.UTC
}
func clipRunes(s string, max int) string {
r := []rune(strings.TrimSpace(s))
if len(r) <= max {
return string(r)
}
if max < 2 {
return string(r[:max])
}
return string(r[:max-1]) + "…"
}