Files
flancer/internal/app/export.go
jbergner 222b5d2413
Some checks failed
release-tag / release-image (push) Has been cancelled
init
2026-08-14 17:42:54 +02:00

254 lines
6.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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]) + "…"
}