feat: initialer Commit des csv2excel CLI-Tools

Go-CLI-Tool zum Konvertieren von CSV-Dateien in Excel (.xlsx),
mit Mage-Build-System und Architektur-Dokumentation.
This commit is contained in:
Oliver Jakoubek 2026-03-05 10:14:52 +01:00
commit 26b874674f
10 changed files with 328 additions and 0 deletions

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
dist/
.beads/export-state/
.bv/

25
CLAUDE.md Normal file
View file

@ -0,0 +1,25 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
`csv2excel` is a Go CLI tool that converts one or more CSV files into a single Excel (.xlsx) file, placing each CSV as a separate worksheet.
## Build
This project uses [Mage](https://magefile.org/) (`magefiles/` — eigenes Go-Modul).
```bash
mage Build # aktuelles OS (auto)
mage BuildWindows # dist/csv2excel.exe
mage BuildLinux # dist/csv2excel
mage Install # nach $GOBIN
mage Clean # dist/ entfernen
```
Ohne Mage: `go build -o dist/csv2excel.exe .`
## Weiterführende Dokumentation
- **Architektur & Dependencies:** Siehe `docs/architecture.md`

26
docs/architecture.md Normal file
View file

@ -0,0 +1,26 @@
# Architecture
## Overview
Single-file tool — entire application logic lives in `main.go`.
- **`main()`**: Parses flags (`-sep`, `-enc`, `-o`), iterates over input CSV files, creates one Excel sheet per file using the filename (without extension) as the sheet name, writes all rows, saves the output `.xlsx`.
- **`detectDelimiter()`**: Auto-detects the CSV delimiter by counting `;`, `,`, and `\t` occurrences in the first line. Falls back to `;` on error.
Key behaviors:
- Sheet names are truncated to 31 characters (Excel limit)
- Encoding `windows1252` wraps the file reader with a Windows-1252 decoder
- `csvReader.LazyQuotes = true` for tolerance with malformed CSV files
- The first sheet renames "Sheet1"; subsequent sheets are created fresh
## Version Injection
Version info is injected at build time via ldflags from git tags:
- `internal/version.Version` — from `git describe --tags --always`
- `internal/version.Commit` — from `git rev-parse --short HEAD`
- `internal/version.BuildDate` — from `date -u`
## Dependencies
- `github.com/xuri/excelize/v2` — Excel file creation/manipulation
- `golang.org/x/text` — Character encoding (Windows-1252 support)

15
go.mod Normal file
View file

@ -0,0 +1,15 @@
module code.beautifulmachines.dev/jakoubek/csv2excel
go 1.25.0
require (
github.com/richardlehane/mscfb v1.0.6 // indirect
github.com/richardlehane/msoleps v1.0.6 // indirect
github.com/tiendc/go-deepcopy v1.7.2 // indirect
github.com/xuri/efp v0.0.1 // indirect
github.com/xuri/excelize/v2 v2.10.1 // indirect
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.50.0 // indirect
golang.org/x/text v0.34.0 // indirect
)

18
go.sum Normal file
View file

@ -0,0 +1,18 @@
github.com/richardlehane/mscfb v1.0.6 h1:eN3bvvZCp00bs7Zf52bxNwAx5lJDBK1tCuH19qq5aC8=
github.com/richardlehane/mscfb v1.0.6/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo=
github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg=
github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44=
github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=
github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
github.com/xuri/excelize/v2 v2.10.1 h1:V62UlqopMqha3kOpnlHy2CcRVw1V8E63jFoWUmMzxN0=
github.com/xuri/excelize/v2 v2.10.1/go.mod h1:iG5tARpgaEeIhTqt3/fgXCGoBRt4hNXgCp3tfXKoOIc=
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=

View file

@ -0,0 +1,7 @@
package version
var (
Version = "dev"
Commit = "none"
BuildDate = "unknown"
)

5
magefiles/go.mod Normal file
View file

@ -0,0 +1,5 @@
module csv2excel/magefile
go 1.25.0
require github.com/magefile/mage v1.15.0 // indirect

2
magefiles/go.sum Normal file
View file

@ -0,0 +1,2 @@
github.com/magefile/mage v1.15.0 h1:BvGheCMAsG3bWUDbZ8AyXXpCNwU9u5CB6sM+HNb9HYg=
github.com/magefile/mage v1.15.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=

100
magefiles/magefile.go Normal file
View file

@ -0,0 +1,100 @@
//go:build mage
package main
import (
"fmt"
"os"
"path/filepath"
"runtime"
"github.com/magefile/mage/mg"
"github.com/magefile/mage/sh"
)
const binaryName = "csv2excel"
func ldflags() (string, error) {
version, err := sh.Output("git", "describe", "--tags", "--always")
if err != nil {
version = "dev"
}
commit, err := sh.Output("git", "rev-parse", "--short", "HEAD")
if err != nil {
commit = "none"
}
buildDate, err := sh.Output("date", "-u", "+%Y-%m-%dT%H:%M:%SZ")
if err != nil {
buildDate = "unknown"
}
return fmt.Sprintf(
`-X csv2excel/internal/version.Version=%s -X csv2excel/internal/version.Commit=%s -X csv2excel/internal/version.BuildDate=%s`,
version, commit, buildDate,
), nil
}
// Build builds the binary for the current platform.
func Build() error {
switch runtime.GOOS {
case "windows":
return BuildWindows()
default:
return BuildLinux()
}
}
// BuildLinux builds a static binary for Linux amd64.
func BuildLinux() error {
mg.Deps(ensureDistDir)
flags, err := ldflags()
if err != nil {
return err
}
out := filepath.Join("dist", binaryName)
env := map[string]string{
"GOOS": "linux",
"GOARCH": "amd64",
"CGO_ENABLED": "0",
}
return sh.RunWithV(env, "go", "build", "-ldflags", flags, "-o", out, ".")
}
// BuildWindows builds a binary for Windows amd64.
func BuildWindows() error {
mg.Deps(ensureDistDir)
flags, err := ldflags()
if err != nil {
return err
}
out := filepath.Join("dist", binaryName+".exe")
env := map[string]string{
"GOOS": "windows",
"GOARCH": "amd64",
"CGO_ENABLED": "0",
}
return sh.RunWithV(env, "go", "build", "-ldflags", flags, "-o", out, ".")
}
// Install installs the binary to $GOBIN or $GOPATH/bin.
func Install() error {
fmt.Println("Installing", binaryName, "...")
flags, err := ldflags()
if err != nil {
return err
}
err = sh.RunV("go", "install", "-ldflags", flags, ".")
if err != nil {
return err
}
fmt.Println("Done.")
return nil
}
// Clean removes the dist/ directory.
func Clean() error {
return sh.Rm("dist")
}
func ensureDistDir() error {
return os.MkdirAll("dist", 0o755)
}

127
main.go Normal file
View file

@ -0,0 +1,127 @@
package main
import (
"encoding/csv"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/xuri/excelize/v2"
"golang.org/x/text/encoding/charmap"
"golang.org/x/text/transform"
)
func main() {
sep := flag.String("sep", "auto", "Trennzeichen: auto, ',', ';', '\\t'")
enc := flag.String("enc", "utf8", "Encoding: utf8, windows1252")
out := flag.String("o", "output.xlsx", "Ausgabedatei")
flag.Parse()
files := flag.Args()
if len(files) == 0 {
fmt.Fprintln(os.Stderr, "Verwendung: csv2xlsx [flags] datei1.csv datei2.csv ...")
os.Exit(1)
}
fmt.Println(sep)
fmt.Println(enc)
fmt.Println(out)
xlsx := excelize.NewFile()
firstSheet := true
for _, path := range files {
sheetName := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
// Excelize: max 31 Zeichen, keine Sonderzeichen
if len(sheetName) > 31 {
sheetName = sheetName[:31]
}
f, err := os.Open(path)
if err != nil {
fmt.Fprintf(os.Stderr, "Fehler beim Öffnen %s: %v\n", path, err)
continue
}
defer f.Close()
var reader io.Reader = f
if *enc == "windows1252" {
reader = transform.NewReader(f, charmap.Windows1252.NewDecoder())
}
delimiter := detectDelimiter(path, *sep)
csvReader := csv.NewReader(reader)
csvReader.Comma = delimiter
csvReader.LazyQuotes = true // tolerant bei kaputten CSV-Dateien
if firstSheet {
xlsx.SetSheetName("Sheet1", sheetName)
firstSheet = false
} else {
xlsx.NewSheet(sheetName)
}
row := 1
for {
record, err := csvReader.Read()
if err == io.EOF {
break
}
if err != nil {
fmt.Fprintf(os.Stderr, "Warnung Zeile %d in %s: %v\n", row, path, err)
continue
}
for col, val := range record {
cell, _ := excelize.CoordinatesToCellName(col+1, row)
xlsx.SetCellValue(sheetName, cell, val)
}
row++
}
fmt.Printf("✓ %s → Reiter \"%s\" (%d Zeilen)\n", path, sheetName, row-1)
}
if err := xlsx.SaveAs(*out); err != nil {
fmt.Fprintf(os.Stderr, "Fehler beim Speichern: %v\n", err)
os.Exit(1)
}
fmt.Printf("✅ Gespeichert: %s\n", *out)
}
func detectDelimiter(path, hint string) rune {
switch hint {
case ";":
return ';'
case "\t":
return '\t'
case ",":
return ','
}
// auto-detect: erste Zeile lesen und zählen
f, err := os.Open(path)
if err != nil {
return ';'
}
defer f.Close()
buf := make([]byte, 4096)
n, _ := f.Read(buf)
line := string(buf[:n])
if idx := strings.Index(line, "\n"); idx > 0 {
line = line[:idx]
}
sc := strings.Count(line, ";")
cc := strings.Count(line, ",")
tc := strings.Count(line, "\t")
if tc >= sc && tc >= cc {
return '\t'
}
if sc >= cc {
return ';'
}
return ','
}