#!/bin/sh set -eu usage() { cat <<'EOF' Usage: ./script/export-collection-schema.sh \ --env \ --output-dir \ [--category ] \ [--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 // []) 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"