Normalize portable field settings during export and import, serialize collection category association updates as arrays, and ignore generated exports and macOS metadata.
299 lines
8.8 KiB
Bash
Executable File
299 lines
8.8 KiB
Bash
Executable File
#!/bin/sh
|
|
set -eu
|
|
|
|
usage() {
|
|
cat <<'EOF'
|
|
Usage:
|
|
./script/export-collection-schema.sh \
|
|
--env <environment> \
|
|
--output-dir <directory> \
|
|
[--category <NocoBase category name>] \
|
|
[--no-overwrite]
|
|
|
|
Options:
|
|
--env Required NocoBase CLI environment name.
|
|
--output-dir Required export destination.
|
|
--category Exact NocoBase Collection category name. Default: all.
|
|
--no-overwrite Refuse to replace an existing output directory.
|
|
Default: atomically replace the existing directory.
|
|
-h, --help Show this help.
|
|
|
|
Portable export of the main data source is always enabled. Output files are JSON.
|
|
|
|
Example:
|
|
./script/export-collection-schema.sh \
|
|
--env master-data-local \
|
|
--category 参考数据 \
|
|
--output-dir ./exports/reference-schema
|
|
EOF
|
|
}
|
|
|
|
ENV_NAME=
|
|
OUTPUT_DIR=
|
|
CATEGORY=
|
|
NO_OVERWRITE=0
|
|
|
|
while [ "$#" -gt 0 ]; do
|
|
case "$1" in
|
|
--env)
|
|
ENV_NAME=${2:?--env requires a value}
|
|
shift 2
|
|
;;
|
|
--output-dir)
|
|
OUTPUT_DIR=${2:?--output-dir requires a value}
|
|
shift 2
|
|
;;
|
|
--category)
|
|
CATEGORY=${2:?--category requires a value}
|
|
shift 2
|
|
;;
|
|
--no-overwrite)
|
|
NO_OVERWRITE=1
|
|
shift
|
|
;;
|
|
-h|--help)
|
|
usage
|
|
exit 0
|
|
;;
|
|
*)
|
|
echo "Unknown argument: $1" >&2
|
|
usage >&2
|
|
exit 2
|
|
;;
|
|
esac
|
|
done
|
|
|
|
[ -n "$ENV_NAME" ] || { echo "--env is required" >&2; exit 2; }
|
|
[ -n "$OUTPUT_DIR" ] || { echo "--output-dir is required" >&2; exit 2; }
|
|
command -v nb >/dev/null 2>&1 || { echo "nb CLI is required" >&2; exit 2; }
|
|
command -v jq >/dev/null 2>&1 || { echo "jq is required" >&2; exit 2; }
|
|
|
|
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
|
PROJECT_ROOT=$(dirname -- "$SCRIPT_DIR")
|
|
OUTPUT_PARENT=$(dirname -- "$OUTPUT_DIR")
|
|
OUTPUT_NAME=$(basename -- "$OUTPUT_DIR")
|
|
[ "$OUTPUT_NAME" != . ] && [ "$OUTPUT_NAME" != .. ] || {
|
|
echo "Refusing unsafe --output-dir: $OUTPUT_DIR" >&2
|
|
exit 2
|
|
}
|
|
mkdir -p "$OUTPUT_PARENT"
|
|
OUTPUT_PARENT=$(CDPATH= cd -- "$OUTPUT_PARENT" && pwd)
|
|
OUTPUT_DIR="$OUTPUT_PARENT/$OUTPUT_NAME"
|
|
|
|
case "$OUTPUT_DIR" in
|
|
/|"$HOME"|"$PROJECT_ROOT"|"$SCRIPT_DIR")
|
|
echo "Refusing unsafe --output-dir: $OUTPUT_DIR" >&2
|
|
exit 2
|
|
;;
|
|
esac
|
|
|
|
if [ "$NO_OVERWRITE" -eq 1 ] && [ -e "$OUTPUT_DIR" ]; then
|
|
echo "Output directory already exists: $OUTPUT_DIR" >&2
|
|
exit 1
|
|
fi
|
|
|
|
run_nb() {
|
|
RAW_OUTPUT=$("$@")
|
|
JSON_OUTPUT=$(printf '%s\n' "$RAW_OUTPUT" | awk '
|
|
!started && /^[[:space:]]*[\{\[]/ { started = 1 }
|
|
started { print }
|
|
')
|
|
printf '%s\n' "$JSON_OUTPUT" | jq -e . >/dev/null
|
|
printf '%s\n' "$JSON_OUTPUT"
|
|
}
|
|
|
|
validate_segment() {
|
|
case "$1" in
|
|
''|.|..|*/*|*\\*)
|
|
echo "Unsafe path segment: $1" >&2
|
|
exit 2
|
|
;;
|
|
esac
|
|
}
|
|
|
|
list_collections() {
|
|
PAGE=1
|
|
ALL='[]'
|
|
while :; do
|
|
RESPONSE=$(run_nb nb api data-modeling collections list \
|
|
--page "$PAGE" --page-size 500 -e "$ENV_NAME" -y -j)
|
|
ROWS=$(printf '%s' "$RESPONSE" | jq -c '.data // []')
|
|
ALL=$(jq -cn --argjson current "$ALL" --argjson rows "$ROWS" '$current + $rows')
|
|
TOTAL_PAGES=$(printf '%s' "$RESPONSE" | jq -r '.meta.totalPage // 1')
|
|
[ "$PAGE" -ge "$TOTAL_PAGES" ] && break
|
|
PAGE=$((PAGE + 1))
|
|
done
|
|
printf '%s\n' "$ALL"
|
|
}
|
|
|
|
load_categories() {
|
|
run_nb nb api resource list \
|
|
--resource collectionCategories \
|
|
--no-paginate \
|
|
--sort sort \
|
|
--fields name \
|
|
--appends collections \
|
|
-e "$ENV_NAME" -y -j
|
|
}
|
|
|
|
read_collection() {
|
|
COLLECTION_NAME=$1
|
|
run_nb nb api data-modeling collections get \
|
|
--filter-by-tk "$COLLECTION_NAME" \
|
|
--appends fields \
|
|
-e "$ENV_NAME" -y -j
|
|
}
|
|
|
|
portable_schema() {
|
|
jq '
|
|
.data as $collection
|
|
| (($collection.fields // []) | map(
|
|
. as $field
|
|
| (($field | del(.settings)) + ($field.settings // {}))
|
|
)) as $fields
|
|
| [$fields[]
|
|
| select(.interface == "m2o" or .interface == "o2m" or .interface == "m2m" or .interface == "o2o")
|
|
| .foreignKey
|
|
| select(. != null)] as $foreignKeys
|
|
| ($collection.template // "general") as $template
|
|
| ($collection | del(.key, .fields, .verify, .category))
|
|
+ {
|
|
fields: [
|
|
$fields[]
|
|
| . as $field
|
|
| select(["id", "createdAt", "createdBy", "updatedAt", "updatedBy", "createdById", "updatedById"] | index($field.name) | not)
|
|
| select(
|
|
if $template == "file" then
|
|
["title", "filename", "extname", "size", "mimetype", "path", "url", "preview", "storage", "storageId", "meta"]
|
|
| index($field.name) | not
|
|
elif $template == "tree" then
|
|
["parentId", "parent", "children"] | index($field.name) | not
|
|
else true end
|
|
)
|
|
| select(
|
|
($foreignKeys | index($field.name) | not)
|
|
or ($field.interface == "m2o" or $field.interface == "o2m" or $field.interface == "m2m" or $field.interface == "o2o")
|
|
)
|
|
| del(.key, .collectionName, .parentKey, .reverseKey, .possibleTypes)
|
|
| walk(
|
|
if type == "object" and has("rules") and (.rules | type) == "array" then
|
|
.rules |= map(del(.key))
|
|
else . end
|
|
)
|
|
] | sort_by(.name)
|
|
}
|
|
'
|
|
}
|
|
|
|
COLLECTIONS=$(list_collections)
|
|
CATEGORIES=$(load_categories)
|
|
|
|
KNOWN_CATEGORIES=$(printf '%s' "$CATEGORIES" | jq -r '[.data[]?.name, "未分类"] | unique | join(", ")')
|
|
if [ -n "$CATEGORY" ] && ! printf '%s' "$CATEGORIES" | jq -e --arg category "$CATEGORY" '
|
|
any(.data[]?; .name == $category) or $category == "未分类"
|
|
' >/dev/null; then
|
|
echo "Unknown category: $CATEGORY; available: $KNOWN_CATEGORIES" >&2
|
|
exit 1
|
|
fi
|
|
|
|
SELECTION=$(printf '%s' "$COLLECTIONS" | jq -c \
|
|
--argjson categoryData "$CATEGORIES" \
|
|
--arg selected "$CATEGORY" '
|
|
[
|
|
.[]
|
|
| . as $collection
|
|
| [$categoryData.data[]?
|
|
| select(any(.collections[]?; .name == $collection.name))
|
|
| .name] as $assigned
|
|
| (if ($assigned | length) == 0 then ["未分类"] else $assigned end) as $categories
|
|
| {
|
|
collection: $collection,
|
|
categories: ($categories | map(select($selected == "" or . == $selected)))
|
|
}
|
|
| select((.categories | length) > 0)
|
|
]
|
|
')
|
|
|
|
TEMP_DIR=$(mktemp -d "$OUTPUT_PARENT/.${OUTPUT_NAME}.tmp.XXXXXX")
|
|
MANIFEST_ROWS="$TEMP_DIR/.manifest-rows.jsonl"
|
|
: > "$MANIFEST_ROWS"
|
|
trap 'rm -rf "$TEMP_DIR"' EXIT HUP INT TERM
|
|
|
|
INDEX=0
|
|
COUNT=$(printf '%s' "$SELECTION" | jq 'length')
|
|
while [ "$INDEX" -lt "$COUNT" ]; do
|
|
ITEM=$(printf '%s' "$SELECTION" | jq -c ".[$INDEX]")
|
|
COLLECTION_NAME=$(printf '%s' "$ITEM" | jq -r '.collection.name')
|
|
validate_segment "$COLLECTION_NAME"
|
|
SCHEMA=$(read_collection "$COLLECTION_NAME" | portable_schema)
|
|
CATEGORY_COUNT=$(printf '%s' "$ITEM" | jq '.categories | length')
|
|
CATEGORY_INDEX=0
|
|
while [ "$CATEGORY_INDEX" -lt "$CATEGORY_COUNT" ]; do
|
|
CATEGORY_NAME=$(printf '%s' "$ITEM" | jq -r ".categories[$CATEGORY_INDEX]")
|
|
validate_segment "$CATEGORY_NAME"
|
|
RELATIVE_FILE="main/$CATEGORY_NAME/$COLLECTION_NAME.json"
|
|
TARGET_FILE="$TEMP_DIR/$RELATIVE_FILE"
|
|
mkdir -p "$(dirname -- "$TARGET_FILE")"
|
|
printf '%s\n' "$SCHEMA" > "$TARGET_FILE"
|
|
jq -cn \
|
|
--arg dataSource main \
|
|
--arg category "$CATEGORY_NAME" \
|
|
--arg collection "$COLLECTION_NAME" \
|
|
--arg file "$RELATIVE_FILE" \
|
|
'{dataSource:$dataSource, category:$category, collection:$collection, file:$file}' \
|
|
>> "$MANIFEST_ROWS"
|
|
CATEGORY_INDEX=$((CATEGORY_INDEX + 1))
|
|
done
|
|
INDEX=$((INDEX + 1))
|
|
done
|
|
|
|
FILES=$(jq -s 'sort_by(.file)' "$MANIFEST_ROWS")
|
|
DISCOVERED=$(printf '%s' "$COLLECTIONS" | jq 'length')
|
|
EXPORTED_FILES=$(printf '%s' "$FILES" | jq 'length')
|
|
GENERATED_AT=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
|
|
OVERWRITE_POLICY=replace-existing
|
|
[ "$NO_OVERWRITE" -eq 1 ] && OVERWRITE_POLICY=refuse-existing
|
|
|
|
jq -n \
|
|
--arg environment "$ENV_NAME" \
|
|
--arg dataSource main \
|
|
--arg category "${CATEGORY:-all}" \
|
|
--arg generatedAt "$GENERATED_AT" \
|
|
--arg overwritePolicy "$OVERWRITE_POLICY" \
|
|
--argjson discoveredCollections "$DISCOVERED" \
|
|
--argjson exportedCollections "$COUNT" \
|
|
--argjson exportedFiles "$EXPORTED_FILES" \
|
|
--argjson files "$FILES" \
|
|
'{
|
|
environment:$environment,
|
|
dataSource:$dataSource,
|
|
category:$category,
|
|
profile:"portable",
|
|
generatedAt:$generatedAt,
|
|
overwritePolicy:$overwritePolicy,
|
|
discoveredCollections:$discoveredCollections,
|
|
exportedCollections:$exportedCollections,
|
|
exportedFiles:$exportedFiles,
|
|
files:$files
|
|
}' > "$TEMP_DIR/manifest.json"
|
|
|
|
rm -f "$MANIFEST_ROWS"
|
|
|
|
if [ -e "$OUTPUT_DIR" ]; then
|
|
BACKUP_DIR="$OUTPUT_DIR.previous.$$"
|
|
mv "$OUTPUT_DIR" "$BACKUP_DIR"
|
|
if mv "$TEMP_DIR" "$OUTPUT_DIR"; then
|
|
rm -rf "$BACKUP_DIR"
|
|
else
|
|
mv "$BACKUP_DIR" "$OUTPUT_DIR"
|
|
exit 1
|
|
fi
|
|
else
|
|
mv "$TEMP_DIR" "$OUTPUT_DIR"
|
|
fi
|
|
trap - EXIT HUP INT TERM
|
|
|
|
jq --arg outputDir "$OUTPUT_DIR" '
|
|
del(.files) + {outputDir:$outputDir}
|
|
' "$OUTPUT_DIR/manifest.json"
|