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:
commit
26b874674f
10 changed files with 328 additions and 0 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
dist/
|
||||||
|
.beads/export-state/
|
||||||
|
.bv/
|
||||||
25
CLAUDE.md
Normal file
25
CLAUDE.md
Normal 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
26
docs/architecture.md
Normal 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
15
go.mod
Normal 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
18
go.sum
Normal 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=
|
||||||
7
internal/version/version.go
Normal file
7
internal/version/version.go
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
package version
|
||||||
|
|
||||||
|
var (
|
||||||
|
Version = "dev"
|
||||||
|
Commit = "none"
|
||||||
|
BuildDate = "unknown"
|
||||||
|
)
|
||||||
5
magefiles/go.mod
Normal file
5
magefiles/go.mod
Normal 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
2
magefiles/go.sum
Normal 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
100
magefiles/magefile.go
Normal 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
127
main.go
Normal 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 ','
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue