Initial dataxl CLI
This commit is contained in:
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
/dataxl
|
||||
*.tmp
|
||||
*.log
|
||||
*.xlsx
|
||||
*.tsv
|
||||
*.csv
|
||||
*.json
|
||||
*.yaml
|
||||
*.yml
|
||||
*.toml
|
||||
!examples/*
|
||||
130
README.md
Normal file
130
README.md
Normal file
@@ -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)
|
||||
597
cmd/dataxl/main.go
Normal file
597
cmd/dataxl/main.go
Normal file
@@ -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
|
||||
}
|
||||
93
cmd/dataxl/main_test.go
Normal file
93
cmd/dataxl/main_test.go
Normal file
@@ -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 }
|
||||
93
docs/architecture.md
Normal file
93
docs/architecture.md
Normal file
@@ -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以上を前提にしています。
|
||||
74
docs/development.md
Normal file
74
docs/development.md
Normal file
@@ -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.
|
||||
16
examples/people.yaml
Normal file
16
examples/people.yaml
Normal file
@@ -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
|
||||
20
go.mod
Normal file
20
go.mod
Normal file
@@ -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
|
||||
)
|
||||
32
go.sum
Normal file
32
go.sum
Normal file
@@ -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=
|
||||
Reference in New Issue
Block a user