From 38e37693ff23401eea703f1aacdf874febf9cf47 Mon Sep 17 00:00:00 2001 From: Sam Lown Date: Wed, 14 Oct 2026 15:53:50 +0000 Subject: [PATCH 1/5] Bump GOBL to v0.508.0 and add conversions via the convert register Upgrade GOBL to v0.508.0 and offer conversions between GOBL and external formats through its new convert package. Only formats registered with the register are offered, replacing the hand-maintained format list of #22. Modules: gobl v0.507.0 -> v0.508.0 gobl.ar.arca new v0.0.3 (ar-arca-v4 moved out of core) gobl.co.dian new v0.0.3 (co-dian-v2 moved out of core) gobl.ubl new v0.86.0 (registers ubl+ formats) gobl.cii new v0.69.0 (registers cii+ formats) gobl.fi.finvoice v0.0.2 -> v0.0.6 gobl.it.sdi v0.81.0 -> v0.83.0 gobl.mx.cfdi v0.64.0 -> v0.68.0 gobl.pl.ksef v0.45.0 -> v0.47.0 GOBL 0.508.0 changes handled: - The ARCA and DIAN addons now live in their own modules; blank-import both in the bundle so the approved addon test passes. - data/addons was removed. Addon definitions are already served from the registry, so only the TestListDataDir subtest reading it is dropped. - Shortened schema descriptions and the new tax/filter schema: refresh the correction options snapshots and the bulk schema expectations. Conversions: - internal/ops: Formats, Import and Export wrap the convert register. Export envelops documents and calculates unsigned envelopes first, so drafts can be exported directly. - CLI: `gobl convert` exports with --to (keys in order of preference), imports with format detection otherwise (--from limits it), and lists the formats with --list. - API: GET /v0/formats, POST /v0/import and POST /v0/export?format=, taking raw bodies. The format used is returned in a GOBL-Format header, exposed through CORS. Unescaped "+" in keys is accepted. - Editor: an Export picker lists the registered export formats and shows the result in a read-only XML pane beside the editor, via /v0/export. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 35 ++++- api/api.go | 8 +- api/convert.go | 65 +++++++++ api/convert_test.go | 106 ++++++++++++++ api/openapi.json | 136 ++++++++++++++++++ bundle/bundle.go | 16 ++- cmd/gobl/convert.go | 118 +++++++++++++++ cmd/gobl/convert_test.go | 75 ++++++++++ cmd/gobl/root.go | 1 + cmd/gobl/testdata/Test_correct_options_schema | 2 +- cmd/gobl/testdata/convert-invoice.json | 39 +++++ editor/assets/editor-data.js | 46 ++++++ editor/assets/editor.css | 6 + editor/assets/editor.js | 34 ++++- editor/editor.templ | 30 +++- editor/editor_templ.go | 116 ++++++++++++--- editor/formats.go | 29 ++++ editor/formats_test.go | 15 ++ go.mod | 21 ++- go.sum | 44 ++++-- internal/mcp/resources_test.go | 6 - internal/ops/bulk_test.go | 4 +- internal/ops/convert.go | 87 +++++++++++ internal/ops/convert_test.go | 116 +++++++++++++++ .../ops/testdata/TestCorrect_options_schema | 2 +- internal/ops/testdata/convert-invoice.json | 39 +++++ 26 files changed, 1133 insertions(+), 63 deletions(-) create mode 100644 api/convert.go create mode 100644 api/convert_test.go create mode 100644 cmd/gobl/convert.go create mode 100644 cmd/gobl/convert_test.go create mode 100644 cmd/gobl/testdata/convert-invoice.json create mode 100644 editor/formats.go create mode 100644 editor/formats_test.go create mode 100644 internal/ops/convert.go create mode 100644 internal/ops/convert_test.go create mode 100644 internal/ops/testdata/convert-invoice.json diff --git a/README.md b/README.md index 8b20218..42da908 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 ``` @@ -102,6 +110,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 +119,7 @@ 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/mcp` | Model Context Protocol (MCP) endpoint | | `GET` | `/v0/openapi.json` | OpenAPI specification | @@ -129,6 +140,26 @@ 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's export pane. 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` | + +Format keys follow GOBL's naming rules (`[+...]`). Exported +documents must include the addons a format requires, such as +`eu-en16931-v2017` for the EN 16931 formats. Regional formats appear as the +modules implementing them register with the convert package. + ## Project structure ``` @@ -136,7 +167,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..1f92174 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,8 @@ 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) // Reference data (static per version — ETag for caching) mux.HandleFunc("GET "+p+"/schemas", WithETag(handleSchemaList)) @@ -83,6 +85,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 +206,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..f007add --- /dev/null +++ b/api/convert.go @@ -0,0 +1,65 @@ +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. +const FormatHeader = "GOBL-Format" + +func handleFormatList(w http.ResponseWriter, _ *http.Request) { + WriteJSON(w, map[string]any{"formats": ops.Formats()}) +} + +func handleImport(w http.ResponseWriter, r *http.Request) { + env, err := ops.Import(r.Context(), &ops.ImportOptions{ + Input: r.Body, + Formats: queryFormats(r), + }) + if err != nil { + WriteError(w, err) + return + } + 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..5199fee --- /dev/null +++ b/api/convert_test.go @@ -0,0 +1,106 @@ +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) +} diff --git a/api/openapi.json b/api/openapi.json index 36d3710..15393e6 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -215,6 +215,87 @@ } } }, + "/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", + "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 +479,32 @@ "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" } + } + } + } + } + } + } + } + } } }, "components": { @@ -550,6 +657,34 @@ } } }, + "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" } + }, + "addons": { + "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" } + } + } + }, "I18nString": { "type": "object", "description": "Localized string keyed by language code.", @@ -594,6 +729,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..8074952 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,8 @@ 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.ubl" ) diff --git a/cmd/gobl/convert.go b/cmd/gobl/convert.go new file mode 100644 index 0000000..c754cce --- /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), " { error: null, // Counter so each successful build creates a fresh FlashMessage. success: 0, + // Key of the format to export the built document into, if any. + format: localStorage.getItem("editor-format") || "", + exporting: false, init() { window.addEventListener("keydown", (e) => { @@ -16,6 +19,7 @@ document.addEventListener("alpine:init", () => { this.build(); } }); + this.$watch("format", (v) => localStorage.setItem("editor-format", v)); }, async build() { @@ -58,12 +62,54 @@ document.addEventListener("alpine:init", () => { }); ed.focus(); this.success++; + await this.export(result); } catch (e) { this.error = { message: e.message }; } finally { this.loading = false; } }, + + // exportCurrent exports the document in the editor, as it is, into the + // selected format. + async exportCurrent() { + const ed = window._cmEditor; + if (!ed || !this.format) return; + this.error = null; + try { + await this.export(JSON.parse(ed.state.doc.toString())); + } catch (e) { + this.error = { message: e.message }; + } + }, + + // export converts the document or envelope into the selected format + // and shows the result in the viewer pane. Errors are shown in the + // error panel in the same shape as build errors. + async export(doc) { + if (!this.format || !window._cmSetViewer) return; + this.exporting = true; + try { + const res = await fetch( + "/v0/export?format=" + encodeURIComponent(this.format), + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(doc), + }, + ); + if (!res.ok) { + this.error = await res.json().catch(() => ({ + message: "export failed: " + res.status, + })); + window._cmSetViewer(""); + return; + } + window._cmSetViewer(await res.text()); + } finally { + this.exporting = false; + } + }, })); Alpine.data("darkModeToggle", () => ({ diff --git a/editor/assets/editor.css b/editor/assets/editor.css index 9d1f220..d24d1e6 100644 --- a/editor/assets/editor.css +++ b/editor/assets/editor.css @@ -3,6 +3,12 @@ flex: 1; overflow: hidden; } +#editor-pane, +#viewer-pane, +#viewer-xml { + height: 100%; + overflow: hidden; +} .cm-editor { height: 100%; } diff --git a/editor/assets/editor.js b/editor/assets/editor.js index da062bf..e8c0b4b 100644 --- a/editor/assets/editor.js +++ b/editor/assets/editor.js @@ -1,13 +1,15 @@ // editor.js -- ES module for GOBL Editor -// Initializes CodeMirror with JSON schema support. +// Initializes CodeMirror with JSON schema support, plus a read-only XML +// viewer for exported documents. 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. +// Theme compartments allow dynamic reconfiguration. const themeCompartment = new Compartment(); +const viewerThemeCompartment = new Compartment(); function isDark() { return document.documentElement.classList.contains("dark"); @@ -48,6 +50,7 @@ const defaultDoc = JSON.stringify( ); 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,7 +73,7 @@ async function loadSchemaFromDoc(view) { } } -const container = document.getElementById("editor-container"); +const container = document.getElementById("editor-pane"); container.replaceChildren(); const editor = new EditorView({ @@ -95,11 +98,30 @@ const editor = new EditorView({ parent: container, }); +const viewer = new EditorView({ + state: EditorState.create({ + doc: "", + extensions: [ + basicSetup, + viewerThemeCompartment.of(isDark() ? vsCodeDark : vsCodeLight), + EditorView.lineWrapping, + EditorState.readOnly.of(true), + xml(), + ], + }), + parent: document.getElementById("viewer-xml"), +}); + window._cmEditor = editor; -window._cmSetDark = (dark) => { - editor.dispatch({ - effects: themeCompartment.reconfigure(dark ? vsCodeDark : vsCodeLight), +window._cmSetViewer = (text) => { + viewer.dispatch({ + changes: { from: 0, to: viewer.state.doc.length, insert: text }, }); }; +window._cmSetDark = (dark) => { + const theme = dark ? vsCodeDark : vsCodeLight; + editor.dispatch({ effects: themeCompartment.reconfigure(theme) }); + viewer.dispatch({ effects: viewerThemeCompartment.reconfigure(theme) }); +}; loadSchemaFromDoc(editor); diff --git a/editor/editor.templ b/editor/editor.templ index c9e2d2d..4ccaa14 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,6 +33,7 @@ templ Page() { @popui.Header(props.Header{ Title: goblTitle(), }) { + @exportPicker() -
- @editorSkeleton() +
+
+ @editorSkeleton() +
+
+
Exporting…
+
+
} } } +// exportPicker selects a format, registered with GOBL's convert package, to +// show the built document in alongside the editor. +templ exportPicker() { + +} + templ editorSkeleton() {
diff --git a/editor/editor_templ.go b/editor/editor_templ.go index 8c2e668..1ab9d82 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,7 +60,11 @@ func Page() templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "