From 26b874674fda6d62c27cc9a7f776cce3d01eeecc Mon Sep 17 00:00:00 2001 From: Oliver Jakoubek Date: Thu, 5 Mar 2026 10:14:52 +0100 Subject: [PATCH] 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. --- .gitignore | 3 + CLAUDE.md | 25 +++++++ docs/architecture.md | 26 ++++++++ go.mod | 15 +++++ go.sum | 18 +++++ internal/version/version.go | 7 ++ magefiles/go.mod | 5 ++ magefiles/go.sum | 2 + magefiles/magefile.go | 100 ++++++++++++++++++++++++++++ main.go | 127 ++++++++++++++++++++++++++++++++++++ 10 files changed, 328 insertions(+) create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 docs/architecture.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/version/version.go create mode 100644 magefiles/go.mod create mode 100644 magefiles/go.sum create mode 100644 magefiles/magefile.go create mode 100644 main.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9a6bd54 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +dist/ +.beads/export-state/ +.bv/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..1df1d66 --- /dev/null +++ b/CLAUDE.md @@ -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` diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..a8f5e0f --- /dev/null +++ b/docs/architecture.md @@ -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) diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..cfc0518 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..9b29b0f --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/version/version.go b/internal/version/version.go new file mode 100644 index 0000000..84ea614 --- /dev/null +++ b/internal/version/version.go @@ -0,0 +1,7 @@ +package version + +var ( + Version = "dev" + Commit = "none" + BuildDate = "unknown" +) diff --git a/magefiles/go.mod b/magefiles/go.mod new file mode 100644 index 0000000..8091d16 --- /dev/null +++ b/magefiles/go.mod @@ -0,0 +1,5 @@ +module csv2excel/magefile + +go 1.25.0 + +require github.com/magefile/mage v1.15.0 // indirect diff --git a/magefiles/go.sum b/magefiles/go.sum new file mode 100644 index 0000000..4ee1b87 --- /dev/null +++ b/magefiles/go.sum @@ -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= diff --git a/magefiles/magefile.go b/magefiles/magefile.go new file mode 100644 index 0000000..d9357e8 --- /dev/null +++ b/magefiles/magefile.go @@ -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) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..7e98611 --- /dev/null +++ b/main.go @@ -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 ',' +}