diff --git a/Dockerfile b/Dockerfile index a223a35..caff470 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.25-alpine AS builder +FROM golang:1.26-alpine AS builder WORKDIR /src COPY go.mod go.sum ./ RUN go mod download diff --git a/README.md b/README.md index 8b20218..80d57ec 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Released under the Apache 2.0 [LICENSE](https://github.com/invopop/gobl.dev/blob [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/invopop/gobl.dev) Core GOBL is a pure document library. This project composes it with every -GOBL addon (see [`bundle/`](./bundle)) and ships two binaries: +GOBL addon and converter (see [`bundle/`](./bundle)) and ships two binaries: | Binary | Package | What it is | |------------|------------------|------------| @@ -43,6 +43,7 @@ Commands: | `gobl sign` | Sign an envelope with a JWK private key. | | `gobl verify` | Verify an envelope's signatures. | | `gobl replicate` | Clone a document with a fresh UUID. | +| `gobl convert` | Convert documents between GOBL and external formats such as UBL and CII (see [Converters](#converters)). | | `gobl keygen` | Generate an ES256 key pair. | | `gobl serve` | Launch the HTTP API server (see [API](#http-api)). | | `gobl mcp` | Launch a [Model Context Protocol](https://modelcontextprotocol.io) server over stdio for AI tools and editors. | @@ -60,6 +61,13 @@ gobl build -i ./invoice.yaml --set-file customer=./party.yaml --set series=TEST # Correct an invoice with a credit note gobl correct -i ./invoice.json --credit +# Export an invoice as UBL, and import it back into GOBL +gobl convert ./invoice.json ./invoice.xml --to ubl+en16931-v2017 +gobl convert -i ./invoice.xml + +# List the available conversion formats +gobl convert --list -i + # Serve the HTTP API on :8080 gobl serve ``` @@ -77,6 +85,23 @@ PORT=3000 go run ./cmd/gobl.dev Open [http://localhost:8080](http://localhost:8080) for the editor; the API is served under `/v0` (see below). +The editor has two modes: + +- **Build** calculates and validates the GOBL document, replacing it with the + result. +- **Convert** shows the GOBL document beside a document in another format, + both editable. **Export →**, above the GOBL editor, builds the GOBL document + without changing it and writes it into the selected format; only formats + that accept the document are offered, as checked through `POST /v0/formats` + while it is edited. **← Import**, above the other editor, detects that + document's format and replaces the GOBL document with the imported one. + +Bare-minimum example documents for each region live in +[`editor/examples`](./editor/examples), picked above the GOBL editor, and +offered above the other editor as samples exported into their most specific +format. They are labelled from their regime and addons, so adding one only +requires adding its file. + ### Docker ```bash @@ -102,6 +127,8 @@ the `/v0` prefix: | `POST` | `/v0/verify` | Verify an envelope's signatures | | `POST` | `/v0/correct` | Generate a corrective document (credit/debit note) | | `POST` | `/v0/replicate` | Clone a document with a new UUID | +| `POST` | `/v0/import` | Detect the format of the raw body (e.g. UBL XML) and import it into a GOBL envelope; `?format=` limits detection | +| `POST` | `/v0/export?format={key}` | Export a GOBL document or envelope into the first accepting format; the key used is returned in the `GOBL-Format` header | | `POST` | `/v0/keygen` | Generate an ES256 key pair | | `GET` | `/v0/schemas` | List registered JSON schemas | | `GET` | `/v0/schemas/{path}` | Fetch a specific schema (add `?bundle` for bundled) | @@ -109,6 +136,8 @@ the `/v0` prefix: | `GET` | `/v0/regimes/{code}` | Fetch a specific tax regime definition | | `GET` | `/v0/addons` | List available addons | | `GET` | `/v0/addons/{key}` | Fetch a specific addon definition | +| `GET` | `/v0/formats` | List the conversion formats registered by the bundled converters | +| `POST` | `/v0/formats` | Check which formats accept a GOBL document, with the reasons for refusals | | `POST` | `/v0/mcp` | Model Context Protocol (MCP) endpoint | | `GET` | `/v0/openapi.json` | OpenAPI specification | @@ -129,6 +158,30 @@ workflow attaches the wasm build to each GitHub Release, uploads it to addons the binaries ship with — one blank import per addon module. Add an approved addon there and both `gobl` and `gobl.dev` pick it up. +## Converters + +Conversions between GOBL and external formats go through GOBL's +[`convert`](https://github.com/invopop/gobl/tree/main/convert) register: +converter modules register their formats when imported, and gobl.dev offers +exactly the formats registered, through `gobl convert`, the `/v0/import`, +`/v0/export` and `/v0/formats` endpoints, and the editor. The converters are +blank-imported in [`bundle/bundle.go`](./bundle/bundle.go) alongside the +addons: + +| Module | Formats | +|--------|---------| +| [gobl.ubl](https://github.com/invopop/gobl.ubl) | `ubl+en16931-v2017`, `ubl+peppol+bis-v3` | +| [gobl.cii](https://github.com/invopop/gobl.cii) | `cii+en16931-v2017`, `cii+peppol+bis-v3` | +| [gobl.de.xinvoice](https://github.com/invopop/gobl.de.xinvoice) | `ubl+de-xrechnung-v3`, `cii+de-xrechnung-v3`, `cii+de-zugferd-v2` (`+basic`, `+extended`) | +| [gobl.fr.ctc](https://github.com/invopop/gobl.fr.ctc) | `ubl+fr-ctc-cius-v1`, `ubl+fr-ctc-extended-v1`, `cii+fr-ctc-cius-v1`, `cii+fr-ctc-extended-v1`, `cii+fr-ctc-facturx-v1`, `cii+fr-facturx-v1` (`+basic`, `+extended`), `cii+fr-choruspro-v1`, `cdar+fr-ctc-cdv-v1` (`+ppf`) | +| [gobl.sa.zatca](https://github.com/invopop/gobl.sa.zatca) | `ubl+sa-zatca-v1` | + +Format keys follow GOBL's naming rules (`[+...]`). Each +format's converter decides whether it accepts a document, typically requiring +the addons of its specification, and explains a refusal with rules faults. +`POST /v0/formats` reports, for each format that exports a document's schema, +whether it accepts the document and why not. + ## Project structure ``` @@ -136,7 +189,7 @@ cmd/ gobl/ CLI entry point (binary: gobl) gobl.dev/ web server entry point (binary: gobl.dev) api/ HTTP API handler (package api) -bundle/ addon registration (package bundle) +bundle/ addon and converter registration (package bundle) editor/ browser editor (PopUI + Templ) internal/ ops/ document operations engine (build, validate, sign, …) diff --git a/api/api.go b/api/api.go index abebc45..922904b 100644 --- a/api/api.go +++ b/api/api.go @@ -52,8 +52,8 @@ func WithRoutes(fn func(mux *http.ServeMux, prefix string)) Option { // registered under the major-version prefix (e.g. /v0/build). // // By default the handler includes document operations (build, validate, -// correct, replicate, sign, verify), reference data (schemas, regimes, -// addons), key generation, the OpenAPI spec, and a version/health endpoint. +// correct, replicate, sign, verify, import, export), reference data (schemas, +// regimes, addons, formats), key generation, the OpenAPI spec, and a version/health endpoint. // CORS, version-header, timing, and logging middleware are always applied. // // Use [WithMCP], [WithFavicon], and [WithRoutes] to enable additional @@ -75,6 +75,9 @@ func NewHandler(opts ...Option) http.Handler { mux.HandleFunc("POST "+p+"/replicate", handleReplicate) mux.HandleFunc("POST "+p+"/sign", handleSign) mux.HandleFunc("POST "+p+"/verify", handleVerify) + mux.HandleFunc("POST "+p+"/import", handleImport) + mux.HandleFunc("POST "+p+"/export", handleExport) + mux.HandleFunc("POST "+p+"/formats", handleFormatCheck) // Reference data (static per version — ETag for caching) mux.HandleFunc("GET "+p+"/schemas", WithETag(handleSchemaList)) @@ -83,6 +86,7 @@ func NewHandler(opts ...Option) http.Handler { mux.HandleFunc("GET "+p+"/regimes/{code}", WithETag(handleRegime)) mux.HandleFunc("GET "+p+"/addons", WithETag(handleAddonList)) mux.HandleFunc("GET "+p+"/addons/{key...}", WithETag(handleAddon)) + mux.HandleFunc("GET "+p+"/formats", WithETag(handleFormatList)) // MCP (Streamable HTTP — stateless) if cfg.withMCP { @@ -203,6 +207,7 @@ func withCORS(next http.Handler) http.Handler { w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + w.Header().Set("Access-Control-Expose-Headers", "GOBL-Version, "+FormatHeader) if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) diff --git a/api/convert.go b/api/convert.go new file mode 100644 index 0000000..81bb061 --- /dev/null +++ b/api/convert.go @@ -0,0 +1,79 @@ +package api + +import ( + "net/http" + "strings" + + "github.com/invopop/gobl.dev/internal/ops" + "github.com/invopop/gobl/cbc" +) + +// FormatHeader is the response header that names the key of the format an +// export was converted into, or an import was detected as. +const FormatHeader = "GOBL-Format" + +func handleFormatList(w http.ResponseWriter, _ *http.Request) { + WriteJSON(w, map[string]any{"formats": ops.Formats()}) +} + +// handleFormatCheck reports, for each format that exports the schema of the +// GOBL document in the body, whether it accepts the document. +func handleFormatCheck(w http.ResponseWriter, r *http.Request) { + checks, err := ops.CheckFormats(r.Context(), &ops.ParseOptions{ + Input: r.Body, + }) + if err != nil { + WriteError(w, err) + return + } + WriteJSON(w, map[string]any{"formats": checks}) +} + +func handleImport(w http.ResponseWriter, r *http.Request) { + env, f, err := ops.Import(r.Context(), &ops.ImportOptions{ + Input: r.Body, + Formats: queryFormats(r), + }) + if err != nil { + WriteError(w, err) + return + } + w.Header().Set(FormatHeader, f.Key.String()) + WriteJSON(w, env) +} + +func handleExport(w http.ResponseWriter, r *http.Request) { + out, err := ops.Export(r.Context(), &ops.ExportOptions{ + ParseOptions: &ops.ParseOptions{ + Input: r.Body, + }, + Formats: queryFormats(r), + }) + if err != nil { + WriteError(w, err) + return + } + mime := out.Format.MIME + if mime == "" { + mime = "application/octet-stream" + } + w.Header().Set("Content-Type", mime) + w.Header().Set(FormatHeader, out.Format.Key.String()) + _, _ = w.Write(out.Data) //nolint:errcheck +} + +// queryFormats reads the format keys from the "format" query parameter, which +// may be repeated or hold a comma-separated list, preserving their order. +// Keys contain "+", which query strings decode as a space, so unescaped keys +// such as "ubl+en16931-v2017" are restored. +func queryFormats(r *http.Request) []cbc.Key { + var keys []cbc.Key + for _, v := range r.URL.Query()["format"] { + for _, k := range strings.Split(v, ",") { + if k = strings.ReplaceAll(strings.TrimSpace(k), " ", "+"); k != "" { + keys = append(keys, cbc.Key(k)) + } + } + } + return keys +} diff --git a/api/convert_test.go b/api/convert_test.go new file mode 100644 index 0000000..5ba8083 --- /dev/null +++ b/api/convert_test.go @@ -0,0 +1,149 @@ +package api_test + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/invopop/gobl" + "github.com/invopop/gobl.dev/api" + "github.com/invopop/gobl/bill" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFormatListEndpoint(t *testing.T) { + srv := httptest.NewServer(api.NewHandler()) + defer srv.Close() + + resp, err := http.Get(srv.URL + prefix + "/formats") + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var body struct { + Formats []struct { + Key string `json:"key"` + } `json:"formats"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) + keys := make([]string, len(body.Formats)) + for i, f := range body.Formats { + keys[i] = f.Key + } + assert.Contains(t, keys, "ubl+en16931-v2017") + assert.Contains(t, keys, "cii+en16931-v2017") +} + +func TestExportImportEndpoints(t *testing.T) { + srv := httptest.NewServer(api.NewHandler()) + defer srv.Close() + + doc, err := os.ReadFile("../internal/ops/testdata/convert-invoice.json") + require.NoError(t, err) + + resp, err := http.Post(srv.URL+prefix+"/export?format=foo+bar,cii%2Ben16931-v2017", "application/json", strings.NewReader(string(doc))) + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusUnprocessableEntity, resp.StatusCode, "unknown keys are rejected") + + // The "+" in keys may be left unescaped. + resp, err = http.Post(srv.URL+prefix+"/export?format=ubl+en16931-v2017", "application/json", strings.NewReader(string(doc))) + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + require.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, "ubl+en16931-v2017", resp.Header.Get(api.FormatHeader)) + assert.Contains(t, resp.Header.Get("Content-Type"), "xml") + xml, err := io.ReadAll(resp.Body) + require.NoError(t, err) + assert.Contains(t, string(xml), "")) + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusUnprocessableEntity, resp.StatusCode) +} + +func TestFormatCheckEndpoint(t *testing.T) { + srv := httptest.NewServer(api.NewHandler()) + defer srv.Close() + + doc, err := os.ReadFile("../internal/ops/testdata/convert-invoice.json") + require.NoError(t, err) + + resp, err := http.Post(srv.URL+prefix+"/formats", "application/json", strings.NewReader(string(doc))) + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + require.Equal(t, http.StatusOK, resp.StatusCode) + + var body struct { + Formats []struct { + Key string `json:"key"` + Accepted bool `json:"accepted"` + Error *struct { + Key string `json:"key"` + Faults []map[string]any `json:"faults"` + } `json:"error"` + } `json:"formats"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) + got := make(map[string]bool) + for _, f := range body.Formats { + got[f.Key] = f.Accepted + if f.Key == "ubl+de-xrechnung-v3" { + require.NotNil(t, f.Error) + assert.Equal(t, "not-supported", f.Error.Key) + assert.NotEmpty(t, f.Error.Faults, "refusals are given as faults") + } + } + assert.True(t, got["ubl+en16931-v2017"]) + assert.False(t, got["ubl+de-xrechnung-v3"]) + assert.NotContains(t, got, "cdar+fr-ctc-cdv-v1", "formats that do not export invoices are left out") + + resp, err = http.Post(srv.URL+prefix+"/formats", "application/json", strings.NewReader(`{"$schema": "https://gobl.org/draft-0/bill/invoice"}`)) + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusUnprocessableEntity, resp.StatusCode, "documents that do not build are rejected") +} diff --git a/api/openapi.json b/api/openapi.json index 36d3710..d61daef 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -215,6 +215,93 @@ } } }, + "/import": { + "post": { + "operationId": "import", + "summary": "Import a document", + "description": "Detects the format of a document in an external format, such as UBL or CII, and converts it into a GOBL envelope.", + "tags": ["Convert"], + "parameters": [ + { + "name": "format", + "in": "query", + "description": "Format keys to limit detection to, comma-separated or repeated.", + "schema": { "type": "string" }, + "example": "ubl+en16931-v2017" + } + ], + "requestBody": { + "required": true, + "content": { + "application/xml": { + "schema": { "type": "string" } + } + } + }, + "responses": { + "200": { + "description": "Imported GOBL envelope", + "headers": { + "GOBL-Format": { + "description": "Key of the format the document was detected as.", + "schema": { "type": "string" } + } + }, + "content": { + "application/json": { + "schema": {} + } + } + }, + "400": { "$ref": "#/components/responses/Error" }, + "422": { "$ref": "#/components/responses/Error" } + } + } + }, + "/export": { + "post": { + "operationId": "export", + "summary": "Export a document", + "description": "Converts a GOBL document or envelope into the first of the requested formats that accepts it. Documents and unsigned envelopes are calculated and validated first.", + "tags": ["Convert"], + "parameters": [ + { + "name": "format", + "in": "query", + "required": true, + "description": "Format keys to export into, in order of preference, comma-separated or repeated.", + "schema": { "type": "string" }, + "example": "ubl+en16931-v2017" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": {} + } + } + }, + "responses": { + "200": { + "description": "Document in the format used, with the format's media type", + "headers": { + "GOBL-Format": { + "description": "Key of the format the document was converted into.", + "schema": { "type": "string" } + } + }, + "content": { + "application/xml": { + "schema": { "type": "string" } + } + } + }, + "400": { "$ref": "#/components/responses/Error" }, + "422": { "$ref": "#/components/responses/Error" } + } + } + }, "/keygen": { "post": { "operationId": "keygen", @@ -398,6 +485,66 @@ "404": { "$ref": "#/components/responses/Error" } } } + }, + "/formats": { + "get": { + "operationId": "listFormats", + "summary": "List conversion formats", + "description": "Lists the external formats registered by the bundled converters, with the GOBL schemas each can import and export.", + "tags": ["Convert"], + "responses": { + "200": { + "description": "List of formats", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "formats": { + "type": "array", + "items": { "$ref": "#/components/schemas/Format" } + } + } + } + } + } + } + } + }, + "post": { + "operationId": "checkFormats", + "summary": "Check formats for a document", + "description": "Builds the GOBL document or envelope and reports, for each format that exports its schema, whether the format accepts it, with the reasons for refusals.", + "tags": ["Convert"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": {} + } + } + }, + "responses": { + "200": { + "description": "Format checks", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "formats": { + "type": "array", + "items": { "$ref": "#/components/schemas/FormatCheck" } + } + } + } + } + } + }, + "400": { "$ref": "#/components/responses/Error" }, + "422": { "$ref": "#/components/responses/Error" } + } + } } }, "components": { @@ -550,6 +697,48 @@ } } }, + "Format": { + "type": "object", + "properties": { + "key": { "type": "string", "example": "ubl+en16931-v2017" }, + "name": { "$ref": "#/components/schemas/I18nString" }, + "description": { "$ref": "#/components/schemas/I18nString" }, + "mime": { "type": "string", "example": "application/xml" }, + "syntax": { "type": "string", "example": "ubl" }, + "countries": { + "type": "array", + "items": { "type": "string" } + }, + "import": { + "type": "array", + "description": "GOBL schemas the format can be imported into.", + "items": { "type": "string" } + }, + "export": { + "type": "array", + "description": "GOBL schemas the format can be exported from.", + "items": { "type": "string" } + } + } + }, + "FormatCheck": { + "allOf": [ + { "$ref": "#/components/schemas/Format" }, + { + "type": "object", + "properties": { + "accepted": { + "type": "boolean", + "description": "Whether the format accepts the document for export." + }, + "error": { + "$ref": "#/components/schemas/Error", + "description": "Why the format refuses the document, usually as faults, or why accepting it still failed to convert." + } + } + } + ] + }, "I18nString": { "type": "object", "description": "Localized string keyed by language code.", @@ -594,6 +783,7 @@ "tags": [ { "name": "Documents", "description": "Build, validate, correct, and replicate GOBL documents." }, { "name": "Signing", "description": "Sign and verify GOBL envelopes." }, + { "name": "Convert", "description": "Convert documents between GOBL and external formats such as UBL and CII." }, { "name": "Reference", "description": "JSON schemas, tax regimes, and format addons." }, { "name": "Meta", "description": "Service metadata." } ] diff --git a/bundle/bundle.go b/bundle/bundle.go index 6156599..0f119d9 100644 --- a/bundle/bundle.go +++ b/bundle/bundle.go @@ -1,12 +1,16 @@ package bundle -// Package bundle declares the addon set that GOBL.dev ships with. Blank-importing -// it registers every addon listed here, so both the CLI and the web/API binaries -// support the same set. Add a blank import per approved addon module — this is the -// one place to update. +// Package bundle declares the addon and converter set that GOBL.dev ships with. +// Blank-importing it registers every addon and converter listed here, so both the +// CLI and the web/API binaries support the same set. Add a blank import per +// approved addon module, or per converter module that registers formats with +// GOBL's convert package — this is the one place to update. import ( + // Addons + _ "github.com/invopop/gobl.ar.arca/addon" _ "github.com/invopop/gobl.br.nfe/addon" _ "github.com/invopop/gobl.br.nfse/addon" + _ "github.com/invopop/gobl.co.dian/addon" _ "github.com/invopop/gobl.dk.oioubl/addon" _ "github.com/invopop/gobl.fi.finvoice/addon" _ "github.com/invopop/gobl.fr.ctc/addon" @@ -15,4 +19,13 @@ import ( _ "github.com/invopop/gobl.pl.ksef/addon" _ "github.com/invopop/gobl.pt.saft/addon" _ "github.com/invopop/gobl.sa.zatca/addon" + + // Converters + _ "github.com/invopop/gobl.cii" + _ "github.com/invopop/gobl.de.xinvoice" + _ "github.com/invopop/gobl.fr.ctc/cdar" + _ "github.com/invopop/gobl.fr.ctc/cii" + _ "github.com/invopop/gobl.fr.ctc/ubl" + _ "github.com/invopop/gobl.sa.zatca/ubl" + _ "github.com/invopop/gobl.ubl" ) diff --git a/cmd/gobl/convert.go b/cmd/gobl/convert.go new file mode 100644 index 0000000..28a6eb7 --- /dev/null +++ b/cmd/gobl/convert.go @@ -0,0 +1,118 @@ +package main + +import ( + "encoding/json" + "errors" + + "github.com/invopop/gobl.dev/internal/ops" + "github.com/invopop/gobl/cbc" + "github.com/spf13/cobra" +) + +type convertOpts struct { + *rootOpts + to []string // formats to export into, in order of preference + from []string // formats to limit import detection to + list bool +} + +func convert(root *rootOpts) *convertOpts { + return &convertOpts{ + rootOpts: root, + } +} + +func (o *convertOpts) cmd() *cobra.Command { + cmd := &cobra.Command{ + Args: cobra.MaximumNArgs(2), + RunE: o.runE, + Use: "convert [infile] [outfile]", + Short: "Convert documents between GOBL and external formats", + Long: `Convert documents between GOBL and the external formats registered by the +bundled converters, such as UBL and CII. + +With --to, the input GOBL document or envelope is exported into the first of the +formats given that accepts it. Without --to, the format of the input is detected +and it is imported into a GOBL envelope. Use --list to see the available formats.`, + Example: ` gobl convert invoice.json invoice.xml --to ubl+en16931-v2017 + gobl convert invoice.xml -i + gobl convert --list -i`, + } + + f := cmd.Flags() + f.StringSliceVarP(&o.to, "to", "t", nil, "Format keys to export into, in order of preference.") + f.StringSliceVar(&o.from, "from", nil, "Format keys to limit import detection to.") + f.BoolVarP(&o.list, "list", "l", false, "List the available formats.") + + return cmd +} + +func (o *convertOpts) runE(cmd *cobra.Command, args []string) error { + ctx := commandContext(cmd) + + if len(o.to) > 0 && len(o.from) > 0 { + return errors.New("--to and --from cannot be used together") + } + + if o.list { + return o.encode(cmd, args, ops.Formats()) + } + + input, err := openInput(cmd, args) + if err != nil { + return err + } + defer input.Close() // nolint:errcheck + + if len(o.to) == 0 { + env, _, err := ops.Import(ctx, &ops.ImportOptions{ + Input: input, + Formats: formatKeys(o.from), + }) + if err != nil { + return err + } + return o.encode(cmd, args, env) + } + + res, err := ops.Export(ctx, &ops.ExportOptions{ + ParseOptions: &ops.ParseOptions{ + Input: input, + }, + Formats: formatKeys(o.to), + }) + if err != nil { + return err + } + + out, err := o.openOutput(cmd, args) + if err != nil { + return err + } + defer out.Close() // nolint:errcheck + + _, err = out.Write(res.Data) + return err +} + +func (o *convertOpts) encode(cmd *cobra.Command, args []string, obj any) error { + out, err := o.openOutput(cmd, args) + if err != nil { + return err + } + defer out.Close() // nolint:errcheck + + enc := json.NewEncoder(out) + if o.indent { + enc.SetIndent("", "\t") + } + return enc.Encode(obj) +} + +func formatKeys(list []string) []cbc.Key { + keys := make([]cbc.Key, len(list)) + for i, k := range list { + keys[i] = cbc.Key(k) + } + return keys +} diff --git a/cmd/gobl/convert_test.go b/cmd/gobl/convert_test.go new file mode 100644 index 0000000..39650ee --- /dev/null +++ b/cmd/gobl/convert_test.go @@ -0,0 +1,75 @@ +package main + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/invopop/gobl" + "github.com/invopop/gobl/bill" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func runConvert(t *testing.T, stdin []byte, args ...string) ([]byte, error) { + t.Helper() + cmd := root().cmd() + stdout := new(bytes.Buffer) + cmd.SetIn(bytes.NewReader(stdin)) + cmd.SetOut(stdout) + cmd.SetArgs(append([]string{"convert"}, args...)) + err := cmd.Execute() + return stdout.Bytes(), err +} + +func Test_convert(t *testing.T) { + t.Run("list", func(t *testing.T) { + out, err := runConvert(t, nil, "--list") + require.NoError(t, err) + var formats []map[string]any + require.NoError(t, json.Unmarshal(out, &formats)) + keys := make([]string, 0, len(formats)) + for _, f := range formats { + keys = append(keys, f["key"].(string)) + } + assert.Contains(t, keys, "ubl+en16931-v2017") + assert.Contains(t, keys, "cii+en16931-v2017") + }) + + t.Run("export and import", func(t *testing.T) { + xml, err := runConvert(t, nil, "testdata/convert-invoice.json", "--to", "ubl+en16931-v2017") + require.NoError(t, err) + assert.Contains(t, string(xml), " { + const boot = readBootstrap(); + Alpine.data("editor", () => ({ + // "build" replaces the GOBL source with the built document. "convert" + // shows the GOBL source beside a document in another format: exporting + // writes the GOBL source into that format, and importing reads the other + // document back into GOBL. + mode: initialMode(), loading: false, envelop: false, error: null, - // Counter so each successful build creates a fresh FlashMessage. - success: 0, + // The pane the last successful action updated, with a counter so each + // one creates a fresh FlashMessage. + done: { pane: "", n: 0 }, - init() { + examples: boot.examples || [], + exampleID: "", + sampleID: "", + + // Labels of the export formats registered with GOBL's convert package, + // by key, and those that accept the GOBL source, as checked by the + // server: only a format's converter can tell whether it accepts a + // document. + labels: boot.formats || {}, + available: [], + checking: false, + checks: 0, + format: stored("editor-format") || "", + // Name of the format of the other document, once known. + inputTitle: "", + + cm: null, + + async init() { window.addEventListener("keydown", (e) => { if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { e.preventDefault(); - this.build(); + this.run(this.defaultAction()); } }); + window.addEventListener("gobl-source-change", () => { + if (this.mode === "convert") this.checkFormats(); + }); + this.$watch("format", (v) => v && store("editor-format", v)); + + this.cm = await window._cmReady; + await this.loadExample(this.findExample(stored("editor-example"))?.id || boot.defaultExample); + if (this.mode === "convert") await this.enterConvert(); }, - async build() { - const ed = window._cmEditor; - if (!ed) return; + findExample(id) { + return this.examples.find((e) => e.id === id); + }, - this.loading = true; + // defaultAction is run with ⌘/Ctrl+Enter: building, or in convert mode, + // the action that reads from the focused editor. + defaultAction() { + if (this.mode === "build") return "build"; + return this.cm?.input.hasFocus ? "import" : "export"; + }, + + setMode(mode) { + if (mode === this.mode) return; + this.mode = mode; + store("editor-mode", mode); this.error = null; - this.success = 0; + if (mode === "convert") this.enterConvert(); + }, - try { - const content = ed.state.doc.toString(); - const payload = JSON.parse(content); + // enterConvert checks the formats for the GOBL source, and fills the + // other editor with a sample when it is empty. + async enterConvert() { + await this.checkFormats(); + if (this.cm.input.state.doc.length) return; + const current = this.findExample(this.exampleID); + const ex = + this.findExample(stored("editor-sample")) || + (current?.sample ? current : null) || + this.examples.find((e) => e.sample); + if (ex) await this.loadSample(ex.id); + }, - const res = await fetch("/v0/build", { + // checkFormats asks the server which formats accept the GOBL source, + // keeping the selected format among them. Documents that do not build + // have none, and the reason is shown as an error. + async checkFormats() { + const seq = ++this.checks; + this.checking = true; + try { + const res = await fetch("/v0/formats", { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ data: payload, envelop: this.envelop }), + headers: { "Content-Type": "text/plain" }, + body: this.cm.source.state.doc.toString(), }); - - const result = await res.json(); - + const body = await res.json().catch(() => ({ message: "format check failed: " + res.status })); + // A newer check started while this one was running. + if (seq !== this.checks) return; if (!res.ok) { - this.error = result; - return; + this.available = []; + this.error = body; + } else { + this.available = body.formats + .filter((f) => f.accepted) + .map((f) => ({ key: f.key, label: this.labels[f.key] || f.key })); + if (this.error && !this.loading) this.error = null; } + const keys = this.available.map((f) => f.key); + if (!keys.includes(this.format)) this.format = keys[0] || ""; + } finally { + if (seq === this.checks) this.checking = false; + } + }, - const pretty = JSON.stringify(result, null, 2); - const scrollTop = ed.scrollDOM.scrollTop; - const { anchor, head } = ed.state.selection.main; - ed.dispatch({ - changes: { from: 0, to: ed.state.doc.length, insert: pretty }, - selection: { - anchor: Math.min(anchor, pretty.length), - head: Math.min(head, pretty.length), - }, - }); - requestAnimationFrame(() => { - ed.scrollDOM.scrollTop = scrollTop; - }); - ed.focus(); - this.success++; + // loadExample replaces the GOBL source with the example, selecting its + // most specific format, listed first. + async loadExample(id) { + const ex = this.findExample(id); + if (!ex || !this.cm) return; + this.exampleID = id; + store("editor-example", id); + this.error = null; + if (ex.formats?.length) this.format = ex.formats[0]; + this.cm.setSource(JSON.stringify(ex.doc, null, 2)); + if (this.mode === "convert") await this.checkFormats(); + }, + + // loadSample replaces the other document with the example exported into + // its most specific format that can be imported back. + async loadSample(id) { + const ex = this.findExample(id); + const key = ex?.sample; + if (!key || !this.cm) return; + this.sampleID = id; + store("editor-sample", id); + this.error = null; + const out = await this.post("/v0/export?format=" + encodeURIComponent(key), ex.doc); + if (!out) return; + this.cm.setInput(out.text); + this.inputTitle = this.labels[out.format] || out.format; + }, + + async run(action) { + if (!this.cm || this.loading) return; + this.loading = true; + this.error = null; + try { + await this[action](); } catch (e) { this.error = { message: e.message }; } finally { this.loading = false; } }, + + flash(pane) { + this.done = { pane, n: this.done.n + 1 }; + }, + + async build() { + const ed = this.cm.source; + const payload = JSON.parse(ed.state.doc.toString()); + const out = await this.post("/v0/build", { data: payload, envelop: this.envelop }); + if (!out) return; + + const pretty = JSON.stringify(JSON.parse(out.text), null, 2); + const scrollTop = ed.scrollDOM.scrollTop; + const { anchor, head } = ed.state.selection.main; + ed.dispatch({ + changes: { from: 0, to: ed.state.doc.length, insert: pretty }, + selection: { + anchor: Math.min(anchor, pretty.length), + head: Math.min(head, pretty.length), + }, + }); + requestAnimationFrame(() => { + ed.scrollDOM.scrollTop = scrollTop; + }); + ed.focus(); + this.flash("source"); + }, + + // export writes the GOBL source, built by the server and left untouched + // here, into the selected format in the other editor. + async export() { + if (!this.format) return; + const out = await this.post( + "/v0/export?format=" + encodeURIComponent(this.format), + this.cm.source.state.doc.toString(), + ); + if (!out) return; + this.cm.setInput(out.text); + // The other editor no longer holds a sample. + this.sampleID = ""; + this.inputTitle = this.labels[out.format] || out.format; + this.flash("input"); + }, + + // import reads the other document, detecting its format, into the GOBL + // source. + async import() { + const out = await this.post("/v0/import", this.cm.input.state.doc.toString()); + if (!out) return; + this.inputTitle = this.labels[out.format] || out.format || ""; + // Export back into the detected format by default. + if (out.format) this.format = out.format; + this.cm.setSource(JSON.stringify(JSON.parse(out.text), null, 2)); + // The GOBL source no longer holds an example. + this.exampleID = ""; + this.flash("source"); + await this.checkFormats(); + }, + + // post sends the body (JSON encoded unless already a string) and returns + // the response text and the format used, or null after showing the + // error. + async post(url, body) { + const raw = typeof body === "string"; + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": raw ? "text/plain" : "application/json" }, + body: raw ? body : JSON.stringify(body), + }); + if (!res.ok) { + this.error = await res.json().catch(() => ({ + message: "request failed: " + res.status, + })); + return null; + } + return { text: await res.text(), format: res.headers.get("GOBL-Format") }; + }, })); Alpine.data("darkModeToggle", () => ({ dark: false, init() { - const stored = localStorage.getItem("dark-mode"); + const s = stored("dark-mode"); this.dark = - stored !== null - ? stored === "true" - : document.documentElement.classList.contains("dark"); + s !== null ? s === "true" : document.documentElement.classList.contains("dark"); document.documentElement.classList.toggle("dark", this.dark); this.applyEditorTheme(); }, toggle() { this.dark = !this.dark; document.documentElement.classList.toggle("dark", this.dark); - localStorage.setItem("dark-mode", this.dark); + store("dark-mode", this.dark); this.applyEditorTheme(); }, - applyEditorTheme() { - if (window._cmSetDark) { - window._cmSetDark(this.dark); - } + async applyEditorTheme() { + const cm = await window._cmReady; + cm.setDark(this.dark); }, })); }); + +// initialMode restores the last mode, mapping the former export and import +// modes onto convert. +function initialMode() { + const m = stored("editor-mode"); + if (m === "export" || m === "import") return "convert"; + return MODES.includes(m) ? m : "build"; +} + +function readBootstrap() { + const el = document.getElementById("editor-bootstrap"); + try { + return el ? JSON.parse(el.textContent) : {}; + } catch (e) { + console.warn("Failed to parse editor bootstrap:", e); + return {}; + } +} + +// stored and store wrap localStorage, which may be unavailable. +function stored(key) { + try { + return localStorage.getItem(key); + } catch { + return null; + } +} + +function store(key, value) { + try { + localStorage.setItem(key, value); + } catch { + // Preferences are not kept. + } +} diff --git a/editor/assets/editor.css b/editor/assets/editor.css index 9d1f220..321411b 100644 --- a/editor/assets/editor.css +++ b/editor/assets/editor.css @@ -3,6 +3,39 @@ flex: 1; overflow: hidden; } +.pane { + height: 100%; + overflow: hidden; +} +.pane-toolbar { + display: flex; + align-items: center; + gap: 0.5rem; + min-height: 2.75rem; + padding: 0.375rem 0.75rem; + border-bottom: 1px solid var(--color-border, #e5e7eb); + flex-shrink: 0; + flex-wrap: wrap; +} +.pane-name { + font-size: 12px; + font-weight: 600; + color: var(--color-foreground-default-secondary, #6b7280); +} +.toolbar-select { + height: 2rem; + max-width: 16rem; + padding: 0 0.5rem; + font-size: 0.875rem; + border: 1px solid var(--color-border, #e5e7eb); + border-radius: 0.25rem; + background: var(--color-background, #fff); +} +.pane-body { + flex: 1; + min-height: 0; + overflow: hidden; +} .cm-editor { height: 100%; } diff --git a/editor/assets/editor.js b/editor/assets/editor.js index da062bf..9177b1a 100644 --- a/editor/assets/editor.js +++ b/editor/assets/editor.js @@ -1,53 +1,20 @@ // editor.js -- ES module for GOBL Editor -// Initializes CodeMirror with JSON schema support. +// Initializes the CodeMirror editors: the GOBL source with JSON schema +// support, and the editor for documents in other formats, which exports are +// written to and imports are read from. Once mounted, the editors are handed +// to the Alpine component through window._cmReady (created in ). import { basicSetup, EditorView } from "codemirror"; import { EditorState, Compartment } from "@codemirror/state"; import { vsCodeLight } from "@fsegurai/codemirror-theme-vscode-light"; import { vsCodeDark } from "@fsegurai/codemirror-theme-vscode-dark"; -// Theme compartment allows dynamic reconfiguration. -const themeCompartment = new Compartment(); - function isDark() { return document.documentElement.classList.contains("dark"); } -const defaultDoc = JSON.stringify( - { - $schema: "https://gobl.org/draft-0/bill/invoice", - currency: "USD", - issue_date: new Date().toISOString().slice(0, 10), - supplier: { - name: "Acme Inc.", - tax_id: { - country: "US", - }, - }, - customer: { - name: "Sample Customer", - }, - lines: [ - { - quantity: "10", - item: { - name: "Development Services", - price: "100.00", - }, - taxes: [ - { - cat: "ST", - percent: "8.25%", - }, - ], - }, - ], - }, - null, - 2, -); - const { jsonSchema, updateSchema } = await import("codemirror-json-schema"); +const { xml } = await import("@codemirror/lang-xml"); const SCHEMA_PREFIX = "https://gobl.org/draft-0/"; let activeSchemaURL = null; @@ -70,36 +37,65 @@ async function loadSchemaFromDoc(view) { } } -const container = document.getElementById("editor-container"); -container.replaceChildren(); +// Every editor gets its own theme compartment so all can follow dark mode. +const themes = []; -const editor = new EditorView({ - state: EditorState.create({ - doc: defaultDoc, - extensions: [ - basicSetup, - themeCompartment.of(isDark() ? vsCodeDark : vsCodeLight), - EditorView.lineWrapping, - jsonSchema(), - EditorView.updateListener.of((update) => { - if (update.docChanged) { - clearTimeout(debounceTimer); - debounceTimer = setTimeout( - () => loadSchemaFromDoc(update.view), - 500, - ); - } - }), - ], - }), - parent: container, -}); +function createEditor(parent, extensions) { + const theme = new Compartment(); + const view = new EditorView({ + state: EditorState.create({ + doc: "", + extensions: [ + basicSetup, + theme.of(isDark() ? vsCodeDark : vsCodeLight), + EditorView.lineWrapping, + ...extensions, + ], + }), + parent, + }); + themes.push({ view, theme }); + return view; +} -window._cmEditor = editor; -window._cmSetDark = (dark) => { - editor.dispatch({ - effects: themeCompartment.reconfigure(dark ? vsCodeDark : vsCodeLight), +function setText(view, text) { + view.dispatch({ + changes: { from: 0, to: view.state.doc.length, insert: text }, }); -}; -loadSchemaFromDoc(editor); +} +const sourceParent = document.getElementById("source-gobl"); +sourceParent.replaceChildren(); + +const source = createEditor(sourceParent, [ + jsonSchema(), + EditorView.updateListener.of((update) => { + if (update.docChanged) { + clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + loadSchemaFromDoc(update.view); + window.dispatchEvent(new CustomEvent("gobl-source-change")); + }, 300); + } + }), +]); + +const input = createEditor(document.getElementById("source-xml"), [xml()]); + +window._cmReadyResolve({ + source, + input, + setSource(text) { + setText(source, text); + loadSchemaFromDoc(source); + }, + setInput(text) { + setText(input, text); + }, + setDark(dark) { + const theme = dark ? vsCodeDark : vsCodeLight; + for (const t of themes) { + t.view.dispatch({ effects: t.theme.reconfigure(theme) }); + } + }, +}); diff --git a/editor/editor.templ b/editor/editor.templ index c9e2d2d..ec56795 100644 --- a/editor/editor.templ +++ b/editor/editor.templ @@ -13,6 +13,7 @@ templ Page() { Importmap: []props.Import{ {Name: "codemirror", URL: "https://esm.sh/codemirror@6.0.1"}, {Name: "@codemirror/state", URL: "https://esm.sh/@codemirror/state@6"}, + {Name: "@codemirror/lang-xml", URL: "https://esm.sh/@codemirror/lang-xml@6"}, {Name: "codemirror-json-schema", URL: "https://esm.sh/@invopop/codemirror-json-schema@0.9.3"}, {Name: "@fsegurai/codemirror-theme-vscode-light", URL: "https://esm.sh/@fsegurai/codemirror-theme-vscode-light@6"}, {Name: "@fsegurai/codemirror-theme-vscode-dark", URL: "https://esm.sh/@fsegurai/codemirror-theme-vscode-dark@6"}, @@ -32,41 +33,7 @@ templ Page() { @popui.Header(props.Header{ Title: goblTitle(), }) { - - @popui.Button(props.Button{ - Attributes: templ.Attributes{ - "@click": "envelop = !envelop", - }, - }) { - @popui.Checkbox(props.Checkbox{ - Attributes: templ.Attributes{ - "x-model": "envelop", - "@click.stop": "", - "tabindex": "-1", - "pointer-events": "none", - }, - Class: "pointer-events-none", - }) - Envelop - } - @popui.Button(props.Button{ - Variant: props.ButtonVariantPrimary, - Attributes: templ.Attributes{ - "@click": "build()", - ":disabled": "loading", - "title": "Build (⌘/Ctrl+Enter)", - }, - }) { - Build - } + @modeTabs() @darkModeToggle() } @popui.Main() { @@ -86,13 +53,172 @@ templ Page() { -
- @editorSkeleton() +
+
+ @sourceToolbar() +
+ @editorSkeleton() +
+
+
+ @inputToolbar() +
+
} } } +// modeTabs switches between building GOBL documents and converting them to +// and from the formats registered with GOBL's convert package. +templ modeTabs() { + @popui.Tabs(props.Tabs{Variant: "pill", Class: "mb-0 w-auto"}) { + for _, m := range []struct{ Key, Label string }{{"build", "Build"}, {"convert", "Convert"}} { + @popui.Tab(props.Tab{ + Variant: "pill", + Attributes: templ.Attributes{ + "@click": "setMode('" + m.Key + "')", + ":data-state": "mode === '" + m.Key + "' ? 'active' : ''", + ":aria-selected": "mode === '" + m.Key + "'", + }, + }) { + { m.Label } + } + } + } +} + +// sourceToolbar sits above the GOBL editor: the example picker, and either +// the build options or the format to export into. +templ sourceToolbar() { +
+ GOBL + +
+ @doneFlash("source") + + +
+
+} + +// inputToolbar sits above the editor for documents in other formats: the +// import action, and samples made by exporting the examples into a format +// they can be imported back from. +templ inputToolbar() { +
+ @popui.Button(props.Button{ + Variant: props.ButtonVariantPrimary, + Attributes: templ.Attributes{ + "@click": "run('import')", + ":disabled": "loading", + "title": "Import the document into GOBL (⌘/Ctrl+Enter)", + }, + }) { + ← Import + } + @doneFlash("input") + + +
+} + +// formatPicker selects the format to export into, from those registered with +// GOBL's convert package that accept the document. +templ formatPicker() { + +} + +// doneFlash briefly confirms the last action that updated the pane. +templ doneFlash(pane string) { + +} + templ editorSkeleton() {
@@ -128,7 +254,12 @@ templ headExtras() { if (localStorage.getItem("dark-mode") === "true") { document.documentElement.classList.add("dark"); } + // Resolved by editor.js with its editors once CodeMirror is mounted. + // The module loads after alpine:init may have fired, so the editor + // component waits on this promise. + window._cmReady = new Promise((r) => { window._cmReadyResolve = r; }); + @templ.JSONScript("editor-bootstrap", bootstrap()) } templ darkModeToggle() { diff --git a/editor/editor_templ.go b/editor/editor_templ.go index 8c2e668..ce6caee 100644 --- a/editor/editor_templ.go +++ b/editor/editor_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.977 +// templ: version: v0.3.1001 package editor //lint:file-ignore SA4006 This context is only used if a nested component is present. @@ -60,96 +60,11 @@ func Page() templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Var4 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { - templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context - templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) - if !templ_7745c5c3_IsBuffer { - defer func() { - templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) - if templ_7745c5c3_Err == nil { - templ_7745c5c3_Err = templ_7745c5c3_BufErr - } - }() - } - ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Err = popui.Checkbox(props.Checkbox{ - Attributes: templ.Attributes{ - "x-model": "envelop", - "@click.stop": "", - "tabindex": "-1", - "pointer-events": "none", - }, - Class: "pointer-events-none", - }).Render(ctx, templ_7745c5c3_Buffer) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, " Envelop") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - return nil - }) - templ_7745c5c3_Err = popui.Button(props.Button{ - Attributes: templ.Attributes{ - "@click": "envelop = !envelop", - }, - }).Render(templ.WithChildren(ctx, templ_7745c5c3_Var4), templ_7745c5c3_Buffer) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, " ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Var5 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { - templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context - templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) - if !templ_7745c5c3_IsBuffer { - defer func() { - templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) - if templ_7745c5c3_Err == nil { - templ_7745c5c3_Err = templ_7745c5c3_BufErr - } - }() - } - ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "Build") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - return nil - }) - templ_7745c5c3_Err = popui.Button(props.Button{ - Variant: props.ButtonVariantPrimary, - Attributes: templ.Attributes{ - "@click": "build()", - ":disabled": "loading", - "title": "Build (⌘/Ctrl+Enter)", - }, - }).Render(templ.WithChildren(ctx, templ_7745c5c3_Var5), templ_7745c5c3_Buffer) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -165,11 +80,11 @@ func Page() templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Var6 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_Var4 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) if !templ_7745c5c3_IsBuffer { @@ -181,7 +96,15 @@ func Page() templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = sourceToolbar().Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -189,13 +112,21 @@ func Page() templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = inputToolbar().Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } return nil }) - templ_7745c5c3_Err = popui.Main().Render(templ.WithChildren(ctx, templ_7745c5c3_Var6), templ_7745c5c3_Buffer) + templ_7745c5c3_Err = popui.Main().Render(templ.WithChildren(ctx, templ_7745c5c3_Var4), templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -206,6 +137,7 @@ func Page() templ.Component { Importmap: []props.Import{ {Name: "codemirror", URL: "https://esm.sh/codemirror@6.0.1"}, {Name: "@codemirror/state", URL: "https://esm.sh/@codemirror/state@6"}, + {Name: "@codemirror/lang-xml", URL: "https://esm.sh/@codemirror/lang-xml@6"}, {Name: "codemirror-json-schema", URL: "https://esm.sh/@invopop/codemirror-json-schema@0.9.3"}, {Name: "@fsegurai/codemirror-theme-vscode-light", URL: "https://esm.sh/@fsegurai/codemirror-theme-vscode-light@6"}, {Name: "@fsegurai/codemirror-theme-vscode-dark", URL: "https://esm.sh/@fsegurai/codemirror-theme-vscode-dark@6"}, @@ -229,6 +161,491 @@ func Page() templ.Component { }) } +// modeTabs switches between building GOBL documents and converting them to +// and from the formats registered with GOBL's convert package. +func modeTabs() templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var5 := templ.GetChildren(ctx) + if templ_7745c5c3_Var5 == nil { + templ_7745c5c3_Var5 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Var6 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + for _, m := range []struct{ Key, Label string }{{"build", "Build"}, {"convert", "Convert"}} { + templ_7745c5c3_Var7 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + var templ_7745c5c3_Var8 string + templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(m.Label) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `editor.templ`, Line: 85, Col: 13} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) + templ_7745c5c3_Err = popui.Tab(props.Tab{ + Variant: "pill", + Attributes: templ.Attributes{ + "@click": "setMode('" + m.Key + "')", + ":data-state": "mode === '" + m.Key + "' ? 'active' : ''", + ":aria-selected": "mode === '" + m.Key + "'", + }, + }).Render(templ.WithChildren(ctx, templ_7745c5c3_Var7), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + return nil + }) + templ_7745c5c3_Err = popui.Tabs(props.Tabs{Variant: "pill", Class: "mb-0 w-auto"}).Render(templ.WithChildren(ctx, templ_7745c5c3_Var6), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// sourceToolbar sits above the GOBL editor: the example picker, and either +// the build options or the format to export into. +func sourceToolbar() templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var9 := templ.GetChildren(ctx) + if templ_7745c5c3_Var9 == nil { + templ_7745c5c3_Var9 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "
GOBL
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = doneFlash("source").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// inputToolbar sits above the editor for documents in other formats: the +// import action, and samples made by exporting the examples into a format +// they can be imported back from. +func inputToolbar() templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var16 := templ.GetChildren(ctx) + if templ_7745c5c3_Var16 == nil { + templ_7745c5c3_Var16 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Var17 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "← Import") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) + templ_7745c5c3_Err = popui.Button(props.Button{ + Variant: props.ButtonVariantPrimary, + Attributes: templ.Attributes{ + "@click": "run('import')", + ":disabled": "loading", + "title": "Import the document into GOBL (⌘/Ctrl+Enter)", + }, + }).Render(templ.WithChildren(ctx, templ_7745c5c3_Var17), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = doneFlash("input").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// formatPicker selects the format to export into, from those registered with +// GOBL's convert package that accept the document. +func formatPicker() templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var21 := templ.GetChildren(ctx) + if templ_7745c5c3_Var21 == nil { + templ_7745c5c3_Var21 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// doneFlash briefly confirms the last action that updated the pane. +func doneFlash(pane string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var22 := templ.GetChildren(ctx) + if templ_7745c5c3_Var22 == nil { + templ_7745c5c3_Var22 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + func editorSkeleton() templ.Component { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context @@ -245,24 +662,24 @@ func editorSkeleton() templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var7 := templ.GetChildren(ctx) - if templ_7745c5c3_Var7 == nil { - templ_7745c5c3_Var7 = templ.NopComponent + templ_7745c5c3_Var24 := templ.GetChildren(ctx) + if templ_7745c5c3_Var24 == nil { + templ_7745c5c3_Var24 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for i := range 20 { if i < 15 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -286,12 +703,16 @@ func headExtras() templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var8 := templ.GetChildren(ctx) - if templ_7745c5c3_Var8 == nil { - templ_7745c5c3_Var8 = templ.NopComponent + templ_7745c5c3_Var25 := templ.GetChildren(ctx) + if templ_7745c5c3_Var25 == nil { + templ_7745c5c3_Var25 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templ.JSONScript("editor-bootstrap", bootstrap()).Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -315,12 +736,12 @@ func darkModeToggle() templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var9 := templ.GetChildren(ctx) - if templ_7745c5c3_Var9 == nil { - templ_7745c5c3_Var9 = templ.NopComponent + templ_7745c5c3_Var26 := templ.GetChildren(ctx) + if templ_7745c5c3_Var26 == nil { + templ_7745c5c3_Var26 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -352,12 +773,12 @@ func goblTitle() templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var10 := templ.GetChildren(ctx) - if templ_7745c5c3_Var10 == nil { - templ_7745c5c3_Var10 = templ.NopComponent + templ_7745c5c3_Var27 := templ.GetChildren(ctx) + if templ_7745c5c3_Var27 == nil { + templ_7745c5c3_Var27 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -365,7 +786,7 @@ func goblTitle() templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "GOBL Editor
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "GOBL Editor
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/editor/examples.go b/editor/examples.go new file mode 100644 index 0000000..846323d --- /dev/null +++ b/editor/examples.go @@ -0,0 +1,159 @@ +package editor + +import ( + "embed" + "encoding/json" + "path" + "sort" + "strings" + "sync" + + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/convert" + "github.com/invopop/gobl/i18n" + "github.com/invopop/gobl/l10n" + "github.com/invopop/gobl/schema" + "github.com/invopop/gobl/tax" +) + +//go:embed examples/*.json +var examplesFS embed.FS + +const ( + // defaultExample is the ID of the example loaded when the editor opens. + defaultExample = "us" + + // standardLabel names examples without addons. + standardLabel = "Standard" +) + +// example is a bare-minimum GOBL document for a region, offered in the +// editor's example picker. The region, label, and the formats that accept +// it are derived from the document itself, so adding an example only +// requires adding its file. Formats that apply in the example's country come +// first, so the most specific one is used by default. +type example struct { + ID string `json:"id"` + Region string `json:"region"` + Label string `json:"label"` + Formats []string `json:"formats,omitempty"` + // Sample is the first of the formats that documents can also be + // imported from, used to offer the example as a document to import. + Sample string `json:"sample,omitempty"` + Doc json.RawMessage `json:"doc"` +} + +// examples are loaded on first use, as the addons they use and the formats +// they are checked against are only available once the bundle has +// registered them. +var examples = sync.OnceValue(mustLoadExamples) + +func mustLoadExamples() []*example { + entries, err := examplesFS.ReadDir("examples") + if err != nil { + panic(err) + } + list := make([]*example, 0, len(entries)) + for _, e := range entries { + data, err := examplesFS.ReadFile(path.Join("examples", e.Name())) + if err != nil { + panic(err) + } + ex, err := newExample(strings.TrimSuffix(e.Name(), ".json"), data) + if err != nil { + panic(e.Name() + ": " + err.Error()) + } + list = append(list, ex) + } + sort.SliceStable(list, func(i, j int) bool { + if list[i].Region != list[j].Region { + return list[i].Region < list[j].Region + } + // Standard examples, without addons, come first in their region. + if (list[i].Label == standardLabel) != (list[j].Label == standardLabel) { + return list[i].Label == standardLabel + } + return list[i].Label < list[j].Label + }) + return list +} + +func newExample(id string, data []byte) (*example, error) { + var doc struct { + Schema schema.ID `json:"$schema"` + Regime l10n.TaxCountryCode `json:"$regime"` + Addons []cbc.Key `json:"$addons"` + Supplier struct { + TaxID struct { + Country l10n.TaxCountryCode `json:"country"` + } `json:"tax_id"` + } `json:"supplier"` + } + if err := json.Unmarshal(data, &doc); err != nil { + return nil, err + } + + country := doc.Regime + if country == "" { + country = doc.Supplier.TaxID.Country + } + region := country.String() + if r := tax.RegimeDefFor(country.Code()); r != nil { + region = r.Name.In(i18n.EN) + } + + label := standardLabel + if len(doc.Addons) > 0 { + names := make([]string, len(doc.Addons)) + for i, k := range doc.Addons { + names[i] = k.String() + if a := tax.AddonForKey(k); a != nil { + names[i] = a.Name.In(i18n.EN) + } + } + label = strings.Join(names, ", ") + } + + formats, err := acceptedFormats(data) + if err != nil { + return nil, err + } + sort.SliceStable(formats, func(i, j int) bool { + return appliesIn(formats[i], country.Code()) && !appliesIn(formats[j], country.Code()) + }) + sample := "" + for _, k := range formats { + if importable(k, doc.Schema) { + sample = k + break + } + } + + return &example{ + ID: id, + Region: region, + Label: label, + Formats: formats, + Sample: sample, + Doc: json.RawMessage(data), + }, nil +} + +// appliesIn reports whether the format with the key is restricted to +// countries that include the country, directly or through a union such as +// the EU. +func appliesIn(key string, country l10n.Code) bool { + f := convert.FormatFor(cbc.Key(key)) + if f == nil { + return false + } + for _, c := range f.Countries { + if c == country { + return true + } + if u := l10n.Union(c); u != nil && u.HasMember(country) { + return true + } + } + return false +} diff --git a/editor/examples/de-en16931.json b/editor/examples/de-en16931.json new file mode 100644 index 0000000..7b5b002 --- /dev/null +++ b/editor/examples/de-en16931.json @@ -0,0 +1,27 @@ +{ + "$schema": "https://gobl.org/draft-0/bill/invoice", + "$addons": ["eu-en16931-v2017"], + "series": "SAMPLE", + "code": "001", + "currency": "EUR", + "issue_date": "2026-01-15", + "supplier": { + "name": "Provide One GmbH", + "tax_id": { "country": "DE", "code": "111111125" }, + "addresses": [{ "street": "Dietmar-Hopp-Allee 16", "locality": "Walldorf", "code": "69190", "country": "DE" }] + }, + "customer": { + "name": "Sample Consumer GmbH", + "addresses": [{ "street": "Werner-Heisenberg-Allee 25", "locality": "München", "code": "80939", "country": "DE" }] + }, + "lines": [ + { + "quantity": "10", + "item": { "name": "Development services", "price": "100.00" }, + "taxes": [{ "cat": "VAT", "rate": "general" }] + } + ], + "payment": { + "terms": { "due_dates": [{ "date": "2026-02-15", "percent": "100%" }] } + } +} diff --git a/editor/examples/de-xrechnung.json b/editor/examples/de-xrechnung.json new file mode 100644 index 0000000..9d3fe69 --- /dev/null +++ b/editor/examples/de-xrechnung.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://gobl.org/draft-0/bill/invoice", + "$addons": ["de-xrechnung-v3"], + "series": "SAMPLE", + "code": "001", + "currency": "EUR", + "issue_date": "2026-01-15", + "ordering": { "code": "04011000-12345-34" }, + "supplier": { + "name": "Provide One GmbH", + "tax_id": { "country": "DE", "code": "111111125" }, + "people": [{ "name": { "given": "Max", "surname": "Mustermann" } }], + "inboxes": [{ "email": "billing@example.com" }], + "emails": [{ "addr": "billing@example.com" }], + "telephones": [{ "num": "+49 6227 123456" }], + "addresses": [{ "street": "Dietmar-Hopp-Allee 16", "locality": "Walldorf", "code": "69190", "country": "DE" }] + }, + "customer": { + "name": "Sample Consumer GmbH", + "inboxes": [{ "email": "email@example.com" }], + "addresses": [{ "street": "Werner-Heisenberg-Allee 25", "locality": "München", "code": "80939", "country": "DE" }] + }, + "lines": [ + { + "quantity": "10", + "item": { "name": "Development services", "price": "100.00" }, + "taxes": [{ "cat": "VAT", "rate": "general" }] + } + ], + "payment": { + "terms": { "due_dates": [{ "date": "2026-02-15", "percent": "100%" }] }, + "instructions": { + "key": "credit-transfer", + "credit_transfer": [{ "iban": "DE89370400440532013000" }] + } + } +} diff --git a/editor/examples/de-zugferd.json b/editor/examples/de-zugferd.json new file mode 100644 index 0000000..7fd23d2 --- /dev/null +++ b/editor/examples/de-zugferd.json @@ -0,0 +1,27 @@ +{ + "$schema": "https://gobl.org/draft-0/bill/invoice", + "$addons": ["de-zugferd-v2"], + "series": "SAMPLE", + "code": "001", + "currency": "EUR", + "issue_date": "2026-01-15", + "supplier": { + "name": "Provide One GmbH", + "tax_id": { "country": "DE", "code": "111111125" }, + "addresses": [{ "street": "Dietmar-Hopp-Allee 16", "locality": "Walldorf", "code": "69190", "country": "DE" }] + }, + "customer": { + "name": "Sample Consumer GmbH", + "addresses": [{ "street": "Werner-Heisenberg-Allee 25", "locality": "München", "code": "80939", "country": "DE" }] + }, + "lines": [ + { + "quantity": "10", + "item": { "name": "Development services", "price": "100.00" }, + "taxes": [{ "cat": "VAT", "rate": "general" }] + } + ], + "payment": { + "terms": { "due_dates": [{ "date": "2026-02-15", "percent": "100%" }] } + } +} diff --git a/editor/examples/es-verifactu.json b/editor/examples/es-verifactu.json new file mode 100644 index 0000000..5d4fc0b --- /dev/null +++ b/editor/examples/es-verifactu.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://gobl.org/draft-0/bill/invoice", + "$addons": ["es-verifactu-v1"], + "series": "SAMPLE", + "code": "001", + "currency": "EUR", + "issue_date": "2026-01-15", + "supplier": { + "name": "Provide One S.L.", + "tax_id": { "country": "ES", "code": "B98602642" } + }, + "customer": { + "name": "Sample Consumer S.L.", + "tax_id": { "country": "ES", "code": "B63272603" } + }, + "lines": [ + { + "quantity": "10", + "item": { "name": "Development services", "price": "100.00" }, + "taxes": [{ "cat": "VAT", "rate": "general" }] + } + ] +} diff --git a/editor/examples/es.json b/editor/examples/es.json new file mode 100644 index 0000000..7128db1 --- /dev/null +++ b/editor/examples/es.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://gobl.org/draft-0/bill/invoice", + "currency": "EUR", + "issue_date": "2026-01-15", + "supplier": { + "name": "Provide One S.L.", + "tax_id": { "country": "ES", "code": "B98602642" } + }, + "customer": { + "name": "Sample Consumer S.L.", + "tax_id": { "country": "ES", "code": "B63272603" } + }, + "lines": [ + { + "quantity": "10", + "item": { "name": "Development services", "price": "100.00" }, + "taxes": [{ "cat": "VAT", "rate": "general" }] + } + ] +} diff --git a/editor/examples/fr-choruspro.json b/editor/examples/fr-choruspro.json new file mode 100644 index 0000000..14eb297 --- /dev/null +++ b/editor/examples/fr-choruspro.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://gobl.org/draft-0/bill/invoice", + "$addons": ["fr-choruspro-v1"], + "series": "SAMPLE", + "code": "001", + "currency": "EUR", + "issue_date": "2026-01-15", + "supplier": { + "name": "Provide One SAS", + "tax_id": { "country": "FR", "code": "44732829320" }, + "identities": [{ "type": "SIRET", "code": "73282932000074" }], + "addresses": [{ "street": "1 Rue de Rivoli", "locality": "Paris", "code": "75001", "country": "FR" }] + }, + "customer": { + "name": "Sample Consumer SARL", + "identities": [{ "type": "SIRET", "code": "35600000000048" }], + "addresses": [{ "street": "10 Place Bellecour", "locality": "Lyon", "code": "69002", "country": "FR" }] + }, + "lines": [ + { + "quantity": "10", + "item": { "name": "Prestations de développement", "price": "100.00" }, + "taxes": [{ "cat": "VAT", "rate": "general" }] + } + ], + "payment": { + "terms": { "due_dates": [{ "date": "2026-02-15", "percent": "100%" }] } + } +} diff --git a/editor/examples/fr-ctc.json b/editor/examples/fr-ctc.json new file mode 100644 index 0000000..ef7a05f --- /dev/null +++ b/editor/examples/fr-ctc.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://gobl.org/draft-0/bill/invoice", + "$addons": ["fr-ctc-flow2-v1"], + "series": "SAMPLE", + "code": "001", + "currency": "EUR", + "issue_date": "2026-01-15", + "supplier": { + "name": "Provide One SAS", + "tax_id": { "country": "FR", "code": "44732829320" }, + "identities": [{ "scope": "legal", "type": "SIREN", "code": "732829320", "ext": { "iso-scheme-id": "0002" } }], + "endpoints": [{ "uri": "iso6523-actorid-upis::0225:732829320" }], + "addresses": [{ "street": "1 Rue de Rivoli", "locality": "Paris", "code": "75001", "country": "FR" }] + }, + "customer": { + "name": "Sample Consumer SARL", + "identities": [{ "scope": "legal", "type": "SIREN", "code": "356000000", "ext": { "iso-scheme-id": "0002" } }], + "endpoints": [{ "uri": "iso6523-actorid-upis::0225:356000000" }], + "addresses": [{ "street": "10 Place Bellecour", "locality": "Lyon", "code": "69002", "country": "FR" }] + }, + "lines": [ + { + "quantity": "10", + "item": { "name": "Prestations de développement", "price": "100.00" }, + "taxes": [{ "cat": "VAT", "rate": "general" }] + } + ], + "payment": { + "terms": { "due_dates": [{ "date": "2026-02-15", "percent": "100%" }] } + } +} diff --git a/editor/examples/fr-en16931.json b/editor/examples/fr-en16931.json new file mode 100644 index 0000000..721daa7 --- /dev/null +++ b/editor/examples/fr-en16931.json @@ -0,0 +1,27 @@ +{ + "$schema": "https://gobl.org/draft-0/bill/invoice", + "$addons": ["eu-en16931-v2017"], + "series": "SAMPLE", + "code": "001", + "currency": "EUR", + "issue_date": "2026-01-15", + "supplier": { + "name": "Provide One SAS", + "tax_id": { "country": "FR", "code": "44732829320" }, + "addresses": [{ "street": "1 Rue de Rivoli", "locality": "Paris", "code": "75001", "country": "FR" }] + }, + "customer": { + "name": "Sample Consumer SARL", + "addresses": [{ "street": "10 Place Bellecour", "locality": "Lyon", "code": "69002", "country": "FR" }] + }, + "lines": [ + { + "quantity": "10", + "item": { "name": "Prestations de développement", "price": "100.00" }, + "taxes": [{ "cat": "VAT", "rate": "general" }] + } + ], + "payment": { + "terms": { "due_dates": [{ "date": "2026-02-15", "percent": "100%" }] } + } +} diff --git a/editor/examples/fr-facturx.json b/editor/examples/fr-facturx.json new file mode 100644 index 0000000..475941e --- /dev/null +++ b/editor/examples/fr-facturx.json @@ -0,0 +1,27 @@ +{ + "$schema": "https://gobl.org/draft-0/bill/invoice", + "$addons": ["fr-facturx-v1"], + "series": "SAMPLE", + "code": "001", + "currency": "EUR", + "issue_date": "2026-01-15", + "supplier": { + "name": "Provide One SAS", + "tax_id": { "country": "FR", "code": "44732829320" }, + "addresses": [{ "street": "1 Rue de Rivoli", "locality": "Paris", "code": "75001", "country": "FR" }] + }, + "customer": { + "name": "Sample Consumer SARL", + "addresses": [{ "street": "10 Place Bellecour", "locality": "Lyon", "code": "69002", "country": "FR" }] + }, + "lines": [ + { + "quantity": "10", + "item": { "name": "Prestations de développement", "price": "100.00" }, + "taxes": [{ "cat": "VAT", "rate": "general" }] + } + ], + "payment": { + "terms": { "due_dates": [{ "date": "2026-02-15", "percent": "100%" }] } + } +} diff --git a/editor/examples/gb.json b/editor/examples/gb.json new file mode 100644 index 0000000..ffbaa1f --- /dev/null +++ b/editor/examples/gb.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://gobl.org/draft-0/bill/invoice", + "currency": "GBP", + "issue_date": "2026-01-15", + "supplier": { + "name": "Provide One Ltd.", + "tax_id": { "country": "GB", "code": "350983637" } + }, + "customer": { + "name": "Sample Consumer Ltd." + }, + "lines": [ + { + "quantity": "10", + "item": { "name": "Development services", "price": "100.00" }, + "taxes": [{ "cat": "VAT", "rate": "standard" }] + } + ] +} diff --git a/editor/examples/it-sdi.json b/editor/examples/it-sdi.json new file mode 100644 index 0000000..6769797 --- /dev/null +++ b/editor/examples/it-sdi.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://gobl.org/draft-0/bill/invoice", + "$addons": ["it-sdi-v1"], + "series": "SAMPLE", + "code": "001", + "currency": "EUR", + "issue_date": "2026-01-15", + "supplier": { + "name": "Provide One S.r.l.", + "tax_id": { "country": "IT", "code": "12345678903" }, + "addresses": [{ "street": "Via dei Mille", "num": "1", "locality": "Roma", "region": "RM", "code": "00100", "country": "IT" }] + }, + "customer": { + "name": "Sample Consumer S.r.l.", + "tax_id": { "country": "IT", "code": "13029381004" }, + "addresses": [{ "street": "Via Roma", "num": "10", "locality": "Milano", "region": "MI", "code": "20100", "country": "IT" }] + }, + "lines": [ + { + "quantity": "10", + "item": { "name": "Servizi di sviluppo", "price": "100.00" }, + "taxes": [{ "cat": "VAT", "rate": "general" }] + } + ] +} diff --git a/editor/examples/mx-cfdi.json b/editor/examples/mx-cfdi.json new file mode 100644 index 0000000..04d1d82 --- /dev/null +++ b/editor/examples/mx-cfdi.json @@ -0,0 +1,27 @@ +{ + "$schema": "https://gobl.org/draft-0/bill/invoice", + "$addons": ["mx-cfdi-v4"], + "series": "SAMPLE", + "code": "001", + "currency": "MXN", + "issue_date": "2026-01-15", + "supplier": { + "name": "ESCUELA KEMPER URGATE", + "tax_id": { "country": "MX", "code": "EKU9003173C9" }, + "ext": { "mx-cfdi-fiscal-regime": "601" }, + "addresses": [{ "code": "42501" }] + }, + "customer": { + "name": "UNIVERSIDAD ROBOTICA ESPAÑOLA", + "tax_id": { "country": "MX", "code": "URE180429TM6" }, + "ext": { "mx-cfdi-fiscal-regime": "601", "mx-cfdi-use": "G01" }, + "addresses": [{ "code": "65000" }] + }, + "lines": [ + { + "quantity": "10", + "item": { "name": "Servicios de desarrollo", "price": "100.00", "ext": { "mx-cfdi-prod-serv": "81111500" } }, + "taxes": [{ "cat": "VAT", "rate": "general" }] + } + ] +} diff --git a/editor/examples/nl-en16931.json b/editor/examples/nl-en16931.json new file mode 100644 index 0000000..bcf8a33 --- /dev/null +++ b/editor/examples/nl-en16931.json @@ -0,0 +1,27 @@ +{ + "$schema": "https://gobl.org/draft-0/bill/invoice", + "$addons": ["eu-en16931-v2017"], + "series": "SAMPLE", + "code": "001", + "currency": "EUR", + "issue_date": "2026-01-15", + "supplier": { + "name": "Provide One B.V.", + "tax_id": { "country": "NL", "code": "000099998B57" }, + "addresses": [{ "street": "Damrak 1", "locality": "Amsterdam", "code": "1012 LG", "country": "NL" }] + }, + "customer": { + "name": "Sample Consumer B.V.", + "addresses": [{ "street": "Coolsingel 40", "locality": "Rotterdam", "code": "3011 AD", "country": "NL" }] + }, + "lines": [ + { + "quantity": "10", + "item": { "name": "Development services", "price": "100.00" }, + "taxes": [{ "cat": "VAT", "rate": "general" }] + } + ], + "payment": { + "terms": { "due_dates": [{ "date": "2026-02-15", "percent": "100%" }] } + } +} diff --git a/editor/examples/pt-saft.json b/editor/examples/pt-saft.json new file mode 100644 index 0000000..e255a8e --- /dev/null +++ b/editor/examples/pt-saft.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://gobl.org/draft-0/bill/invoice", + "$addons": ["pt-saft-v1"], + "series": "FT SAMPLE", + "code": "1", + "currency": "EUR", + "issue_date": "2026-01-15", + "supplier": { + "name": "Provide One Lda.", + "tax_id": { "country": "PT", "code": "545259045" } + }, + "customer": { + "name": "Sample Consumer" + }, + "lines": [ + { + "quantity": "10", + "item": { "name": "Serviços de desenvolvimento", "price": "100.00" }, + "taxes": [{ "cat": "VAT", "rate": "general" }] + } + ] +} diff --git a/editor/examples/sa-zatca.json b/editor/examples/sa-zatca.json new file mode 100644 index 0000000..25bac82 --- /dev/null +++ b/editor/examples/sa-zatca.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://gobl.org/draft-0/bill/invoice", + "$addons": ["sa-zatca-v1"], + "series": "SAMPLE", + "code": "001", + "currency": "SAR", + "issue_date": "2026-01-15", + "supplier": { + "name": "Provide One LLC", + "tax_id": { "country": "SA", "code": "312345678912343" }, + "identities": [{ "type": "CRN", "code": "1234567890" }], + "addresses": [{ "num": "1234", "street": "King Fahd Road", "street_extra": "Al Olaya", "locality": "Riyadh", "code": "12345", "country": "SA" }] + }, + "customer": { + "name": "Sample Consumer LLC", + "identities": [{ "type": "TIN", "code": "123456789012345" }], + "addresses": [{ "num": "5678", "street": "Olaya Street", "street_extra": "Al Malaz", "locality": "Riyadh", "code": "54321", "country": "SA" }] + }, + "lines": [ + { + "quantity": "10", + "item": { "name": "Development services", "price": "100.00" }, + "taxes": [{ "cat": "VAT", "rate": "general" }] + } + ], + "payment": { + "terms": { "due_dates": [{ "date": "2026-02-15", "percent": "100%" }] } + }, + "delivery": { "date": "2026-01-15" } +} diff --git a/editor/examples/us.json b/editor/examples/us.json new file mode 100644 index 0000000..ebefb2b --- /dev/null +++ b/editor/examples/us.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://gobl.org/draft-0/bill/invoice", + "currency": "USD", + "issue_date": "2026-01-15", + "supplier": { + "name": "Acme Inc.", + "tax_id": { "country": "US" } + }, + "customer": { + "name": "Sample Customer" + }, + "lines": [ + { + "quantity": "10", + "item": { "name": "Development services", "price": "100.00" }, + "taxes": [{ "cat": "ST", "percent": "8.25%" }] + } + ] +} diff --git a/editor/examples_test.go b/editor/examples_test.go new file mode 100644 index 0000000..b12ffe8 --- /dev/null +++ b/editor/examples_test.go @@ -0,0 +1,119 @@ +package editor + +import ( + "bytes" + "context" + "testing" + + "github.com/invopop/gobl.dev/internal/ops" + "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/convert" + "github.com/invopop/gobl/schema" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestExamples(t *testing.T) { + list := examples() + require.NotEmpty(t, list) + ids := make([]string, len(list)) + for i, ex := range list { + ids[i] = ex.ID + } + assert.Contains(t, ids, defaultExample) + + for _, ex := range list { + t.Run(ex.ID, func(t *testing.T) { + assert.NotEmpty(t, ex.Region) + assert.NotEmpty(t, ex.Label) + assert.Greater(t, len(ex.Region), 2, "region name should come from the regime") + + _, err := ops.Build(context.Background(), &ops.BuildOptions{ + ParseOptions: &ops.ParseOptions{Input: bytes.NewReader(ex.Doc)}, + }) + require.NoError(t, err) + + // Every format that accepts the example converts it. + for _, key := range ex.Formats { + _, err := ops.Export(context.Background(), &ops.ExportOptions{ + ParseOptions: &ops.ParseOptions{Input: bytes.NewReader(ex.Doc)}, + Formats: []cbc.Key{cbc.Key(key)}, + }) + assert.NoError(t, err, "export to %s", key) + } + }) + } +} + +// TestExamplesCoverFormats ensures every format that exports invoices is +// accepted by at least one example, so all of them can be tried out in the +// editor. +func TestExamplesCoverFormats(t *testing.T) { + covered := make(map[string]bool) + for _, ex := range examples() { + for _, k := range ex.Formats { + covered[k] = true + } + } + invoice := schema.Lookup(bill.Invoice{}) + for _, f := range convert.Formats() { + for _, s := range f.Export { + if s == invoice { + assert.True(t, covered[f.Key.String()], "no example is accepted by %s", f.Key) + } + } + } +} + +func TestExampleFormatPreference(t *testing.T) { + preferred := map[string]string{ + "de-xrechnung": "cii+de-xrechnung-v3", + "fr-facturx": "cii+fr-facturx-v1", + "sa-zatca": "ubl+sa-zatca-v1", + "de-en16931": "cii+en16931-v2017", + } + for _, ex := range examples() { + if want, ok := preferred[ex.ID]; ok { + require.NotEmpty(t, ex.Formats, ex.ID) + assert.Equal(t, want, ex.Formats[0], ex.ID) + } + } +} + +// TestExampleSamples ensures every sample offered for import is detected as +// the format it was exported into, so importing it is faithful. +func TestExampleSamples(t *testing.T) { + for _, ex := range examples() { + if ex.Sample == "" { + continue + } + t.Run(ex.ID, func(t *testing.T) { + out, err := ops.Export(context.Background(), &ops.ExportOptions{ + ParseOptions: &ops.ParseOptions{Input: bytes.NewReader(ex.Doc)}, + Formats: []cbc.Key{cbc.Key(ex.Sample)}, + }) + require.NoError(t, err) + _, f, err := ops.Import(context.Background(), &ops.ImportOptions{Input: bytes.NewReader(out.Data)}) + require.NoError(t, err) + assert.Equal(t, ex.Sample, f.Key.String()) + }) + } + samples := map[string]string{ + "de-zugferd": "cii+de-zugferd-v2+basic", + "fr-facturx": "cii+fr-facturx-v1+basic", + "sa-zatca": "ubl+sa-zatca-v1", + } + for _, ex := range examples() { + if want, ok := samples[ex.ID]; ok { + assert.Equal(t, want, ex.Sample, ex.ID) + } + } +} + +func TestExamplesOrder(t *testing.T) { + list := examples() + for i := 1; i < len(list); i++ { + assert.LessOrEqual(t, list[i-1].Region, list[i].Region) + } +} diff --git a/editor/formats.go b/editor/formats.go new file mode 100644 index 0000000..f1ea19c --- /dev/null +++ b/editor/formats.go @@ -0,0 +1,71 @@ +package editor + +import ( + "bytes" + "context" + "slices" + + "github.com/invopop/gobl.dev/internal/ops" + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/convert" + "github.com/invopop/gobl/i18n" + "github.com/invopop/gobl/schema" +) + +// formatLabels names the formats registered with GOBL's convert package +// that documents can be exported into, by key. Formats that cannot be +// imported, as their documents cannot be told apart from another format's, +// are marked as such. +func formatLabels() map[string]string { + labels := make(map[string]string) + for _, f := range convert.Formats() { + if len(f.Export) == 0 { + continue + } + label := f.Name.In(i18n.EN) + if label == "" { + label = f.Key.String() + } + if len(f.Import) == 0 { + label += " (export only)" + } + labels[f.Key.String()] = label + } + return labels +} + +// importable reports whether documents of the schema in the format with the +// key can be imported back into GOBL. +func importable(key string, schemaID schema.ID) bool { + f := convert.FormatFor(cbc.Key(key)) + return f != nil && slices.Contains(f.Import, schemaID) +} + +// acceptedFormats lists the keys of the formats that accept the document for +// export, in registration order. Only the formats' converters can tell, so +// the document is checked against each of them. +func acceptedFormats(doc []byte) ([]string, error) { + checks, err := ops.CheckFormats(context.Background(), &ops.ParseOptions{ + Input: bytes.NewReader(doc), + }) + if err != nil { + return nil, err + } + keys := make([]string, 0) + for _, c := range checks { + if c.Accepted { + keys = append(keys, c.Key.String()) + } + } + return keys, nil +} + +// bootstrap is the initial state rendered into the page as JSON and read by +// the editor's Alpine component. +func bootstrap() map[string]any { + return map[string]any{ + "examples": examples(), + "defaultExample": defaultExample, + "formats": formatLabels(), + } +} diff --git a/editor/formats_test.go b/editor/formats_test.go new file mode 100644 index 0000000..c6f4dde --- /dev/null +++ b/editor/formats_test.go @@ -0,0 +1,32 @@ +package editor + +import ( + "testing" + + _ "github.com/invopop/gobl.dev/bundle" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFormatLabels(t *testing.T) { + labels := formatLabels() + assert.Equal(t, "UBL EN 16931:2017", labels["ubl+en16931-v2017"]) + assert.Equal(t, "CII XRechnung 3", labels["cii+de-xrechnung-v3"]) + assert.NotContains(t, labels, "ubl", "import-only formats are not offered") + assert.Equal(t, "CII Factur-X EN 16931 (export only)", labels["cii+fr-facturx-v1"]) +} + +func TestAcceptedFormats(t *testing.T) { + keys, err := acceptedFormats([]byte(`{ + "$schema": "https://gobl.org/draft-0/bill/invoice", + "currency": "USD", + "supplier": {"name": "Acme Inc.", "tax_id": {"country": "US"}}, + "customer": {"name": "Sample Customer"}, + "lines": [{"quantity": "1", "item": {"name": "Test", "price": "10.00"}}] + }`)) + require.NoError(t, err) + assert.Empty(t, keys, "formats need addons the document lacks") + + _, err = acceptedFormats([]byte(`{"$schema": "https://gobl.org/draft-0/bill/invoice"}`)) + assert.Error(t, err, "the document must build") +} diff --git a/go.mod b/go.mod index 4abe515..658452e 100644 --- a/go.mod +++ b/go.mod @@ -1,29 +1,34 @@ module github.com/invopop/gobl.dev -go 1.25.0 +go 1.26.1 require ( github.com/a-h/templ v0.3.1001 github.com/google/go-cmp v0.7.0 github.com/imdario/mergo v0.3.16 - github.com/invopop/gobl v0.507.0 + github.com/invopop/gobl v0.508.1 + github.com/invopop/gobl.ar.arca v0.0.3 github.com/invopop/gobl.br.nfe v0.0.4 github.com/invopop/gobl.br.nfse v0.0.2 + github.com/invopop/gobl.cii v0.73.0 + github.com/invopop/gobl.co.dian v0.0.3 + github.com/invopop/gobl.de.xinvoice v0.0.7 github.com/invopop/gobl.dk.oioubl v0.0.9 - github.com/invopop/gobl.fi.finvoice v0.0.2 - github.com/invopop/gobl.fr.ctc v0.0.8 + github.com/invopop/gobl.fi.finvoice v0.0.6 + github.com/invopop/gobl.fr.ctc v0.0.10 github.com/invopop/gobl.it.sdi v0.85.0 - github.com/invopop/gobl.mx.cfdi v0.64.0 - github.com/invopop/gobl.pl.ksef v0.45.0 - github.com/invopop/gobl.pt.saft v0.0.8 - github.com/invopop/gobl.sa.zatca v0.0.4 + github.com/invopop/gobl.mx.cfdi v0.68.0 + github.com/invopop/gobl.pl.ksef v0.47.0 + github.com/invopop/gobl.pt.saft v0.0.9 + github.com/invopop/gobl.sa.zatca v0.0.6 + github.com/invopop/gobl.ubl v0.90.0 github.com/invopop/icons v0.14.0 github.com/invopop/popui.go v0.30.0 github.com/invopop/yaml v0.3.1 github.com/magefile/mage v1.17.2 github.com/mark3labs/mcp-go v0.46.0 github.com/spf13/cobra v1.9.1 - github.com/stretchr/testify v1.11.1 + github.com/stretchr/testify v1.12.1 gitlab.com/flimzy/testy v0.15.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -34,6 +39,7 @@ require ( github.com/Oudwins/tailwind-merge-go v0.2.1 // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/beevik/etree v1.6.0 // indirect github.com/buger/jsonparser v1.1.2 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/expr-lang/expr v1.17.8 // indirect @@ -42,6 +48,10 @@ require ( github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/invopop/jsonschema v0.14.0 // indirect + github.com/invopop/validation v0.8.0 // indirect + github.com/invopop/xmlctx v0.13.0 // indirect + github.com/invopop/xmldsig v0.14.0 // indirect + github.com/jonboulle/clockwork v0.5.0 // indirect github.com/labstack/echo/v4 v4.15.3 // indirect github.com/labstack/gommon v0.5.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect @@ -49,14 +59,17 @@ require ( github.com/pb33f/ordered-map/v2 v2.3.1 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/russellhaering/goxmldsig v1.6.0 // indirect github.com/spf13/cast v1.7.1 // indirect github.com/spf13/pflag v1.0.6 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect go.yaml.in/yaml/v4 v4.0.0-rc.2 // indirect golang.org/x/crypto v0.53.0 // indirect golang.org/x/net v0.56.0 // indirect golang.org/x/sys v0.46.0 // indirect - golang.org/x/text v0.38.0 // indirect + golang.org/x/text v0.41.0 // indirect + software.sslmate.com/src/go-pkcs12 v0.7.2 // indirect ) diff --git a/go.sum b/go.sum index 506454e..f1d4b41 100644 --- a/go.sum +++ b/go.sum @@ -12,6 +12,8 @@ github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3d github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/beevik/etree v1.6.0 h1:u8Kwy8pp9D9XeITj2Z0XtA5qqZEmtJtuXZRQi+j03eE= +github.com/beevik/etree v1.6.0/go.mod h1:bh4zJxiIr62SOf9pRzN7UUYaEDa9HEKafK25+sLc0Gc= github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk= github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= @@ -33,36 +35,56 @@ github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4= github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/invopop/gobl v0.507.0 h1:vg4Ohs8LAaDC/1YN9n4xOZrmQ83T7k98aUlw8HlG4nc= -github.com/invopop/gobl v0.507.0/go.mod h1:HmiEdQreTSQYyNbhs81VKTmI7BAJKYC/6enh9RDwnE0= +github.com/invopop/gobl v0.508.1 h1:erhNlKngV1Lota9yhvwc80vNWZRJ9matra3gE8brQ28= +github.com/invopop/gobl v0.508.1/go.mod h1:HmiEdQreTSQYyNbhs81VKTmI7BAJKYC/6enh9RDwnE0= +github.com/invopop/gobl.ar.arca v0.0.3 h1:nf2nLHMP5G++N2LqxBXc56+G2jk1MlF8XWNFK3OSa0Q= +github.com/invopop/gobl.ar.arca v0.0.3/go.mod h1:Q1c3unwx2Kin532Il0OPChiaC+32K5GtJtHWhU9I+rg= github.com/invopop/gobl.br.nfe v0.0.4 h1:/RWQEwP8pHej+8u6u/BNeLKjYZ4NyZJRjWGF2cPEZdo= github.com/invopop/gobl.br.nfe v0.0.4/go.mod h1:qXzt4bz9S4+nJRbREb9QEAoO1W9r3Sjy6R207W7IuhY= github.com/invopop/gobl.br.nfse v0.0.2 h1:lhHnDBxs8sYBjWQ+nZ8q5/+6OUwRGPedgjJuxHDQdmI= github.com/invopop/gobl.br.nfse v0.0.2/go.mod h1:rgq6Kw/yaRexA7yf9+sbAI4Ugj+DrlIKsWLxv8K2lAI= +github.com/invopop/gobl.cii v0.73.0 h1:J/P97rHQtoVpw6WqiPiG87Hi2AiSDeBe5s2IPXDEAus= +github.com/invopop/gobl.cii v0.73.0/go.mod h1:U49LgaLLGI89vubj/A76wfXyDnWM5b/HC3j5cgqgyGI= +github.com/invopop/gobl.co.dian v0.0.3 h1:UlMEKZSaL7m/mzRCg9gE4bnMP7HdqKoVtB0TzqCaHZI= +github.com/invopop/gobl.co.dian v0.0.3/go.mod h1:i/bdrT1SnB7KPpJmPLUL0kqcn7OOZibEHIG1uNPXndc= +github.com/invopop/gobl.de.xinvoice v0.0.7 h1:CeCaf37qLiha6QDoBtQ16zLKR02ltIPFLw6ifqd7auc= +github.com/invopop/gobl.de.xinvoice v0.0.7/go.mod h1:IJ3vnhuRjkQ4rkOFycuY80vsACDM4jsle+q4CBewxbk= github.com/invopop/gobl.dk.oioubl v0.0.9 h1:Bi3KXZah0Q9SqZWS57VWulGsHif4itCqtBgzj1LgOZ8= github.com/invopop/gobl.dk.oioubl v0.0.9/go.mod h1:+5kt1RYq0I6g3R8MEFo9qrXJORBy1uVDpNl4CJyQY6E= -github.com/invopop/gobl.fi.finvoice v0.0.2 h1:yOPLBUVNZ8Uk3KEaYXjXTzqy4HV3xdyT4vkrp4ydtYM= -github.com/invopop/gobl.fi.finvoice v0.0.2/go.mod h1:3NenHpVprZgry3em/42ddMBxTfngCCjpiKePAxoejbE= -github.com/invopop/gobl.fr.ctc v0.0.8 h1:T4/QTD1qfHdACEeZSw6jMkK8U0eiwpaJXv1QZs6n6dc= -github.com/invopop/gobl.fr.ctc v0.0.8/go.mod h1:S7D4Oz8cvKJ5usaY9Esjf6efx4gfDaC51/s0y9UVUF4= +github.com/invopop/gobl.fi.finvoice v0.0.6 h1:+H45L88fzfqFUX4pHFgcwHFbIVsIaTDrtmS/Ci+kczw= +github.com/invopop/gobl.fi.finvoice v0.0.6/go.mod h1:zqLWHPlMDy/aDKslgmL4yq47RX55w54x7DS7VdZxaQM= +github.com/invopop/gobl.fr.ctc v0.0.10 h1:n3FDbk0vp5NiK1mCZ1sa7Ojdh0o2Nl6FFd6uA+B8Fcc= +github.com/invopop/gobl.fr.ctc v0.0.10/go.mod h1:gZt1YlUiWN6KX2Z9/ave16wOAKkn0ZsiVm9gT+Nv+Qc= github.com/invopop/gobl.it.sdi v0.85.0 h1:J1jtYcYxn5Oot5VVD9RtaOQGrqhF3mkHX3ihle1HibM= github.com/invopop/gobl.it.sdi v0.85.0/go.mod h1:+SfnlFTPjKSZZBrKEzVlrFePukufGV2AMSA5RFoWGEw= -github.com/invopop/gobl.mx.cfdi v0.64.0 h1:JThygwAHB9TyR9seSrJepmfE77i51DLYDUKLOaJBZyU= -github.com/invopop/gobl.mx.cfdi v0.64.0/go.mod h1:Y0T5b3cruP4kx0IV/KHEhNVpm8O0FYbSCHXaBMWXKzQ= -github.com/invopop/gobl.pl.ksef v0.45.0 h1:XlK33Geb9aDG0G5PyCGa0Rp2JCcLDWq7JuiJEpMVEpA= -github.com/invopop/gobl.pl.ksef v0.45.0/go.mod h1:aAbp+2Z7kBosREe/kMzfW2Fm6Dud7E0LwvK7tFY1QF4= -github.com/invopop/gobl.pt.saft v0.0.8 h1:cIR2MV964p1CAAWOptUBDIk1xf3MEPrLqI7a5iwA6cg= -github.com/invopop/gobl.pt.saft v0.0.8/go.mod h1:jFAvKmLpSw566qvafNFbkvHdZz1ZjSjNgQvgcX5Gdxk= -github.com/invopop/gobl.sa.zatca v0.0.4 h1:0BQKJdPazvdXCRosSU5pkQKCaexGQBATXEVqbAMajYk= -github.com/invopop/gobl.sa.zatca v0.0.4/go.mod h1:tmLCoaKb4X7sZ2zRU8LalA1sgxM2kQv/uqMzZslhE6A= +github.com/invopop/gobl.mx.cfdi v0.68.0 h1:pRfKanQ7OAcRKIb/QnHTOtvXnFelSGjfP/DoUJ9Dl+s= +github.com/invopop/gobl.mx.cfdi v0.68.0/go.mod h1:Q4a7D39cGS5wKGF5LOnVAvKr+Cx3Yfge8+oNd3XIVv0= +github.com/invopop/gobl.pl.ksef v0.47.0 h1:uVYeu1u93BBWOFo0riFh6uNRhIf0xpOoaOiNQw51zxY= +github.com/invopop/gobl.pl.ksef v0.47.0/go.mod h1:aAbp+2Z7kBosREe/kMzfW2Fm6Dud7E0LwvK7tFY1QF4= +github.com/invopop/gobl.pt.saft v0.0.9 h1:muphsEldIN4Fp5U3Vx1wPger6bKkeR+mKHcGye6KHJo= +github.com/invopop/gobl.pt.saft v0.0.9/go.mod h1:1fXYKp8e0OH+HqAIeuNylfSaLVTyN2EOnDuzxeyQK40= +github.com/invopop/gobl.sa.zatca v0.0.6 h1:Me+scq66q8BJsySyTeqsNRVFp0cBWRLwVt3tliiUqbg= +github.com/invopop/gobl.sa.zatca v0.0.6/go.mod h1:O3awa6nyf8L4GwAbVhNOiThSw2nsklugsPOHhr6Y0xY= +github.com/invopop/gobl.ubl v0.90.0 h1:/E5f3g+VarhupDzkTGUwQGtS+CWU1ZK7sKWe8NZ1Udc= +github.com/invopop/gobl.ubl v0.90.0/go.mod h1:qEi6j0PkdT0RG1ap3CKjeQevOBInCU/B8fqhF4OW+JE= github.com/invopop/icons v0.14.0 h1:Bk0hF+EI/x3XwWX1IqMeFDMdu766zY3zFBqTkImCdhU= github.com/invopop/icons v0.14.0/go.mod h1:rXrrY2Rz7Z7KINyQIPMaauqXTZNhVMDI2MYIiux436Y= github.com/invopop/jsonschema v0.14.0 h1:MHQqLhvpNUZfw+hM3AZDYK7jxO8FZoQeQM77g8iyZjg= github.com/invopop/jsonschema v0.14.0/go.mod h1:ygm6C2EaVNMBDPpaPlnOA2pFAxBnxGjFlMZABxm9n2I= +github.com/invopop/phorm v0.1.5 h1:qzhDJgo1YaRFTRknD58cLbuTp5UAV/PhQTD9FFK0LIM= +github.com/invopop/phorm v0.1.5/go.mod h1:7GJMH9RwreocY9NX36ZnhmMcS/qE/NY4+Ro+YFdFHR8= github.com/invopop/popui.go v0.30.0 h1:Yx91iSDm7HW2xvGUQCdrRm+HmK1RuA7jxn26+EDbCTg= github.com/invopop/popui.go v0.30.0/go.mod h1:2YoksZOz2hkSuSF+F2FUJVlnviPlAwmvu/UpD2hJww8= +github.com/invopop/validation v0.8.0 h1:e5hXHGnONHImgJdonIpNbctg1hlWy1ncaHoVIQ0JWuw= +github.com/invopop/validation v0.8.0/go.mod h1:nLLeXYPGwUNfdCdJo7/q3yaHO62LSx/3ri7JvgKR9vg= +github.com/invopop/xmlctx v0.13.0 h1:ZNRMC0O/A5h8InoLVSA7tIjjrhJn/NDBYfByBUpSb+g= +github.com/invopop/xmlctx v0.13.0/go.mod h1:xZ3Bdf0jq2GcjN6QNroUa+l37kyXDXmDEEKiBj9NAl0= +github.com/invopop/xmldsig v0.14.0 h1:ROwf32DZtX2EekrrOSjLLiY0s2kPEcqHZculITlZfAw= +github.com/invopop/xmldsig v0.14.0/go.mod h1:oWDotOqdKbbwmfA1B057TVsNZW/V1XgoXsRgrW5by18= github.com/invopop/yaml v0.3.1 h1:f0+ZpmhfBSS4MhG+4HYseMdJhoeeopbSKbq5Rpeelso= github.com/invopop/yaml v0.3.1/go.mod h1:PMOp3nn4/12yEZUFfmOuNHJsZToEEOwoWsT+D81KkeA= +github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I= +github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -87,6 +109,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/russellhaering/goxmldsig v1.6.0 h1:8fdWXEPh2k/NZNQBPFNoVfS3JmzS4ZprY/sAOpKQLks= +github.com/russellhaering/goxmldsig v1.6.0/go.mod h1:TrnaquDcYxWXfJrOjeMBTX4mLBeYAqaHEyUeWPxZlBM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= @@ -94,8 +118,8 @@ github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -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/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= @@ -104,6 +128,8 @@ github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zI github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= gitlab.com/flimzy/testy v0.15.0 h1:69TL12IpxqGUyL8NuRV3Z5OhIDszXLNqLtfBDhOV3ys= gitlab.com/flimzy/testy v0.15.0/go.mod h1:KbAJWCwB++0hEFzeeQRbC7vdZYP/yEha94s4X1wVFrw= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= go.yaml.in/yaml/v4 v4.0.0-rc.2 h1:/FrI8D64VSr4HtGIlUtlFMGsm7H7pWTbj6vOLVZcA6s= go.yaml.in/yaml/v4 v4.0.0-rc.2/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= @@ -112,10 +138,12 @@ golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= -golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +software.sslmate.com/src/go-pkcs12 v0.7.2 h1:Rh9FoMaI5k7Oo6EOS+2/BnoZ+JFIS+XHjM0VGkSPXLM= +software.sslmate.com/src/go-pkcs12 v0.7.2/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= diff --git a/internal/mcp/resources_test.go b/internal/mcp/resources_test.go index 278e7d0..f386ae3 100644 --- a/internal/mcp/resources_test.go +++ b/internal/mcp/resources_test.go @@ -235,12 +235,6 @@ func TestListDataDir(t *testing.T) { assert.Contains(t, items, "es") }) - t.Run("addons", func(t *testing.T) { - items, err := listDataDir("addons") - require.NoError(t, err) - assert.NotEmpty(t, items) - }) - t.Run("nonexistent dir", func(t *testing.T) { _, err := listDataDir("nonexistent") require.Error(t, err) diff --git a/internal/ops/bulk_test.go b/internal/ops/bulk_test.go index 10f918b..5221f56 100644 --- a/internal/ops/bulk_test.go +++ b/internal/ops/bulk_test.go @@ -618,7 +618,7 @@ func TestBulk(t *testing.T) { //nolint:gocyclo // Following raw message is copied and pasted! (sorry!) Payload: json.RawMessage(`{ "list": [ - "https://gobl.org/draft-0/bill/action", "https://gobl.org/draft-0/bill/charge", "https://gobl.org/draft-0/bill/correction-options", "https://gobl.org/draft-0/bill/delivery", "https://gobl.org/draft-0/bill/delivery-details", "https://gobl.org/draft-0/bill/discount", "https://gobl.org/draft-0/bill/fault", "https://gobl.org/draft-0/bill/invoice", "https://gobl.org/draft-0/bill/line", "https://gobl.org/draft-0/bill/order", "https://gobl.org/draft-0/bill/ordering", "https://gobl.org/draft-0/bill/payment", "https://gobl.org/draft-0/bill/payment-details", "https://gobl.org/draft-0/bill/reason", "https://gobl.org/draft-0/bill/status", "https://gobl.org/draft-0/bill/status-line", "https://gobl.org/draft-0/bill/tax", "https://gobl.org/draft-0/bill/totals", "https://gobl.org/draft-0/cal/date", "https://gobl.org/draft-0/cal/date-time", "https://gobl.org/draft-0/cal/period", "https://gobl.org/draft-0/cal/time", "https://gobl.org/draft-0/cal/timestamp", "https://gobl.org/draft-0/cbc/code", "https://gobl.org/draft-0/cbc/code-map", "https://gobl.org/draft-0/cbc/definition", "https://gobl.org/draft-0/cbc/key", "https://gobl.org/draft-0/cbc/meta", "https://gobl.org/draft-0/cbc/source", "https://gobl.org/draft-0/cbc/uri", "https://gobl.org/draft-0/currency/amount", "https://gobl.org/draft-0/currency/code", "https://gobl.org/draft-0/currency/exchange-rate", "https://gobl.org/draft-0/dsig/digest", "https://gobl.org/draft-0/dsig/signature", "https://gobl.org/draft-0/envelope", "https://gobl.org/draft-0/head/header", "https://gobl.org/draft-0/head/link", "https://gobl.org/draft-0/head/stamp", "https://gobl.org/draft-0/i18n/string", "https://gobl.org/draft-0/l10n/code", "https://gobl.org/draft-0/l10n/iso-country-code", "https://gobl.org/draft-0/l10n/tax-country-code", "https://gobl.org/draft-0/note/message", "https://gobl.org/draft-0/num/amount", "https://gobl.org/draft-0/num/percentage", "https://gobl.org/draft-0/org/address", "https://gobl.org/draft-0/org/attachment", "https://gobl.org/draft-0/org/attribute", "https://gobl.org/draft-0/org/coordinates", "https://gobl.org/draft-0/org/document-ref", "https://gobl.org/draft-0/org/email", "https://gobl.org/draft-0/org/endpoint", "https://gobl.org/draft-0/org/identity", "https://gobl.org/draft-0/org/image", "https://gobl.org/draft-0/org/inbox", "https://gobl.org/draft-0/org/item", "https://gobl.org/draft-0/org/name", "https://gobl.org/draft-0/org/note", "https://gobl.org/draft-0/org/party", "https://gobl.org/draft-0/org/person", "https://gobl.org/draft-0/org/registration", "https://gobl.org/draft-0/org/telephone", "https://gobl.org/draft-0/org/website", "https://gobl.org/draft-0/pay/card", "https://gobl.org/draft-0/pay/credit-transfer", "https://gobl.org/draft-0/pay/direct-debit", "https://gobl.org/draft-0/pay/instructions", "https://gobl.org/draft-0/pay/online", "https://gobl.org/draft-0/pay/record", "https://gobl.org/draft-0/pay/terms", "https://gobl.org/draft-0/regimes/mx/food-vouchers", "https://gobl.org/draft-0/regimes/mx/fuel-account-balance", "https://gobl.org/draft-0/schema/object", "https://gobl.org/draft-0/tax/addon-def", "https://gobl.org/draft-0/tax/addon-list", "https://gobl.org/draft-0/tax/catalogue-def", "https://gobl.org/draft-0/tax/correction-definition", "https://gobl.org/draft-0/tax/correction-set", "https://gobl.org/draft-0/tax/extensions", "https://gobl.org/draft-0/tax/identity", "https://gobl.org/draft-0/tax/note", "https://gobl.org/draft-0/tax/regime-code", "https://gobl.org/draft-0/tax/regime-def", "https://gobl.org/draft-0/tax/scenario", "https://gobl.org/draft-0/tax/scenario-set", "https://gobl.org/draft-0/tax/set", "https://gobl.org/draft-0/tax/tag-set", "https://gobl.org/draft-0/tax/total" + "https://gobl.org/draft-0/bill/action", "https://gobl.org/draft-0/bill/charge", "https://gobl.org/draft-0/bill/correction-options", "https://gobl.org/draft-0/bill/delivery", "https://gobl.org/draft-0/bill/delivery-details", "https://gobl.org/draft-0/bill/discount", "https://gobl.org/draft-0/bill/fault", "https://gobl.org/draft-0/bill/invoice", "https://gobl.org/draft-0/bill/line", "https://gobl.org/draft-0/bill/order", "https://gobl.org/draft-0/bill/ordering", "https://gobl.org/draft-0/bill/payment", "https://gobl.org/draft-0/bill/payment-details", "https://gobl.org/draft-0/bill/reason", "https://gobl.org/draft-0/bill/status", "https://gobl.org/draft-0/bill/status-line", "https://gobl.org/draft-0/bill/tax", "https://gobl.org/draft-0/bill/totals", "https://gobl.org/draft-0/cal/date", "https://gobl.org/draft-0/cal/date-time", "https://gobl.org/draft-0/cal/period", "https://gobl.org/draft-0/cal/time", "https://gobl.org/draft-0/cal/timestamp", "https://gobl.org/draft-0/cbc/code", "https://gobl.org/draft-0/cbc/code-map", "https://gobl.org/draft-0/cbc/definition", "https://gobl.org/draft-0/cbc/key", "https://gobl.org/draft-0/cbc/meta", "https://gobl.org/draft-0/cbc/source", "https://gobl.org/draft-0/cbc/uri", "https://gobl.org/draft-0/currency/amount", "https://gobl.org/draft-0/currency/code", "https://gobl.org/draft-0/currency/exchange-rate", "https://gobl.org/draft-0/dsig/digest", "https://gobl.org/draft-0/dsig/signature", "https://gobl.org/draft-0/envelope", "https://gobl.org/draft-0/head/header", "https://gobl.org/draft-0/head/link", "https://gobl.org/draft-0/head/stamp", "https://gobl.org/draft-0/i18n/string", "https://gobl.org/draft-0/l10n/code", "https://gobl.org/draft-0/l10n/iso-country-code", "https://gobl.org/draft-0/l10n/tax-country-code", "https://gobl.org/draft-0/note/message", "https://gobl.org/draft-0/num/amount", "https://gobl.org/draft-0/num/percentage", "https://gobl.org/draft-0/org/address", "https://gobl.org/draft-0/org/attachment", "https://gobl.org/draft-0/org/attribute", "https://gobl.org/draft-0/org/coordinates", "https://gobl.org/draft-0/org/document-ref", "https://gobl.org/draft-0/org/email", "https://gobl.org/draft-0/org/endpoint", "https://gobl.org/draft-0/org/identity", "https://gobl.org/draft-0/org/image", "https://gobl.org/draft-0/org/inbox", "https://gobl.org/draft-0/org/item", "https://gobl.org/draft-0/org/name", "https://gobl.org/draft-0/org/note", "https://gobl.org/draft-0/org/party", "https://gobl.org/draft-0/org/person", "https://gobl.org/draft-0/org/registration", "https://gobl.org/draft-0/org/telephone", "https://gobl.org/draft-0/org/website", "https://gobl.org/draft-0/pay/card", "https://gobl.org/draft-0/pay/credit-transfer", "https://gobl.org/draft-0/pay/direct-debit", "https://gobl.org/draft-0/pay/instructions", "https://gobl.org/draft-0/pay/online", "https://gobl.org/draft-0/pay/record", "https://gobl.org/draft-0/pay/terms", "https://gobl.org/draft-0/regimes/mx/food-vouchers", "https://gobl.org/draft-0/regimes/mx/fuel-account-balance", "https://gobl.org/draft-0/schema/object", "https://gobl.org/draft-0/tax/addon-def", "https://gobl.org/draft-0/tax/addon-list", "https://gobl.org/draft-0/tax/catalogue-def", "https://gobl.org/draft-0/tax/correction-definition", "https://gobl.org/draft-0/tax/correction-set", "https://gobl.org/draft-0/tax/extensions", "https://gobl.org/draft-0/tax/filter", "https://gobl.org/draft-0/tax/identity", "https://gobl.org/draft-0/tax/note", "https://gobl.org/draft-0/tax/regime-code", "https://gobl.org/draft-0/tax/regime-def", "https://gobl.org/draft-0/tax/scenario", "https://gobl.org/draft-0/tax/scenario-set", "https://gobl.org/draft-0/tax/set", "https://gobl.org/draft-0/tax/tag-set", "https://gobl.org/draft-0/tax/total" ] }`), IsFinal: false, @@ -650,7 +650,7 @@ func TestBulk(t *testing.T) { //nolint:gocyclo "val": { "type": "string", "title": "Value", - "description": "The serialized stamp value generated for or by the external agency" + "description": "Serialized stamp value generated for or by the external agency." } }, "type": "object", diff --git a/internal/ops/convert.go b/internal/ops/convert.go new file mode 100644 index 0000000..a87d68b --- /dev/null +++ b/internal/ops/convert.go @@ -0,0 +1,148 @@ +package ops + +import ( + "context" + "encoding/json" + "errors" + "io" + "slices" + + "github.com/invopop/gobl" + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/convert" +) + +// ImportOptions define the options used to import a document in an external +// format into GOBL. +type ImportOptions struct { + Input io.Reader + // Formats, when set, limits format detection to the given keys. + Formats []cbc.Key +} + +// ExportOptions define the options used to export a GOBL document into an +// external format. +type ExportOptions struct { + *ParseOptions + // Formats lists the keys of the formats to export into, in order of + // preference. The first one that accepts the document is used. + Formats []cbc.Key +} + +// Formats lists every format registered with GOBL's convert package by the +// converters in the bundle. +func Formats() []*convert.Format { + return convert.Formats() +} + +// Import detects the format of the input and converts it into a GOBL +// envelope using the registered converters, also returning the format +// detected. +func Import(ctx context.Context, opts *ImportOptions) (*gobl.Envelope, *convert.Format, error) { + data, err := io.ReadAll(cancelableReader(ctx, opts.Input)) + if err != nil { + return nil, nil, gobl.ErrInput.WithCause(err) + } + if len(data) == 0 { + return nil, nil, gobl.ErrInput.WithReason("no input data") + } + f, err := convert.Detect(data, opts.Formats...) + if err != nil { + return nil, nil, gobl.ErrInternal.WithCause(err) + } + env, err := convert.Import(data, f.Key) + if err != nil { + return nil, nil, gobl.ErrInternal.WithCause(err) + } + return env, f, nil +} + +// FormatCheck reports whether a format accepts a document for export. When +// it does not, Error gives the reason, usually as rules faults. +type FormatCheck struct { + *convert.Format + Accepted bool `json:"accepted"` + Error *gobl.Error `json:"error,omitempty"` +} + +// Export converts the GOBL document or envelope in the input into the first +// of the requested formats that accepts it. Documents are wrapped in an +// envelope, and unsigned envelopes are calculated, before conversion so that +// drafts can be exported directly. +func Export(ctx context.Context, opts *ExportOptions) (*convert.Output, error) { + if len(opts.Formats) == 0 { + return nil, gobl.ErrInput.WithReason("no output format provided") + } + env, err := exportEnvelope(ctx, opts.ParseOptions) + if err != nil { + return nil, err + } + out, err := convert.Export(env, opts.Formats...) + if err != nil { + return nil, gobl.ErrInternal.WithCause(err) + } + return out, nil +} + +// CheckFormats reports, for every registered format that exports the +// document's schema, whether it accepts the GOBL document or envelope in the +// input, prepared as for Export. +// +// The convert register only asks a format's converter whether it accepts a +// document during an export, so each format is checked by exporting a copy +// of the envelope, as converters may adjust the document they convert. +func CheckFormats(ctx context.Context, opts *ParseOptions) ([]*FormatCheck, error) { + env, err := exportEnvelope(ctx, opts) + if err != nil { + return nil, err + } + data, err := json.Marshal(env) + if err != nil { + return nil, gobl.ErrInternal.WithCause(err) + } + + list := make([]*FormatCheck, 0) + for _, f := range convert.Formats() { + if !slices.Contains(f.Export, env.Document.Schema) { + continue + } + cp := new(gobl.Envelope) + if err := json.Unmarshal(data, cp); err != nil { + return nil, gobl.ErrInternal.WithCause(err) + } + c := &FormatCheck{Format: f, Accepted: true} + if _, err := convert.Export(cp, f.Key); err != nil { + // Formats that accept the document but fail to convert it are + // still offered, so the failure is shown when they are used. + c.Accepted = !errors.Is(err, convert.ErrNotSupported) + c.Error = gobl.ErrInternal.WithCause(err) + } + list = append(list, c) + } + return list, nil +} + +// exportEnvelope parses the input into an envelope ready for export. +func exportEnvelope(ctx context.Context, opts *ParseOptions) (*gobl.Envelope, error) { + popts := *opts + popts.Envelop = true + obj, err := parseGOBLData(ctx, &popts) + if err != nil { + return nil, gobl.ErrInternal.WithCause(err) + } + env, ok := obj.(*gobl.Envelope) + if !ok { + return nil, gobl.ErrInput.WithReason("input must be a GOBL document or envelope") + } + + // Signed envelopes are sealed, so their contents are converted as they are. + if len(env.Signatures) == 0 { + if err := env.Calculate(); err != nil { + return nil, gobl.ErrInternal.WithCause(err) + } + } + if err := env.Validate(); err != nil { + return nil, gobl.ErrInternal.WithCause(err) + } + return env, nil +} diff --git a/internal/ops/convert_test.go b/internal/ops/convert_test.go new file mode 100644 index 0000000..ab7052a --- /dev/null +++ b/internal/ops/convert_test.go @@ -0,0 +1,117 @@ +package ops + +import ( + "bytes" + "context" + "strings" + "testing" + + "github.com/invopop/gobl" + _ "github.com/invopop/gobl.dev/bundle" + "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/convert" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFormats(t *testing.T) { + keys := make([]cbc.Key, 0) + for _, f := range Formats() { + keys = append(keys, f.Key) + } + assert.Contains(t, keys, cbc.Key("ubl+en16931-v2017")) + assert.Contains(t, keys, cbc.Key("cii+en16931-v2017")) +} + +func TestExport(t *testing.T) { + ctx := context.Background() + + t.Run("document to UBL", func(t *testing.T) { + out, err := Export(ctx, &ExportOptions{ + ParseOptions: &ParseOptions{Input: testFileReader(t, "testdata/convert-invoice.json")}, + Formats: []cbc.Key{"ubl+en16931-v2017"}, + }) + require.NoError(t, err) + assert.Equal(t, cbc.Key("ubl+en16931-v2017"), out.Format.Key) + assert.Contains(t, string(out.Data), "