From efd90ec80d6890ee142af00d6a74a5c14dd5f3d5 Mon Sep 17 00:00:00 2001 From: buty4649 Date: Fri, 17 Apr 2026 17:17:17 +0900 Subject: [PATCH] =?UTF-8?q?system:=20=E3=83=9E=E3=82=B9=E3=82=BF=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E3=82=B5=E3=83=96=E3=82=B3=E3=83=9E=E3=83=B3=E3=83=89?= =?UTF-8?q?=20(list/show/data/import/upload)=20=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 管理者向けの system master API 一式を呼び出す `xpoint system master` サブコマンド群を追加。 - `system master list` — GET /api/v1/system/master でマスタ一覧取得 - `system master show ` — GET /api/v1/system/master/ {master_table_name} でユーザ固有マスタのプロパティ (フィールド定義) を取得する - `system master data ` — GET /api/v1/system/master/ {master_code}/data[.json|.csv] でマスタデータを取得する。 --type (0: 簡易 / 1: ユーザ固有)・--format (json|csv)・ --rows/--offset/--file-name/--delimiter/--title/--no-title/--fields など CSV エクスポート用のクエリも公開 - `system master import ` — PUT /api/v1/system/master/ {master_code}/data で簡易マスタへ JSON 形式のデータを投入する。 --data にインラインJSON、ファイル、-(stdin) を指定できる。 --overwrite で既存データを置き換える - `system master upload ` — POST /multiapi/v1/ system/master/{master_table_name}/data でユーザ固有マスタの インポート用 CSV をアップロードする (インポートは実行しない)。 --file に CSV パスまたは -(stdin)、--overwrite で上書き可能 ついでに downloadBytes を拡張した downloadBytesWithContentType を 追加し、レスポンスの Content-Type も返せるようにした。 refs #25 Co-Authored-By: Claude Opus 4.7 --- cmd/system_master.go | 371 ++++++++++++++++++++++ internal/schema/schema_test.go | 5 + internal/schema/system.master.data.json | 62 ++++ internal/schema/system.master.import.json | 67 ++++ internal/schema/system.master.list.json | 49 +++ internal/schema/system.master.show.json | 52 +++ internal/schema/system.master.upload.json | 48 +++ internal/xpoint/client.go | 200 +++++++++++- 8 files changed, 849 insertions(+), 5 deletions(-) create mode 100644 cmd/system_master.go create mode 100644 internal/schema/system.master.data.json create mode 100644 internal/schema/system.master.import.json create mode 100644 internal/schema/system.master.list.json create mode 100644 internal/schema/system.master.show.json create mode 100644 internal/schema/system.master.upload.json diff --git a/cmd/system_master.go b/cmd/system_master.go new file mode 100644 index 0000000..24cb967 --- /dev/null +++ b/cmd/system_master.go @@ -0,0 +1,371 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "text/tabwriter" + + "github.com/pepabo/xpoint-cli/internal/xpoint" + "github.com/spf13/cobra" +) + +var ( + systemMasterListOutput string + systemMasterListJQ string + systemMasterShowOutput string + systemMasterShowJQ string + + systemMasterDataType int + systemMasterDataRows int + systemMasterDataOffset int + systemMasterDataFormat string + systemMasterDataFileName string + systemMasterDataDelimiter string + systemMasterDataTitle bool + systemMasterDataNoTitle bool + systemMasterDataFields string + systemMasterDataOutput string + systemMasterDataJQ string + + systemMasterImportOverwrite bool + systemMasterImportData string + systemMasterImportJQ string + + systemMasterUploadFile string + systemMasterUploadOverwrite bool +) + +var systemMasterCmd = &cobra.Command{ + Use: "master", + Short: "Manage X-point masters via admin APIs", +} + +var systemMasterListCmd = &cobra.Command{ + Use: "list", + Short: "List masters (admin)", + Long: "List all masters via GET /api/v1/system/master. Requires an administrator account.", + RunE: runSystemMasterList, +} + +var systemMasterShowCmd = &cobra.Command{ + Use: "show ", + Short: "Show a user-specific master's property definition", + Long: `Fetch user-specific master property info via +GET /api/v1/system/master/{master_table_name}. Requires an administrator +account.`, + Args: cobra.ExactArgs(1), + RunE: runSystemMasterShow, +} + +var systemMasterDataCmd = &cobra.Command{ + Use: "data ", + Short: "Export master data (JSON or CSV)", + Long: `Export master rows via GET /api/v1/system/master/{master_code}/data. + +--type (required) selects the master kind: + 0 simple master + 1 user-specific master (pass the table_name as ) + +--format defaults to json. Use --format csv for CSV output; the CSV +payload is written to stdout (or --output FILE / DIR/).`, + Args: cobra.ExactArgs(1), + RunE: runSystemMasterData, +} + +var systemMasterImportCmd = &cobra.Command{ + Use: "import ", + Short: "Import rows into a simple master", + Long: `Import data rows into a simple master via +PUT /api/v1/system/master/{master_code}/data. + +--data takes a JSON array of {"code","value"} objects, either inline, +as a file path, or - for stdin. +Pass --overwrite to replace existing data instead of appending.`, + Args: cobra.ExactArgs(1), + RunE: runSystemMasterImport, +} + +var systemMasterUploadCmd = &cobra.Command{ + Use: "upload ", + Short: "Upload a CSV for a user-specific master's import staging", + Long: `Upload a CSV via POST /multiapi/v1/system/master/{master_table_name}/data. + +The upload only stages the file; the import itself is run later from +the admin site's task management (manually or by schedule).`, + Args: cobra.ExactArgs(1), + RunE: runSystemMasterUpload, +} + +func init() { + systemCmd.AddCommand(systemMasterCmd) + systemMasterCmd.AddCommand(systemMasterListCmd) + systemMasterCmd.AddCommand(systemMasterShowCmd) + systemMasterCmd.AddCommand(systemMasterDataCmd) + systemMasterCmd.AddCommand(systemMasterImportCmd) + systemMasterCmd.AddCommand(systemMasterUploadCmd) + + lf := systemMasterListCmd.Flags() + lf.StringVarP(&systemMasterListOutput, "output", "o", "", "output format: table|json (default: table on TTY, json otherwise)") + lf.StringVar(&systemMasterListJQ, "jq", "", "apply a gojq filter to the JSON response (forces JSON output)") + + sf := systemMasterShowCmd.Flags() + sf.StringVarP(&systemMasterShowOutput, "output", "o", "", "output format: table|json (default: table on TTY, json otherwise)") + sf.StringVar(&systemMasterShowJQ, "jq", "", "apply a gojq filter to the JSON response (forces JSON output)") + + df := systemMasterDataCmd.Flags() + df.IntVar(&systemMasterDataType, "type", -1, "master_type: 0=simple master, 1=user-specific master (required)") + df.IntVar(&systemMasterDataRows, "rows", 0, "number of rows to fetch (0 = omit; server default 100; max 1000)") + df.IntVar(&systemMasterDataOffset, "offset", 0, "offset (0 = omit; server default 0)") + df.StringVar(&systemMasterDataFormat, "format", "json", "output format: json | csv") + df.StringVar(&systemMasterDataFileName, "file-name", "", "CSV file name hint (CSV only; default: {master_code}.csv)") + df.StringVar(&systemMasterDataDelimiter, "delimiter", "", "CSV delimiter: comma | tab (CSV only; default comma)") + df.BoolVar(&systemMasterDataTitle, "title", false, "CSV only (user-specific master): include field names on the first row (default: true)") + df.BoolVar(&systemMasterDataNoTitle, "no-title", false, "CSV only (user-specific master): omit field names from the first row") + df.StringVar(&systemMasterDataFields, "fields", "", "CSV only (simple master): comma-separated list of field names to include") + df.StringVarP(&systemMasterDataOutput, "output", "o", "", "output path: FILE, DIR/, - for stdout (default: stdout for JSON, server-provided filename for CSV)") + df.StringVar(&systemMasterDataJQ, "jq", "", "apply a gojq filter to the JSON response (JSON format only)") + _ = systemMasterDataCmd.MarkFlagRequired("type") + + imf := systemMasterImportCmd.Flags() + imf.BoolVar(&systemMasterImportOverwrite, "overwrite", false, "replace existing simple master data instead of appending") + imf.StringVar(&systemMasterImportData, "data", "", "JSON array of {\"code\",\"value\"} rows: inline, file path, or - for stdin (required)") + imf.StringVar(&systemMasterImportJQ, "jq", "", "apply a gojq filter to the JSON response") + _ = systemMasterImportCmd.MarkFlagRequired("data") + + uf := systemMasterUploadCmd.Flags() + uf.StringVar(&systemMasterUploadFile, "file", "", "path to the CSV file to upload, or - for stdin (required)") + uf.BoolVar(&systemMasterUploadOverwrite, "overwrite", false, "overwrite the existing staged CSV for this master") + _ = systemMasterUploadCmd.MarkFlagRequired("file") +} + +func runSystemMasterList(cmd *cobra.Command, args []string) error { + client, err := newClientFromFlags(cmd.Context()) + if err != nil { + return err + } + res, err := client.ListMasters(cmd.Context()) + if err != nil { + return err + } + + return render(res, resolveOutputFormat(systemMasterListOutput), systemMasterListJQ, func() error { + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + defer w.Flush() + fmt.Fprintln(w, "TYPE\tTYPE_NAME\tCODE\tTABLE_NAME\tITEMS\tNAME\tREMARKS") + for _, m := range res.Master { + code := m.Code + if code == "" { + code = "-" + } + tbl := m.TableName + if tbl == "" { + tbl = "-" + } + fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%d\t%s\t%s\n", + m.Type, m.TypeName, code, tbl, m.ItemCount, m.Name, m.Remarks, + ) + } + return nil + }) +} + +func runSystemMasterShow(cmd *cobra.Command, args []string) error { + tableName := strings.TrimSpace(args[0]) + if tableName == "" { + return fmt.Errorf("master_table_name is required") + } + client, err := newClientFromFlags(cmd.Context()) + if err != nil { + return err + } + res, err := client.GetUserMasterInfo(cmd.Context(), tableName) + if err != nil { + return err + } + return render(res, resolveOutputFormat(systemMasterShowOutput), systemMasterShowJQ, func() error { + fmt.Fprintf(os.Stdout, "TABLE: %s\n", res.TableName) + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + defer w.Flush() + fmt.Fprintln(w, "ID\tTYPE\tLENGTH\tPK\tINDEX") + for _, f := range res.Fields { + fmt.Fprintf(w, "%s\t%s\t%v\t%t\t%t\n", f.ID, f.Type, f.Length, f.PrimaryKey, f.Index) + } + return nil + }) +} + +func runSystemMasterData(cmd *cobra.Command, args []string) error { + masterCode := strings.TrimSpace(args[0]) + if masterCode == "" { + return fmt.Errorf("master_code is required") + } + if systemMasterDataType != 0 && systemMasterDataType != 1 { + return fmt.Errorf("--type must be 0 (simple) or 1 (user-specific), got %d", systemMasterDataType) + } + format := strings.ToLower(strings.TrimSpace(systemMasterDataFormat)) + switch format { + case "", "json": + format = "json" + case "csv": + default: + return fmt.Errorf("unknown --format %q (must be json or csv)", systemMasterDataFormat) + } + if systemMasterDataTitle && systemMasterDataNoTitle { + return fmt.Errorf("--title and --no-title are mutually exclusive") + } + + p := xpoint.MasterDataParams{MasterType: systemMasterDataType} + if cmd.Flags().Changed("rows") { + v := systemMasterDataRows + p.Rows = &v + } + if cmd.Flags().Changed("offset") { + v := systemMasterDataOffset + p.Offset = &v + } + if format == "csv" { + p.FileName = systemMasterDataFileName + p.Delimiter = systemMasterDataDelimiter + p.Fields = systemMasterDataFields + if systemMasterDataNoTitle { + b := false + p.Title = &b + } else if cmd.Flags().Changed("title") { + v := systemMasterDataTitle + p.Title = &v + } + } + + client, err := newClientFromFlags(cmd.Context()) + if err != nil { + return err + } + filename, body, _, err := client.GetMasterData(cmd.Context(), masterCode, format, p) + if err != nil { + return err + } + + if format == "json" { + if systemMasterDataJQ != "" { + return runJQ(json.RawMessage(body), systemMasterDataJQ) + } + switch systemMasterDataOutput { + case "", "-": + _, werr := os.Stdout.Write(body) + return werr + } + dst := resolveDownloadPath(systemMasterDataOutput, fallbackName(filename, masterCode+".json"), 0) + if err := os.WriteFile(dst, body, 0o600); err != nil { + return fmt.Errorf("write master data: %w", err) + } + fmt.Fprintf(os.Stderr, "saved: %s (%d bytes)\n", dst, len(body)) + return nil + } + + // CSV + if systemMasterDataOutput == "-" { + _, werr := os.Stdout.Write(body) + return werr + } + if systemMasterDataOutput == "" && !isTerminal(os.Stdout) { + _, werr := os.Stdout.Write(body) + return werr + } + dst := resolveDownloadPath(systemMasterDataOutput, fallbackName(filename, masterCode+".csv"), 0) + if err := os.WriteFile(dst, body, 0o600); err != nil { + return fmt.Errorf("write csv: %w", err) + } + fmt.Fprintf(os.Stderr, "saved: %s (%d bytes)\n", dst, len(body)) + return nil +} + +func runSystemMasterImport(cmd *cobra.Command, args []string) error { + masterCode := strings.TrimSpace(args[0]) + if masterCode == "" { + return fmt.Errorf("master_code is required") + } + raw, err := loadStringInput(systemMasterImportData) + if err != nil { + return fmt.Errorf("load --data: %w", err) + } + var items []xpoint.SimpleMasterDataItem + if err := json.Unmarshal([]byte(raw), &items); err != nil { + return fmt.Errorf("--data must be a JSON array of {\"code\",\"value\"}: %w", err) + } + + req := xpoint.ImportSimpleMasterRequest{Data: items} + if cmd.Flags().Changed("overwrite") { + v := systemMasterImportOverwrite + req.Overwrite = &v + } + + client, err := newClientFromFlags(cmd.Context()) + if err != nil { + return err + } + out, err := client.ImportSimpleMasterData(cmd.Context(), masterCode, req) + if err != nil { + return err + } + if systemMasterImportJQ != "" { + return runJQ(out, systemMasterImportJQ) + } + return writeJSON(os.Stdout, out) +} + +func runSystemMasterUpload(cmd *cobra.Command, args []string) error { + tableName := strings.TrimSpace(args[0]) + if tableName == "" { + return fmt.Errorf("master_table_name is required") + } + content, fileName, err := readUploadFile(systemMasterUploadFile) + if err != nil { + return fmt.Errorf("read --file: %w", err) + } + + var overwrite *bool + if cmd.Flags().Changed("overwrite") { + v := systemMasterUploadOverwrite + overwrite = &v + } + + client, err := newClientFromFlags(cmd.Context()) + if err != nil { + return err + } + res, err := client.UploadUserMasterCSV(cmd.Context(), tableName, fileName, content, overwrite) + if err != nil { + return err + } + return writeJSON(os.Stdout, res) +} + +// fallbackName returns name when non-empty, else alt. +func fallbackName(name, alt string) string { + if name != "" { + return name + } + return alt +} + +// readUploadFile reads the CSV file contents and returns the bytes plus a +// suggested filename for the multipart form-data part. "-" reads from stdin +// and yields a synthetic "upload.csv" filename. +func readUploadFile(path string) ([]byte, string, error) { + if path == "-" { + b, err := io.ReadAll(os.Stdin) + if err != nil { + return nil, "", fmt.Errorf("read stdin: %w", err) + } + return b, "upload.csv", nil + } + b, err := os.ReadFile(path) + if err != nil { + return nil, "", err + } + return b, filepath.Base(path), nil +} diff --git a/internal/schema/schema_test.go b/internal/schema/schema_test.go index d169834..f2a6a3e 100644 --- a/internal/schema/schema_test.go +++ b/internal/schema/schema_test.go @@ -38,6 +38,11 @@ func TestAliases_Sorted(t *testing.T) { "query.list", "system.form.list", "system.form.show", + "system.master.data", + "system.master.import", + "system.master.list", + "system.master.show", + "system.master.upload", } if len(got) != len(want) { t.Fatalf("aliases = %v", got) diff --git a/internal/schema/system.master.data.json b/internal/schema/system.master.data.json new file mode 100644 index 0000000..7ecd837 --- /dev/null +++ b/internal/schema/system.master.data.json @@ -0,0 +1,62 @@ +{ + "method": "GET", + "path": "/api/v1/system/master/{master_code}/data", + "summary": "マスタデータ取得", + "description": "簡易マスタまたはユーザ固有マスタのデータを JSON または CSV で取得する。\nURL の末尾を data.json / data.csv とすることでフォーマットを切り替える。\n", + "parameters": [ + { + "name": "master_code", + "in": "path", + "required": true, + "type": "string", + "description": "マスタ識別情報 (簡易:マスタコード / ユーザ固有:テーブル名)" + }, + { + "name": "master_type", + "in": "query", + "required": true, + "type": "integer", + "description": "0:簡易マスタ / 1:ユーザ固有マスタ" + }, + { + "name": "rows", + "in": "query", + "type": "integer", + "description": "取得行数 (default 100, max 1000)" + }, + { + "name": "offset", + "in": "query", + "type": "integer", + "description": "オフセット (default 0)" + }, + { + "name": "file_name", + "in": "query", + "type": "string", + "description": "CSV ファイル名 (CSV 時のみ)" + }, + { + "name": "delimiter", + "in": "query", + "type": "string", + "description": "区切り文字 comma|tab (CSV 時のみ)" + }, + { + "name": "title", + "in": "query", + "type": "boolean", + "description": "CSV + ユーザ固有マスタ時のみ。先頭行にフィールド名を出力するか (default true)" + }, + { + "name": "fields", + "in": "query", + "type": "string", + "description": "CSV + 簡易マスタ時のみ。出力項目をカンマ区切りで指定" + } + ], + "response": { + "contentType": "application/json | text/csv", + "description": "マスタデータ (JSON または CSV)" + } +} diff --git a/internal/schema/system.master.import.json b/internal/schema/system.master.import.json new file mode 100644 index 0000000..74e752b --- /dev/null +++ b/internal/schema/system.master.import.json @@ -0,0 +1,67 @@ +{ + "method": "PUT", + "path": "/api/v1/system/master/{master_code}/data", + "summary": "簡易マスタデータインポート", + "description": "JSON 形式のマスタデータを簡易マスタにインポートする。\n管理者権限が必要。\n", + "parameters": [ + { + "name": "master_code", + "in": "path", + "required": true, + "type": "string", + "description": "簡易マスタのマスタコード" + } + ], + "requestBody": { + "contentType": "application/json", + "type": "object", + "properties": { + "overwrite": { + "type": "boolean", + "description": "true: インポートデータで置き換え / false: 追加 (default false)" + }, + "data": { + "type": "array", + "required": true, + "description": "インポートデータ (code/value のオブジェクト配列)", + "items": { + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "項目コード" + }, + "value": { + "description": "項目値 (文字列または数値)" + } + } + } + } + } + }, + "response": { + "type": "object", + "properties": { + "master": { + "type": "object", + "description": "マスタ情報" + }, + "all_count": { + "type": "integer", + "description": "全インポート件数" + }, + "success_count": { + "type": "integer", + "description": "インポート成功件数" + }, + "error_count": { + "type": "integer", + "description": "インポート失敗件数" + }, + "result": { + "type": "array", + "description": "インポート結果情報 (status/data/message)" + } + } + } +} diff --git a/internal/schema/system.master.list.json b/internal/schema/system.master.list.json new file mode 100644 index 0000000..4bd4e34 --- /dev/null +++ b/internal/schema/system.master.list.json @@ -0,0 +1,49 @@ +{ + "method": "GET", + "path": "/api/v1/system/master", + "summary": "マスタ一覧取得", + "description": "管理者サイトの「マスタ管理」で表示されるマスタ一覧相当のリストを取得する。\n管理者権限が必要。\n", + "parameters": [], + "response": { + "type": "object", + "properties": { + "master": { + "type": "array", + "description": "マスタ情報 (存在しない場合は空配列)", + "items": { + "type": "object", + "properties": { + "type": { + "type": "integer", + "description": "マスタタイプ (0:簡易マスタ / 1:ユーザ固有マスタ / 2:X-point マスタ / 5:kintone 連携マスタ / 6:書類マスタ)" + }, + "type_name": { + "type": "string", + "description": "マスタタイプ名称" + }, + "name": { + "type": "string", + "description": "マスタ名称" + }, + "code": { + "type": "string", + "description": "マスタコード (簡易/kintone/書類マスタのみ返される)" + }, + "table_name": { + "type": "string", + "description": "マスタテーブル名 (ユーザ固有マスタで返される)" + }, + "item_count": { + "type": "integer", + "description": "データ件数" + }, + "remarks": { + "type": "string", + "description": "備考" + } + } + } + } + } + } +} diff --git a/internal/schema/system.master.show.json b/internal/schema/system.master.show.json new file mode 100644 index 0000000..c89246b --- /dev/null +++ b/internal/schema/system.master.show.json @@ -0,0 +1,52 @@ +{ + "method": "GET", + "path": "/api/v1/system/master/{master_table_name}", + "summary": "ユーザ固有マスタ情報取得", + "description": "ユーザ固有マスタプロパティ相当の情報 (フィールド定義) を取得する。\n管理者権限が必要。\n", + "parameters": [ + { + "name": "master_table_name", + "in": "path", + "required": true, + "type": "string", + "description": "マスタテーブル名" + } + ], + "response": { + "type": "object", + "properties": { + "table_name": { + "type": "string", + "description": "マスタテーブル名" + }, + "fields": { + "type": "array", + "description": "フィールド情報", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "フィールドID" + }, + "type": { + "type": "string", + "description": "型 (varchar / numeric など)" + }, + "length": { + "description": "型に応じた長さ (varchar は integer、numeric は \"10.2\" のような文字列)" + }, + "primary_key": { + "type": "boolean", + "description": "主キーかどうか" + }, + "index": { + "type": "boolean", + "description": "インデックスかどうか" + } + } + } + } + } + } +} diff --git a/internal/schema/system.master.upload.json b/internal/schema/system.master.upload.json new file mode 100644 index 0000000..5f2b085 --- /dev/null +++ b/internal/schema/system.master.upload.json @@ -0,0 +1,48 @@ +{ + "method": "POST", + "path": "/multiapi/v1/system/master/{master_table_name}/data", + "summary": "ユーザ固有マスタデータアップロード", + "description": "ユーザ固有マスタのインポートに利用する CSV ファイルを X-point へアップロードする (インポート自体は実行しない)。\n事前に対象のユーザ固有マスタに「ユーザ固有マスタインポートバッチ」の登録が必要。\n管理者権限が必要。\n", + "parameters": [ + { + "name": "master_table_name", + "in": "path", + "required": true, + "type": "string", + "description": "マスタテーブル名" + } + ], + "requestBody": { + "contentType": "multipart/form-data", + "type": "object", + "properties": { + "file": { + "type": "string", + "format": "binary", + "required": true, + "description": "アップロード CSV ファイル" + }, + "overwrite": { + "type": "boolean", + "description": "true: 上書きする / false: 上書きしない (default false)" + } + } + }, + "response": { + "type": "object", + "properties": { + "master": { + "type": "object", + "description": "マスタ情報" + }, + "message_type": { + "type": "integer", + "description": "メッセージタイプ (3:INFO 固定)" + }, + "message": { + "type": "string", + "description": "メッセージ内容" + } + } + } +} diff --git a/internal/xpoint/client.go b/internal/xpoint/client.go index 8ad7b7d..c02a6ad 100644 --- a/internal/xpoint/client.go +++ b/internal/xpoint/client.go @@ -14,6 +14,7 @@ import ( "net/url" "os" "strconv" + "strings" "time" ) @@ -192,6 +193,187 @@ func (c *Client) GetSystemFormDetail(ctx context.Context, formID int) (*FormDeta return &out, nil } +type Master struct { + Type int `json:"type"` + TypeName string `json:"type_name"` + Name string `json:"name"` + Code string `json:"code"` + TableName string `json:"table_name"` + ItemCount int `json:"item_count"` + Remarks string `json:"remarks"` +} + +type MasterListResponse struct { + Master []Master `json:"master"` +} + +// ListMasters calls GET /api/v1/system/master (admin). +func (c *Client) ListMasters(ctx context.Context) (*MasterListResponse, error) { + var out MasterListResponse + if err := c.do(ctx, http.MethodGet, "/api/v1/system/master", nil, nil, &out); err != nil { + return nil, err + } + return &out, nil +} + +type UserMasterField struct { + ID string `json:"id"` + Type string `json:"type"` + Length any `json:"length"` // varchar -> int, numeric -> "10.2" + PrimaryKey bool `json:"primary_key"` + Index bool `json:"index"` +} + +type UserMasterInfoResponse struct { + TableName string `json:"table_name"` + Fields []UserMasterField `json:"fields"` +} + +// GetUserMasterInfo calls GET /api/v1/system/master/{master_table_name} (admin) +// and returns the user-specific master property definition. +func (c *Client) GetUserMasterInfo(ctx context.Context, tableName string) (*UserMasterInfoResponse, error) { + path := fmt.Sprintf("/api/v1/system/master/%s", url.PathEscape(tableName)) + var out UserMasterInfoResponse + if err := c.do(ctx, http.MethodGet, path, nil, nil, &out); err != nil { + return nil, err + } + return &out, nil +} + +// MasterDataParams holds query parameters for GET /api/v1/system/master/{master_code}/data. +// MasterType is required (0: simple master, 1: user-specific master). +// When the path suffix is .csv, the remaining fields (FileName, Delimiter, +// Title, Fields) apply. +type MasterDataParams struct { + MasterType int // required (0 or 1) + Rows *int // default 100, max 1000 + Offset *int // default 0 + FileName string // CSV only + Delimiter string // CSV only: "comma" | "tab" + Title *bool // CSV + user-specific master only + Fields string // CSV + simple master only: comma-separated list +} + +func (p MasterDataParams) query() url.Values { + v := url.Values{} + v.Set("master_type", strconv.Itoa(p.MasterType)) + if p.Rows != nil { + v.Set("rows", strconv.Itoa(*p.Rows)) + } + if p.Offset != nil { + v.Set("offset", strconv.Itoa(*p.Offset)) + } + if p.FileName != "" { + v.Set("file_name", p.FileName) + } + if p.Delimiter != "" { + v.Set("delimiter", p.Delimiter) + } + if p.Title != nil { + v.Set("title", strconv.FormatBool(*p.Title)) + } + if p.Fields != "" { + v.Set("fields", p.Fields) + } + return v +} + +// GetMasterData calls GET /api/v1/system/master/{master_code}/data[.json|.csv] +// and returns the server-provided filename, raw response bytes, and content +// type. suffix may be "", "json", or "csv" (mapped to .json/.csv). +func (c *Client) GetMasterData(ctx context.Context, masterCode, suffix string, p MasterDataParams) (string, []byte, string, error) { + path := fmt.Sprintf("/api/v1/system/master/%s/data", url.PathEscape(masterCode)) + accept := "application/json" + switch strings.ToLower(suffix) { + case "", "json": + // default: JSON + case "csv": + path += ".csv" + accept = "text/csv" + default: + return "", nil, "", fmt.Errorf("unknown master data format %q (must be json or csv)", suffix) + } + filename, body, ct, err := c.downloadBytesWithContentType(ctx, http.MethodGet, path, p.query(), nil, "", accept) + return filename, body, ct, err +} + +type SimpleMasterDataItem struct { + Code string `json:"code"` + Value any `json:"value"` +} + +type ImportSimpleMasterRequest struct { + Overwrite *bool `json:"overwrite,omitempty"` + Data []SimpleMasterDataItem `json:"data"` +} + +// ImportSimpleMasterData calls PUT /api/v1/system/master/{master_code}/data +// (admin) to import rows into a simple master. The response shape is +// documented but complex, so it is returned as raw JSON. +func (c *Client) ImportSimpleMasterData(ctx context.Context, masterCode string, req ImportSimpleMasterRequest) (json.RawMessage, error) { + body, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("marshal request: %w", err) + } + path := fmt.Sprintf("/api/v1/system/master/%s/data", url.PathEscape(masterCode)) + var out json.RawMessage + if err := c.do(ctx, http.MethodPut, path, nil, body, &out); err != nil { + return nil, err + } + return out, nil +} + +type UploadUserMasterResponse struct { + Master struct { + Type int `json:"type"` + TypeName string `json:"type_name"` + Name string `json:"name"` + Code string `json:"code"` + TableName string `json:"table_name"` + } `json:"master"` + MessageType int `json:"message_type"` + Message string `json:"message"` +} + +// UploadUserMasterCSV calls POST /multiapi/v1/system/master/{master_table_name}/data +// (admin) to upload a CSV file to a user-specific master's import staging. +// fileName is the CSV filename, content the raw CSV bytes, overwrite is sent +// as the overwrite form field when non-nil. +func (c *Client) UploadUserMasterCSV(ctx context.Context, tableName, fileName string, content []byte, overwrite *bool) (*UploadUserMasterResponse, error) { + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) + + h := textproto.MIMEHeader{} + h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="file"; filename=%q`, fileName)) + h.Set("Content-Type", "text/csv") + fw, err := mw.CreatePart(h) + if err != nil { + return nil, fmt.Errorf("create file part: %w", err) + } + if _, err := fw.Write(content); err != nil { + return nil, fmt.Errorf("write file content: %w", err) + } + if overwrite != nil { + if err := mw.WriteField("overwrite", strconv.FormatBool(*overwrite)); err != nil { + return nil, fmt.Errorf("write overwrite: %w", err) + } + } + if err := mw.Close(); err != nil { + return nil, fmt.Errorf("close multipart writer: %w", err) + } + + path := fmt.Sprintf("/multiapi/v1/system/master/%s/data", url.PathEscape(tableName)) + _, body, _, err := c.downloadBytesWithContentType(ctx, http.MethodPost, path, nil, buf.Bytes(), mw.FormDataContentType(), "application/json") + if err != nil { + return nil, err + } + var out UploadUserMasterResponse + if err := json.Unmarshal(body, &out); err != nil { + return nil, fmt.Errorf("decode response: %w", err) + } + return &out, nil +} + type Approval struct { DocID int `json:"docid"` Hidden *bool `json:"hidden,omitempty"` @@ -707,6 +889,14 @@ func (c *Client) DownloadPDF(ctx context.Context, docID int) (string, []byte, er // the request body; pass "" when body is nil or when Content-Type should // be left unset (e.g. multipart where the caller sets the boundary). func (c *Client) downloadBytes(ctx context.Context, method, path string, q url.Values, body []byte, contentType, accept string) (string, []byte, error) { + filename, respBody, _, err := c.downloadBytesWithContentType(ctx, method, path, q, body, contentType, accept) + return filename, respBody, err +} + +// downloadBytesWithContentType is like downloadBytes but also returns the +// response Content-Type, useful for endpoints that switch format based on +// URL suffix (e.g. master data JSON/CSV). +func (c *Client) downloadBytesWithContentType(ctx context.Context, method, path string, q url.Values, body []byte, contentType, accept string) (string, []byte, string, error) { u := c.baseURL + path if len(q) > 0 { u += "?" + q.Encode() @@ -719,7 +909,7 @@ func (c *Client) downloadBytes(ctx context.Context, method, path string, q url.V req, err := http.NewRequestWithContext(ctx, method, u, reqBody) if err != nil { - return "", nil, err + return "", nil, "", err } c.auth.apply(req) if accept != "" { @@ -736,13 +926,13 @@ func (c *Client) downloadBytes(ctx context.Context, method, path string, q url.V resp, err := c.http.Do(req) if err != nil { - return "", nil, fmt.Errorf("request failed: %w", err) + return "", nil, "", fmt.Errorf("request failed: %w", err) } defer resp.Body.Close() respBody, err := io.ReadAll(resp.Body) if err != nil { - return "", nil, fmt.Errorf("read response body: %w", err) + return "", nil, "", fmt.Errorf("read response body: %w", err) } if debug { fmt.Fprintf(os.Stderr, "[xp] <- %s (%d bytes)\n", resp.Status, len(respBody)) @@ -751,9 +941,9 @@ func (c *Client) downloadBytes(ctx context.Context, method, path string, q url.V } } if resp.StatusCode/100 != 2 { - return "", nil, fmt.Errorf("xpoint api error: %s: %s", resp.Status, string(respBody)) + return "", nil, "", fmt.Errorf("xpoint api error: %s: %s", resp.Status, string(respBody)) } - return parseContentDispositionFilename(resp.Header.Get("Content-Disposition")), respBody, nil + return parseContentDispositionFilename(resp.Header.Get("Content-Disposition")), respBody, resp.Header.Get("Content-Type"), nil } // DocviewParams holds query parameters for GET /api/v1/documents/docview.