From fab059bc0a567246b82ac8fa715299f354d45b14 Mon Sep 17 00:00:00 2001 From: Luno Date: Fri, 26 Jun 2026 21:12:47 +0900 Subject: [PATCH] Initial dataxl CLI --- .gitignore | 11 + README.md | 130 +++++++++ cmd/dataxl/main.go | 597 ++++++++++++++++++++++++++++++++++++++++ cmd/dataxl/main_test.go | 93 +++++++ docs/architecture.md | 93 +++++++ docs/development.md | 74 +++++ examples/people.yaml | 16 ++ go.mod | 20 ++ go.sum | 32 +++ 9 files changed, 1066 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 cmd/dataxl/main.go create mode 100644 cmd/dataxl/main_test.go create mode 100644 docs/architecture.md create mode 100644 docs/development.md create mode 100644 examples/people.yaml create mode 100644 go.mod create mode 100644 go.sum diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..53211a1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +/dataxl +*.tmp +*.log +*.xlsx +*.tsv +*.csv +*.json +*.yaml +*.yml +*.toml +!examples/* diff --git a/README.md b/README.md new file mode 100644 index 0000000..9c51d38 --- /dev/null +++ b/README.md @@ -0,0 +1,130 @@ +# dataxl + +`dataxl` は、構造化データファイルとExcelの間で情報を行き来させるための +Go製CLIです。 + +主な用途は、YAML/TOML/JSONなどの構造化ファイルをExcelに貼り付けやすい +TSVへ変換したり、Excelからコピーした表を再び構造化データへ戻したりする +ことです。 + +## 対応形式 + +- JSON +- YAML / YML +- TOML +- CSV +- TSV +- XLSX + +## インストール + +Go 1.24以上が必要です。 + +```sh +go install git.rumginger.org/agent/dataxl/cmd/dataxl@latest +``` + +リポジトリをcloneして使う場合: + +```sh +git clone https://git.rumginger.org/agent/dataxl.git +cd dataxl +go build ./cmd/dataxl +``` + +## 基本的な使い方 + +YAMLをExcel貼り付け向けのTSVへ変換: + +```sh +dataxl -from yaml -to tsv -i examples/people.yaml -o people.tsv +``` + +ExcelからコピーしたTSVをJSONへ戻す: + +```sh +dataxl -from tsv -to json < people.tsv > people.json +``` + +JSONからExcel workbookを作成: + +```sh +dataxl -from json -to xlsx -i people.json -o people.xlsx +``` + +Excel workbookをYAMLへ変換: + +```sh +dataxl -from xlsx -to yaml -i people.xlsx -sheet Sheet1 +``` + +入力または出力ファイル名から形式を推定できる場合、`-from` または `-to` は +省略できます。stdin/stdoutを使う場合は明示してください。 + +```sh +dataxl -i examples/people.yaml -o people.tsv +dataxl -from tsv -to yaml < people.tsv +``` + +## Excelとの連携 + +Excelへ貼り付ける場合はTSVが便利です。 + +```sh +dataxl -from yaml -to tsv -i examples/people.yaml +``` + +出力をそのままコピーしてExcelのシートに貼り付けると、タブ区切りの列として +展開されます。 + +Excelから戻す場合は、シート上の範囲をコピーしてstdinへ渡します。 + +```sh +dataxl -from tsv -to yaml > restored.yaml +``` + +## 入れ子構造の表現 + +構造化データの入れ子は、Excelで編集しやすい列名へ展開されます。 + +入力例: + +```yaml +- id: 1 + user: + name: Alice + items: + - sku: A-001 + qty: 2 +``` + +TSV出力例: + +```tsv +id items[0].qty items[0].sku user.name +1 2 A-001 Alice +``` + +逆方向の変換では、`user.name` や `items[0].sku` のような列名から入れ子の +map/arrayを復元します。 + +## CLIオプション + +- `-i`: 入力ファイル。省略時はstdin。 +- `-o`: 出力ファイル。省略時はstdout。 +- `-from`: 入力形式。`json`, `yaml`, `toml`, `csv`, `tsv`, `xlsx`。 +- `-to`: 出力形式。`json`, `yaml`, `toml`, `csv`, `tsv`, `xlsx`。 +- `-sheet`: XLSXの読み書きに使うシート名。既定値は `Sheet1`。 +- `-pretty`: JSONなどの構造化出力を整形するか。既定値は `true`。 + +## 現在の制約 + +- 表形式では1行目をヘッダーとして扱います。 +- XLSXは指定した1シートのみ読み書きします。 +- セル値の型推定は、空文字、真偽値、整数、小数、文字列の範囲です。 +- 複雑なExcel書式や数式の保持は目的外です。 + +## 開発者向け情報 + +- 設計概要: [docs/architecture.md](docs/architecture.md) +- 開発手順: [docs/development.md](docs/development.md) diff --git a/cmd/dataxl/main.go b/cmd/dataxl/main.go new file mode 100644 index 0000000..957ccbc --- /dev/null +++ b/cmd/dataxl/main.go @@ -0,0 +1,597 @@ +package main + +import ( + "bytes" + "encoding/csv" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + + "github.com/BurntSushi/toml" + "github.com/xuri/excelize/v2" + "gopkg.in/yaml.v3" +) + +type options struct { + inFile string + outFile string + from string + to string + sheet string + pretty bool +} + +type table struct { + Header []string + Rows [][]string +} + +func main() { + if err := run(os.Args[1:], os.Stdin, os.Stdout, os.Stderr); err != nil { + fmt.Fprintln(os.Stderr, "dataxl:", err) + os.Exit(1) + } +} + +func run(args []string, stdin io.Reader, stdout, stderr io.Writer) error { + var opt options + fs := flag.NewFlagSet("dataxl", flag.ContinueOnError) + fs.SetOutput(stderr) + fs.StringVar(&opt.inFile, "i", "", "input file, defaults to stdin") + fs.StringVar(&opt.outFile, "o", "", "output file, defaults to stdout") + fs.StringVar(&opt.from, "from", "", "input format: json, yaml, toml, csv, tsv, xlsx") + fs.StringVar(&opt.to, "to", "", "output format: json, yaml, toml, csv, tsv, xlsx") + fs.StringVar(&opt.sheet, "sheet", "Sheet1", "worksheet name for xlsx input/output") + fs.BoolVar(&opt.pretty, "pretty", true, "pretty-print structured output") + fs.Usage = func() { + fmt.Fprintln(stderr, `Usage: + dataxl -from yaml -to tsv -i input.yaml -o output.tsv + dataxl -from tsv -to json < paste-from-excel.tsv + dataxl -from json -to xlsx -i data.json -o data.xlsx + +Formats: + json, yaml/yml, toml, csv, tsv, xlsx + +Notes: + Structured records are flattened into spreadsheet columns such as user.name + and items[0].sku. Spreadsheet columns with those names are restored when + converting back to json/yaml/toml.`) + } + if err := fs.Parse(args); err != nil { + return err + } + if fs.NArg() != 0 { + return fmt.Errorf("unexpected arguments: %s", strings.Join(fs.Args(), " ")) + } + + input, err := readInput(opt.inFile, stdin) + if err != nil { + return err + } + opt.from = normalizeFormat(opt.from) + opt.to = normalizeFormat(opt.to) + if opt.from == "" { + opt.from = inferFormat(opt.inFile) + } + if opt.to == "" { + opt.to = inferFormat(opt.outFile) + } + if opt.from == "" || opt.to == "" { + return errors.New("both -from and -to are required when a format cannot be inferred from file names") + } + if err := validateFormat(opt.from); err != nil { + return err + } + if err := validateFormat(opt.to); err != nil { + return err + } + + var out []byte + if isTabular(opt.from) && isTabular(opt.to) { + t, err := parseTable(input, opt.from, opt.sheet) + if err != nil { + return err + } + out, err = encodeTable(t, opt.to, opt.sheet) + if err != nil { + return err + } + } else if isTabular(opt.from) { + t, err := parseTable(input, opt.from, opt.sheet) + if err != nil { + return err + } + records := tableToRecords(t) + out, err = encodeStructured(records, opt.to, opt.pretty) + if err != nil { + return err + } + } else if isTabular(opt.to) { + value, err := parseStructured(input, opt.from) + if err != nil { + return err + } + t := valueToTable(value) + out, err = encodeTable(t, opt.to, opt.sheet) + if err != nil { + return err + } + } else { + value, err := parseStructured(input, opt.from) + if err != nil { + return err + } + out, err = encodeStructured(value, opt.to, opt.pretty) + if err != nil { + return err + } + } + + return writeOutput(opt.outFile, stdout, out) +} + +func readInput(path string, stdin io.Reader) ([]byte, error) { + if path == "" || path == "-" { + return io.ReadAll(stdin) + } + return os.ReadFile(path) +} + +func writeOutput(path string, stdout io.Writer, data []byte) error { + if path == "" || path == "-" { + _, err := stdout.Write(data) + return err + } + return os.WriteFile(path, data, 0644) +} + +func normalizeFormat(format string) string { + format = strings.ToLower(strings.TrimPrefix(strings.TrimSpace(format), ".")) + switch format { + case "yml": + return "yaml" + case "xlsm", "xls": + return "xlsx" + default: + return format + } +} + +func inferFormat(path string) string { + if path == "" || path == "-" { + return "" + } + return normalizeFormat(strings.TrimPrefix(filepath.Ext(path), ".")) +} + +func validateFormat(format string) error { + switch format { + case "json", "yaml", "toml", "csv", "tsv", "xlsx": + return nil + default: + return fmt.Errorf("unsupported format %q", format) + } +} + +func isTabular(format string) bool { + return format == "csv" || format == "tsv" || format == "xlsx" +} + +func parseStructured(input []byte, format string) (any, error) { + var value any + switch format { + case "json": + dec := json.NewDecoder(bytes.NewReader(input)) + dec.UseNumber() + if err := dec.Decode(&value); err != nil { + return nil, err + } + case "yaml": + if err := yaml.Unmarshal(input, &value); err != nil { + return nil, err + } + value = normalizeYAML(value) + case "toml": + var m map[string]any + if err := toml.Unmarshal(input, &m); err != nil { + return nil, err + } + value = m + default: + return nil, fmt.Errorf("format %q is not structured", format) + } + return value, nil +} + +func encodeStructured(value any, format string, pretty bool) ([]byte, error) { + switch format { + case "json": + if pretty { + return json.MarshalIndent(value, "", " ") + } + return json.Marshal(value) + case "yaml": + return yaml.Marshal(value) + case "toml": + m, ok := value.(map[string]any) + if !ok { + m = map[string]any{"rows": value} + } + var b bytes.Buffer + err := toml.NewEncoder(&b).Encode(m) + return b.Bytes(), err + default: + return nil, fmt.Errorf("format %q is not structured", format) + } +} + +func normalizeYAML(value any) any { + switch v := value.(type) { + case map[string]any: + out := make(map[string]any, len(v)) + for key, child := range v { + out[key] = normalizeYAML(child) + } + return out + case map[any]any: + out := make(map[string]any, len(v)) + for key, child := range v { + out[fmt.Sprint(key)] = normalizeYAML(child) + } + return out + case []any: + for i := range v { + v[i] = normalizeYAML(v[i]) + } + } + return value +} + +func parseTable(input []byte, format, sheet string) (table, error) { + switch format { + case "csv": + return readDelimited(input, ',') + case "tsv": + return readDelimited(input, '\t') + case "xlsx": + f, err := excelize.OpenReader(bytes.NewReader(input)) + if err != nil { + return table{}, err + } + defer f.Close() + rows, err := f.GetRows(sheet) + if err != nil { + return table{}, err + } + return rowsToTable(rows), nil + default: + return table{}, fmt.Errorf("format %q is not tabular", format) + } +} + +func readDelimited(input []byte, comma rune) (table, error) { + r := csv.NewReader(bytes.NewReader(input)) + r.Comma = comma + r.FieldsPerRecord = -1 + r.TrimLeadingSpace = comma == ',' + rows, err := r.ReadAll() + if err != nil { + return table{}, err + } + return rowsToTable(rows), nil +} + +func rowsToTable(rows [][]string) table { + if len(rows) == 0 { + return table{} + } + width := 0 + for _, row := range rows { + if len(row) > width { + width = len(row) + } + } + header := padRow(rows[0], width) + body := make([][]string, 0, len(rows)-1) + for _, row := range rows[1:] { + body = append(body, padRow(row, width)) + } + return table{Header: header, Rows: body} +} + +func padRow(row []string, width int) []string { + out := make([]string, width) + copy(out, row) + return out +} + +func encodeTable(t table, format, sheet string) ([]byte, error) { + switch format { + case "csv": + return writeDelimited(t, ',') + case "tsv": + return writeDelimited(t, '\t') + case "xlsx": + return writeXLSX(t, sheet) + default: + return nil, fmt.Errorf("format %q is not tabular", format) + } +} + +func writeDelimited(t table, comma rune) ([]byte, error) { + var b bytes.Buffer + w := csv.NewWriter(&b) + w.Comma = comma + if err := w.Write(t.Header); err != nil { + return nil, err + } + for _, row := range t.Rows { + if err := w.Write(row); err != nil { + return nil, err + } + } + w.Flush() + return b.Bytes(), w.Error() +} + +func writeXLSX(t table, sheet string) ([]byte, error) { + f := excelize.NewFile() + defaultSheet := f.GetSheetName(0) + if sheet == "" { + sheet = "Sheet1" + } + if defaultSheet != sheet { + if err := f.SetSheetName(defaultSheet, sheet); err != nil { + return nil, err + } + } + rows := append([][]string{t.Header}, t.Rows...) + for r, row := range rows { + for c, value := range row { + cell, err := excelize.CoordinatesToCellName(c+1, r+1) + if err != nil { + return nil, err + } + if err := f.SetCellValue(sheet, cell, value); err != nil { + return nil, err + } + } + } + if len(t.Header) > 0 { + end, _ := excelize.CoordinatesToCellName(len(t.Header), 1) + style, _ := f.NewStyle(&excelize.Style{Font: &excelize.Font{Bold: true}}) + _ = f.SetCellStyle(sheet, "A1", end, style) + _ = f.SetPanes(sheet, &excelize.Panes{ + Freeze: true, + Split: false, + XSplit: 0, + YSplit: 1, + TopLeftCell: "A2", + ActivePane: "bottomLeft", + }) + } + var b bytes.Buffer + if err := f.Write(&b); err != nil { + return nil, err + } + return b.Bytes(), nil +} + +func valueToTable(value any) table { + records := recordsFromValue(value) + flatRows := make([]map[string]string, 0, len(records)) + seen := map[string]bool{} + var header []string + for _, record := range records { + flat := map[string]string{} + flatten("", record, flat) + for key := range flat { + if !seen[key] { + seen[key] = true + header = append(header, key) + } + } + flatRows = append(flatRows, flat) + } + sort.Strings(header) + rows := make([][]string, 0, len(flatRows)) + for _, flat := range flatRows { + row := make([]string, len(header)) + for i, key := range header { + row[i] = flat[key] + } + rows = append(rows, row) + } + return table{Header: header, Rows: rows} +} + +func recordsFromValue(value any) []any { + switch v := value.(type) { + case []any: + return v + case map[string]any: + for _, key := range []string{"rows", "records", "items"} { + if rows, ok := v[key].([]any); ok { + return rows + } + } + return []any{v} + default: + return []any{v} + } +} + +func flatten(prefix string, value any, out map[string]string) { + switch v := value.(type) { + case map[string]any: + if len(v) == 0 && prefix != "" { + out[prefix] = "{}" + return + } + keys := make([]string, 0, len(v)) + for key := range v { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + childPrefix := key + if prefix != "" { + childPrefix = prefix + "." + key + } + flatten(childPrefix, v[key], out) + } + case []any: + if len(v) == 0 && prefix != "" { + out[prefix] = "[]" + return + } + for i, child := range v { + flatten(fmt.Sprintf("%s[%d]", prefix, i), child, out) + } + default: + if prefix == "" { + prefix = "value" + } + out[prefix] = scalarString(v) + } +} + +func scalarString(value any) string { + switch v := value.(type) { + case nil: + return "" + case json.Number: + return v.String() + case string: + return v + case bool: + return strconv.FormatBool(v) + case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64: + return fmt.Sprint(v) + default: + data, err := json.Marshal(v) + if err != nil { + return fmt.Sprint(v) + } + return string(data) + } +} + +func tableToRecords(t table) []map[string]any { + var records []map[string]any + for _, row := range t.Rows { + record := map[string]any{} + for i, header := range t.Header { + header = strings.TrimSpace(header) + if header == "" || i >= len(row) { + continue + } + setPath(record, header, parseCell(row[i])) + } + records = append(records, record) + } + return records +} + +func parseCell(s string) any { + s = strings.TrimSpace(s) + if s == "" { + return "" + } + if s == "true" { + return true + } + if s == "false" { + return false + } + if i, err := strconv.ParseInt(s, 10, 64); err == nil { + return i + } + if f, err := strconv.ParseFloat(s, 64); err == nil && strings.ContainsAny(s, ".eE") { + return f + } + return s +} + +var pathTokenRE = regexp.MustCompile(`([^\.\[\]]+)|\[(\d+)\]`) + +func setPath(root map[string]any, path string, value any) { + tokens := parsePath(path) + if len(tokens) == 0 { + return + } + var cur any = root + for i, token := range tokens { + last := i == len(tokens)-1 + nextIsIndex := !last && tokens[i+1].isIndex + if token.isIndex { + continue + } + m, ok := cur.(map[string]any) + if !ok { + return + } + if last { + m[token.key] = value + return + } + if _, ok := m[token.key]; !ok { + if nextIsIndex { + m[token.key] = []any{} + } else { + m[token.key] = map[string]any{} + } + } + if nextIsIndex { + slice, _ := m[token.key].([]any) + index := tokens[i+1].index + for len(slice) <= index { + if i+2 < len(tokens) && !tokens[i+2].isIndex { + slice = append(slice, map[string]any{}) + } else { + slice = append(slice, nil) + } + } + m[token.key] = slice + if i+2 == len(tokens) { + slice[index] = value + return + } + if slice[index] == nil { + slice[index] = map[string]any{} + } + cur = slice[index] + i++ + continue + } + cur = m[token.key] + } +} + +type pathToken struct { + key string + index int + isIndex bool +} + +func parsePath(path string) []pathToken { + matches := pathTokenRE.FindAllStringSubmatch(path, -1) + tokens := make([]pathToken, 0, len(matches)) + for _, m := range matches { + if m[1] != "" { + tokens = append(tokens, pathToken{key: m[1]}) + continue + } + index, _ := strconv.Atoi(m[2]) + tokens = append(tokens, pathToken{index: index, isIndex: true}) + } + return tokens +} diff --git a/cmd/dataxl/main_test.go b/cmd/dataxl/main_test.go new file mode 100644 index 0000000..5787241 --- /dev/null +++ b/cmd/dataxl/main_test.go @@ -0,0 +1,93 @@ +package main + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestYAMLToTSVFlattensForExcel(t *testing.T) { + input := strings.NewReader(` +- id: 1 + user: + name: Alice + items: + - sku: A-001 + qty: 2 +- id: 2 + user: + name: Bob + items: + - sku: B-002 + qty: 5 +`) + var out bytes.Buffer + var errOut bytes.Buffer + err := run([]string{"-from", "yaml", "-to", "tsv"}, input, &out, &errOut) + if err != nil { + t.Fatalf("run failed: %v\nstderr: %s", err, errOut.String()) + } + + got := out.String() + for _, want := range []string{"id", "items[0].qty", "items[0].sku", "user.name", "Alice", "B-002"} { + if !strings.Contains(got, want) { + t.Fatalf("output missing %q:\n%s", want, got) + } + } +} + +func TestTSVToJSONRestoresPaths(t *testing.T) { + input := strings.NewReader("id\titems[0].qty\titems[0].sku\tuser.name\n1\t2\tA-001\tAlice\n") + var out bytes.Buffer + var errOut bytes.Buffer + err := run([]string{"-from", "tsv", "-to", "json"}, input, &out, &errOut) + if err != nil { + t.Fatalf("run failed: %v\nstderr: %s", err, errOut.String()) + } + + var records []map[string]any + if err := json.Unmarshal(out.Bytes(), &records); err != nil { + t.Fatalf("invalid json: %v\n%s", err, out.String()) + } + if len(records) != 1 { + t.Fatalf("record count = %d, want 1", len(records)) + } + user := records[0]["user"].(map[string]any) + if user["name"] != "Alice" { + t.Fatalf("user.name = %v, want Alice", user["name"]) + } + items := records[0]["items"].([]any) + first := items[0].(map[string]any) + if first["sku"] != "A-001" || first["qty"].(float64) != 2 { + t.Fatalf("unexpected item: %#v", first) + } +} + +func TestJSONToXLSXAndBack(t *testing.T) { + dir := t.TempDir() + xlsxPath := filepath.Join(dir, "data.xlsx") + jsonInput := strings.NewReader(`[{"id":1,"name":"Alice"},{"id":2,"name":"Bob"}]`) + var errOut bytes.Buffer + if err := run([]string{"-from", "json", "-to", "xlsx", "-o", xlsxPath}, jsonInput, ioDiscard{}, &errOut); err != nil { + t.Fatalf("json to xlsx failed: %v\nstderr: %s", err, errOut.String()) + } + if stat, err := os.Stat(xlsxPath); err != nil || stat.Size() == 0 { + t.Fatalf("xlsx was not created: stat=%v err=%v", stat, err) + } + + var out bytes.Buffer + errOut.Reset() + if err := run([]string{"-from", "xlsx", "-to", "json", "-i", xlsxPath}, nil, &out, &errOut); err != nil { + t.Fatalf("xlsx to json failed: %v\nstderr: %s", err, errOut.String()) + } + if !strings.Contains(out.String(), `"name": "Bob"`) { + t.Fatalf("roundtrip output missing Bob:\n%s", out.String()) + } +} + +type ioDiscard struct{} + +func (ioDiscard) Write(p []byte) (int, error) { return len(p), nil } diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..e164532 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,93 @@ +# Architecture + +`dataxl` は、ファイル形式ごとの差を小さくするために、内部表現を2つに分けています。 + +- structured value: JSON/YAML/TOMLから読める `map[string]any`, `[]any`, scalar +- table: Excel/CSV/TSVに近い `Header []string` と `Rows [][]string` + +変換は原則として次のどれかです。 + +- structured -> structured +- structured -> table +- table -> structured +- table -> table + +## Format Adapters + +各形式の処理は `cmd/dataxl/main.go` の以下の関数に集約しています。 + +- `parseStructured` +- `encodeStructured` +- `parseTable` +- `encodeTable` + +現在はCLIが小さいため単一ファイルに置いています。形式やオプションが増えたら、 +`internal/format` や `internal/table` へ分割する余地があります。 + +## Table Model + +表形式は必ず1行目をヘッダーとして扱います。 + +```go +type table struct { + Header []string + Rows [][]string +} +``` + +CSV/TSV/XLSXの読み込みでは、短い行を空文字で埋めて列数を揃えます。 +XLSXの書き出しではヘッダーを太字にし、1行目を固定します。 + +## Flattening + +structured -> table では、入れ子のmap/arrayを列パスへ展開します。 + +- map: `user.name` +- array: `items[0].sku` +- top-level scalar: `value` + +列順は安定性を優先してソートしています。Excel上で列の位置が変わっても、 +ヘッダー名を見て復元するため、列順には依存しません。 + +## Unflattening + +table -> structured では、ヘッダーのパス表現からmap/arrayを復元します。 + +例: + +```text +items[0].sku +``` + +は次の構造になります。 + +```json +{ + "items": [ + { + "sku": "..." + } + ] +} +``` + +セル値は `parseCell` で軽く型推定します。 + +- 空文字: `""` +- `true` / `false`: boolean +- 整数: int64 +- 小数または指数表記: float64 +- その他: string + +## TOML Output + +TOMLはトップレベル配列を直接表せないため、表からTOMLへ出力する場合など、 +トップレベルがmapでない値は `rows` キーに包んで出力します。 + +## Dependencies + +- `github.com/xuri/excelize/v2`: XLSX読み書き +- `gopkg.in/yaml.v3`: YAML読み書き +- `github.com/BurntSushi/toml`: TOML読み書き + +Go 1.24以上を前提にしています。 diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..9525976 --- /dev/null +++ b/docs/development.md @@ -0,0 +1,74 @@ +# Development + +## Requirements + +- Go 1.24 or later + +## Setup + +```sh +git clone https://git.rumginger.org/agent/dataxl.git +cd dataxl +go mod download +``` + +## Common Commands + +Format code: + +```sh +gofmt -w cmd/dataxl/*.go +``` + +Run tests: + +```sh +go test ./... +``` + +Build: + +```sh +go build ./cmd/dataxl +``` + +Run locally: + +```sh +go run ./cmd/dataxl -from yaml -to tsv -i examples/people.yaml +``` + +## Test Coverage + +The current tests cover: + +- YAML -> TSV flattening +- TSV -> JSON path restoration +- JSON -> XLSX -> JSON round trip + +When adding a new format or path rule, add tests around both directions where +possible. + +## Release Notes + +There is no automated release pipeline yet. A minimal release flow is: + +```sh +go test ./... +git tag v0.1.0 +git push origin main --tags +``` + +After tags exist, users can install a specific version: + +```sh +go install git.rumginger.org/agent/dataxl/cmd/dataxl@v0.1.0 +``` + +## Design Guidelines + +- Keep stdin/stdout usable for shell pipelines. +- Keep TSV behavior predictable because it is the main Excel clipboard format. +- Preserve headers as the contract between spreadsheet data and structured data. +- Prefer explicit errors over silent best-effort conversion when a format is unsupported. +- Keep dependencies small unless a format needs a mature parser/writer. diff --git a/examples/people.yaml b/examples/people.yaml new file mode 100644 index 0000000..3f02a7b --- /dev/null +++ b/examples/people.yaml @@ -0,0 +1,16 @@ +- id: 1 + user: + name: Alice + email: alice@example.com + department: Sales + items: + - sku: A-001 + qty: 2 +- id: 2 + user: + name: Bob + email: bob@example.com + department: Engineering + items: + - sku: B-002 + qty: 5 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..56c6df7 --- /dev/null +++ b/go.mod @@ -0,0 +1,20 @@ +module git.rumginger.org/agent/dataxl + +go 1.24.0 + +require ( + github.com/BurntSushi/toml v1.6.0 + github.com/xuri/excelize/v2 v2.10.1 + gopkg.in/yaml.v3 v3.0.1 +) + +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/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..096acfc --- /dev/null +++ b/go.sum @@ -0,0 +1,32 @@ +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +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/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +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/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ= +golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs= +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= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=