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