- JSON lines import keeping ids, locale_id looked up by locale code (an unknown code aborts before any write) - one transaction, setval past the copied ids, --force replaces, --dry-run reports, an empty file copies nothing - scripts/winter-export.sql: SELECT JSON_OBJECT with the translate locale code - TestFaqImportSmoke
167 lines
4.3 KiB
Go
167 lines
4.3 KiB
Go
// Package console holds the FAQ plugin's commands: faq:import-winter, a copy
|
|
// of a WinterCMS site's FAQs.
|
|
package console
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
|
"git.golem15.com/golem15/summercms/modules/bonfire"
|
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
func flagValue(in bonfire.Input, name string) string {
|
|
v, ok := in.Flag(name)
|
|
if !ok {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(v)
|
|
}
|
|
|
|
func flagOn(in bonfire.Input, name string) bool {
|
|
switch strings.ToLower(flagValue(in, name)) {
|
|
case "true", "1", "yes":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// lookupDB returns the app database. The generated main.go publishes no
|
|
// database for plugin commands, so when none is published it opens
|
|
// database.dsn and publishes it the way lagoon's migrate does. The returned
|
|
// func closes a pool this call opened; it is a no-op otherwise.
|
|
func lookupDB(ctx context.Context, app *backpack.App) (*gorm.DB, func(), error) {
|
|
if app == nil {
|
|
return nil, nil, fmt.Errorf("golem15.faq: app is nil")
|
|
}
|
|
if db, ok := app.Lookup[*gorm.DB](); ok && db != nil {
|
|
return db, func() {}, nil
|
|
}
|
|
if lagoon.DSN(app.Config) == "" {
|
|
return nil, nil, fmt.Errorf("golem15.faq: database is not configured (set SUMMER_DATABASE__DSN)")
|
|
}
|
|
sqlDB, gdb, err := lagoon.OpenFromApp(ctx, app)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if err := lagoon.Publish(app, sqlDB, gdb); err != nil {
|
|
_ = sqlDB.Close()
|
|
return nil, nil, err
|
|
}
|
|
return gdb, func() { _ = sqlDB.Close() }, nil
|
|
}
|
|
|
|
// readJSONLines decodes one JSON object per non-blank line of file.
|
|
func readJSONLines(file string) ([]map[string]json.RawMessage, error) {
|
|
f, err := os.Open(file)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("File not found: %s", file)
|
|
}
|
|
defer f.Close()
|
|
var rows []map[string]json.RawMessage
|
|
scanner := bufio.NewScanner(f)
|
|
scanner.Buffer(make([]byte, 1<<20), 64<<20)
|
|
n := 0
|
|
for scanner.Scan() {
|
|
n++
|
|
line := bytes.TrimSpace(scanner.Bytes())
|
|
if len(line) == 0 {
|
|
continue
|
|
}
|
|
var row map[string]json.RawMessage
|
|
if err := json.Unmarshal(line, &row); err != nil {
|
|
return nil, fmt.Errorf("%s line %d: %w", file, n, err)
|
|
}
|
|
rows = append(rows, row)
|
|
}
|
|
if err := scanner.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return rows, nil
|
|
}
|
|
|
|
// isNull reports a missing or null JSON value.
|
|
func isNull(raw json.RawMessage) bool {
|
|
t := bytes.TrimSpace(raw)
|
|
return len(t) == 0 || bytes.Equal(t, []byte("null"))
|
|
}
|
|
|
|
// jsonText reads a string, number or boolean as text; nil for null.
|
|
func jsonText(raw json.RawMessage) (*string, error) {
|
|
if isNull(raw) {
|
|
return nil, nil
|
|
}
|
|
var v any
|
|
if err := json.Unmarshal(raw, &v); err != nil {
|
|
return nil, err
|
|
}
|
|
var s string
|
|
switch x := v.(type) {
|
|
case string:
|
|
s = x
|
|
case float64:
|
|
s = strconv.FormatFloat(x, 'f', -1, 64)
|
|
case bool:
|
|
s = strconv.FormatBool(x)
|
|
default:
|
|
return nil, fmt.Errorf("want text, got %s", raw)
|
|
}
|
|
return &s, nil
|
|
}
|
|
|
|
// jsonUint reads a positive integer given as a number or a string; 0 for
|
|
// null.
|
|
func jsonUint(raw json.RawMessage) (uint, error) {
|
|
s, err := jsonText(raw)
|
|
if err != nil || s == nil || strings.TrimSpace(*s) == "" {
|
|
return 0, err
|
|
}
|
|
n, err := strconv.ParseUint(strings.TrimSpace(*s), 10, 32)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("want an id, got %s", raw)
|
|
}
|
|
return uint(n), nil
|
|
}
|
|
|
|
// jsonBool reads MySQL's 0 and 1, a JSON boolean or "true"/"false"; def for
|
|
// null.
|
|
func jsonBool(raw json.RawMessage, def bool) (bool, error) {
|
|
s, err := jsonText(raw)
|
|
if err != nil || s == nil {
|
|
return def, err
|
|
}
|
|
switch strings.ToLower(strings.TrimSpace(*s)) {
|
|
case "1", "true":
|
|
return true, nil
|
|
case "0", "false", "":
|
|
return false, nil
|
|
}
|
|
return false, fmt.Errorf("want a boolean, got %s", raw)
|
|
}
|
|
|
|
// jsonTime reads a MySQL DATETIME text (with or without fractions) or an
|
|
// RFC 3339 time as UTC; nil for null.
|
|
func jsonTime(raw json.RawMessage) (*time.Time, error) {
|
|
s, err := jsonText(raw)
|
|
if err != nil || s == nil || strings.TrimSpace(*s) == "" {
|
|
return nil, err
|
|
}
|
|
text := strings.TrimSpace(*s)
|
|
for _, layout := range []string{"2006-01-02 15:04:05.999999", "2006-01-02 15:04:05", time.RFC3339Nano} {
|
|
if t, err := time.ParseInLocation(layout, text, time.UTC); err == nil {
|
|
t = t.UTC()
|
|
return &t, nil
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("want a date and time, got %s", raw)
|
|
}
|