Merge remote-tracking branch 'upstream/main'
This commit is contained in:
commit
904e550b75
370 changed files with 82780 additions and 3799 deletions
70
.github/workflows/build-docker-internal.yml
vendored
Normal file
70
.github/workflows/build-docker-internal.yml
vendored
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
name: Push docker images to internal registry
|
||||
|
||||
on:
|
||||
# push:
|
||||
# branches: [main]
|
||||
# paths:
|
||||
# - ".github/workflows/docker.yml"
|
||||
workflow_run:
|
||||
workflows: ["Build Linux Artifacts"]
|
||||
types:
|
||||
- completed
|
||||
|
||||
env:
|
||||
VERSION: 0.10.5
|
||||
jobs:
|
||||
build:
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Get Version
|
||||
run: |
|
||||
echo VERSION=$(cat share/const.go |grep 'const VERSION' | awk '{print $4}' | sed "s/\"//g")-unstable >> $GITHUB_ENV
|
||||
|
||||
- name: Check Version
|
||||
run: echo $VERSION
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: hub.iqka.com
|
||||
username: ${{ secrets.DOCKER_INTERNAL_USER }}
|
||||
password: ${{ secrets.DOCKER_INTERNAL_TOKEN }}
|
||||
|
||||
- name: Build Production
|
||||
timeout-minutes: 60
|
||||
uses: docker/build-push-action@v6
|
||||
env:
|
||||
DOCKER_CONTENT_TRUST: 1
|
||||
with:
|
||||
context: ./docker/production
|
||||
platforms: linux/amd64
|
||||
build-args: |
|
||||
VERSION=${{ env.VERSION }}
|
||||
ARCH=amd64
|
||||
push: true
|
||||
tags: hub.iqka.com/yaoapp/yao:${{ env.VERSION }}-amd64
|
||||
|
||||
- name: Build Production Arm64
|
||||
timeout-minutes: 60
|
||||
uses: docker/build-push-action@v6
|
||||
env:
|
||||
DOCKER_CONTENT_TRUST: 1
|
||||
with:
|
||||
context: ./docker/production
|
||||
platforms: linux/arm64
|
||||
build-args: |
|
||||
VERSION=${{ env.VERSION }}
|
||||
ARCH=arm64
|
||||
push: true
|
||||
tags: hub.iqka.com/yaoapp/yao:${{ env.VERSION }}-arm64
|
||||
92
.github/workflows/build-docker.yml
vendored
Normal file
92
.github/workflows/build-docker.yml
vendored
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
name: Build and push docker images
|
||||
|
||||
on:
|
||||
# push:
|
||||
# branches: [main]
|
||||
# paths:
|
||||
# - ".github/workflows/docker.yml"
|
||||
workflow_run:
|
||||
workflows: ["Build Linux Artifacts"]
|
||||
types:
|
||||
- completed
|
||||
|
||||
env:
|
||||
VERSION: 0.10.5
|
||||
jobs:
|
||||
build:
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Get Version
|
||||
run: |
|
||||
echo VERSION=$(cat share/const.go |grep 'const VERSION' | awk '{print $4}' | sed "s/\"//g")-unstable >> $GITHUB_ENV
|
||||
|
||||
- name: Check Version
|
||||
run: echo $VERSION
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
|
||||
- name: Build Development
|
||||
uses: docker/build-push-action@v6
|
||||
env:
|
||||
DOCKER_CONTENT_TRUST: 1
|
||||
with:
|
||||
context: ./docker/development
|
||||
platforms: linux/amd64
|
||||
build-args: |
|
||||
VERSION=${{ env.VERSION }}
|
||||
ARCH=amd64
|
||||
push: true
|
||||
tags: yaoapp/yao:${{ env.VERSION }}-amd64-dev
|
||||
|
||||
- name: Build Development Arm64
|
||||
uses: docker/build-push-action@v6
|
||||
env:
|
||||
DOCKER_CONTENT_TRUST: 1
|
||||
with:
|
||||
context: ./docker/development
|
||||
platforms: linux/arm64
|
||||
build-args: |
|
||||
VERSION=${{ env.VERSION }}
|
||||
ARCH=arm64
|
||||
push: true
|
||||
tags: yaoapp/yao:${{ env.VERSION }}-arm64-dev
|
||||
|
||||
- name: Build Production
|
||||
uses: docker/build-push-action@v6
|
||||
env:
|
||||
DOCKER_CONTENT_TRUST: 1
|
||||
with:
|
||||
context: ./docker/production
|
||||
platforms: linux/amd64
|
||||
build-args: |
|
||||
VERSION=${{ env.VERSION }}
|
||||
ARCH=amd64
|
||||
push: true
|
||||
tags: yaoapp/yao:${{ env.VERSION }}-amd64
|
||||
|
||||
- name: Build Production Arm64
|
||||
uses: docker/build-push-action@v6
|
||||
env:
|
||||
DOCKER_CONTENT_TRUST: 1
|
||||
with:
|
||||
context: ./docker/production
|
||||
platforms: linux/arm64
|
||||
build-args: |
|
||||
VERSION=${{ env.VERSION }}
|
||||
ARCH=arm64
|
||||
push: true
|
||||
tags: yaoapp/yao:${{ env.VERSION }}-arm64
|
||||
24
.github/workflows/build-linux.yml
vendored
24
.github/workflows/build-linux.yml
vendored
|
|
@ -9,14 +9,24 @@ on:
|
|||
jobs:
|
||||
build:
|
||||
runs-on: "ubuntu-latest"
|
||||
strategy:
|
||||
matrix:
|
||||
go: [1.23.4]
|
||||
container:
|
||||
image: yaoapp/yao-build:0.10.5
|
||||
|
||||
env:
|
||||
CF_ACCESS_KEY_ID: ${{ secrets.CF_ACCESS_KEY_ID }}
|
||||
CF_SECRET_ACCESS_KEY: ${{ secrets.CF_SECRET_ACCESS_KEY }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
|
||||
|
||||
steps:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- name: Configure R2 For Cloudflare
|
||||
run: |
|
||||
aws configure set aws_access_key_id $CF_ACCESS_KEY_ID
|
||||
aws configure set aws_secret_access_key $CF_SECRET_ACCESS_KEY
|
||||
aws configure set default.region us-east-1 # Update with your R2 region if different
|
||||
aws configure set default.s3.signature_version s3v4
|
||||
aws configure set default.s3.endpoint_url https://$R2_ACCOUNT_ID.r2.cloudflarestorage.com
|
||||
aws --version
|
||||
|
||||
- name: Install pnpm
|
||||
run: npm install -g pnpm
|
||||
|
|
|
|||
25
.github/workflows/build-macos.yml
vendored
25
.github/workflows/build-macos.yml
vendored
|
|
@ -2,15 +2,18 @@ name: Build MacOS Artifacts
|
|||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
inputs:
|
||||
tags:
|
||||
description: "Version tags"
|
||||
|
||||
env:
|
||||
VERSION: 0.10.5
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
matrix:
|
||||
go: [1.23.4]
|
||||
go: [1.24.3]
|
||||
runs-on: "macos-latest"
|
||||
steps:
|
||||
- name: Setup Node.js
|
||||
|
|
@ -65,11 +68,13 @@ jobs:
|
|||
rm -rf $dir/__MACOSX
|
||||
done
|
||||
|
||||
- name: Checkout XGen v1.0
|
||||
- name: Checkout CUI v1.0
|
||||
# ** XGEN will be renamed to CUI in the feature. and move to the new repository. **
|
||||
# ** new repository: https://github.com/YaoApp/cui.git **
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: sjzsdu/xgen
|
||||
path: xgen-v1.0
|
||||
repository: yaoapp/cui
|
||||
path: cui-v1.0
|
||||
|
||||
- name: Checkout Yao-Init
|
||||
uses: actions/checkout@v4
|
||||
|
|
@ -83,12 +88,12 @@ jobs:
|
|||
mv xun ../
|
||||
mv gou ../
|
||||
mv v8go ../
|
||||
mv xgen-v1.0 ../
|
||||
mv cui-v1.0 ../
|
||||
mv yao-init ../
|
||||
rm -f ../xgen-v1.0/packages/setup/vite.config.ts.*
|
||||
rm -f ../cui-v1.0/packages/setup/vite.config.ts.*
|
||||
ls -l .
|
||||
ls -l ../
|
||||
ls -l ../xgen-v1.0/packages/setup/
|
||||
ls -l ../cui-v1.0/packages/setup/
|
||||
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
|
|
|
|||
376
.github/workflows/pr-test.yml
vendored
Normal file
376
.github/workflows/pr-test.yml
vendored
Normal file
|
|
@ -0,0 +1,376 @@
|
|||
name: PR Unit Test
|
||||
|
||||
# read-write repo token
|
||||
# access to secrets
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Receive PR"]
|
||||
types:
|
||||
- completed
|
||||
env:
|
||||
YAO_DEV: ${{ github.WORKSPACE }}
|
||||
YAO_ENV: development
|
||||
YAO_ROOT: ${{ github.WORKSPACE }}/../app
|
||||
YAO_HOST: 0.0.0.0
|
||||
YAO_PORT: 5099
|
||||
YAO_SESSION: "memory"
|
||||
YAO_LOG: "./logs/application.log"
|
||||
YAO_LOG_MODE: "TEXT"
|
||||
YAO_JWT_SECRET: "bLp@bi!oqo-2U+hoTRUG"
|
||||
YAO_DB_AESKEY: "ZLX=T&f6refeCh-ro*r@"
|
||||
OSS_TEST_ID: ${{ secrets.OSS_TEST_ID}}
|
||||
OSS_TEST_SECRET: ${{ secrets.OSS_TEST_SECRET}}
|
||||
ROOT_PLUGIN: ${{ github.WORKSPACE }}/../../../data/gou-unit/plugins
|
||||
|
||||
MYSQL_TEST_HOST: "127.0.0.1"
|
||||
MYSQL_TEST_PORT: "3308"
|
||||
MYSQL_TEST_USER: test
|
||||
MYSQL_TEST_PASS: "123456"
|
||||
|
||||
SQLITE_DB: "./app/db/yao.db"
|
||||
|
||||
REDIS_TEST_HOST: "127.0.0.1"
|
||||
REDIS_TEST_PORT: "6379"
|
||||
REDIS_TEST_DB: "2"
|
||||
|
||||
MONGO_TEST_HOST: "127.0.0.1"
|
||||
MONGO_TEST_PORT: "27017"
|
||||
MONGO_TEST_USER: "root"
|
||||
MONGO_TEST_PASS: "123456"
|
||||
|
||||
OPENAI_TEST_KEY: ${{ secrets.OPENAI_TEST_KEY }}
|
||||
TEST_MOAPI_SECRET: ${{ secrets.OPENAI_TEST_KEY }}
|
||||
TEST_MOAPI_MIRROR: https://api.openai.com
|
||||
|
||||
TAB_NAME: "::PET ADMIN"
|
||||
PAGE_SIZE: "20"
|
||||
PAGE_LINK: "https://yaoapps.com"
|
||||
PAGE_ICON: "icon-trash"
|
||||
|
||||
DEMO_APP: ${{ github.WORKSPACE }}/../app
|
||||
|
||||
# Application Setting
|
||||
|
||||
## Path
|
||||
YAO_EXTENSION_ROOT: ${{ github.WORKSPACE }}/../extension
|
||||
YAO_TEST_APPLICATION: ${{ github.WORKSPACE }}/../app
|
||||
YAO_SUI_TEST_APPLICATION: ${{ github.WORKSPACE }}/../yao-startup-webapp
|
||||
|
||||
## Runtime
|
||||
YAO_RUNTIME_MIN: 3
|
||||
YAO_RUNTIME_MAX: 6
|
||||
YAO_RUNTIME_HEAP_LIMIT: 1500000000
|
||||
YAO_RUNTIME_HEAP_RELEASE: 10000000
|
||||
YAO_RUNTIME_HEAP_AVAILABLE: 550000000
|
||||
YAO_RUNTIME_PRECOMPILE: true
|
||||
|
||||
# Neo4j
|
||||
NEO4J_TEST_URL: "neo4j://localhost:7686"
|
||||
NEO4J_TEST_USER: "neo4j"
|
||||
NEO4J_TEST_PASS: "Yao2026Neo4j"
|
||||
|
||||
# Qdrant
|
||||
QDRANT_TEST_HOST: "127.0.0.1"
|
||||
QDRANT_TEST_PORT: "6334"
|
||||
|
||||
# S3
|
||||
S3_API: ${{ secrets.S3_API }}
|
||||
S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }}
|
||||
S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }}
|
||||
S3_BUCKET: ${{ secrets.S3_BUCKET }}
|
||||
S3_PUBLIC_URL: ${{ secrets.S3_PUBLIC_URL }}
|
||||
|
||||
# === Openapi Signin Configs ===
|
||||
## Google
|
||||
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
|
||||
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
|
||||
|
||||
## Microsoft
|
||||
MICROSOFT_CLIENT_ID: ${{ secrets.MICROSOFT_CLIENT_ID }}
|
||||
MICROSOFT_CLIENT_SECRET: ${{ secrets.MICROSOFT_CLIENT_SECRET }}
|
||||
|
||||
## Apple
|
||||
APPLE_SERVICE_ID: ${{ secrets.APPLE_SERVICE_ID }}
|
||||
APPLE_PRIVATE_KEY_PATH: "apple/signin_client_secret_key.p8"
|
||||
APPLE_KEY_ID: ${{ secrets.APPLE_KEY_ID }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
|
||||
## Github
|
||||
GITHUBUSER_CLIENT_ID: ${{ secrets.GITHUBUSER_CLIENT_ID }}
|
||||
GITHUBUSER_CLIENT_SECRET: ${{ secrets.GITHUBUSER_CLIENT_SECRET }}
|
||||
|
||||
## Cloudflare Turnstile
|
||||
CLOUDFLARE_TURNSTILE_SITEKEY: ${{ secrets.CLOUDFLARE_TURNSTILE_SITEKEY }}
|
||||
CLOUDFLARE_TURNSTILE_SECRET: ${{ secrets.CLOUDFLARE_TURNSTILE_SECRET }}
|
||||
|
||||
jobs:
|
||||
UnitTest:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
services:
|
||||
qdrant:
|
||||
image: qdrant/qdrant:latest
|
||||
ports:
|
||||
- 6333:6333 # HTTP API
|
||||
- 6334:6334 # gRPC
|
||||
|
||||
fastembed:
|
||||
image: yaoapp/fastembed:latest-amd64
|
||||
env:
|
||||
FASTEMBED_PASSWORD: Yao@2026
|
||||
ports:
|
||||
- 6001:8000
|
||||
|
||||
neo4j:
|
||||
image: neo4j:latest
|
||||
ports:
|
||||
- "7687:7687"
|
||||
env:
|
||||
NEO4J_AUTH: neo4j/Yao2026Neo4j
|
||||
|
||||
mcp-everything:
|
||||
image: yaoapp/mcp-everything:latest
|
||||
ports:
|
||||
- "3021:3021"
|
||||
- "3022:3022"
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
go: [1.24]
|
||||
db: [MySQL8.0, MySQL5.7, SQLite3]
|
||||
redis: [4, 5, 6]
|
||||
mongo: ["6.0"]
|
||||
if: >
|
||||
${{ github.event.workflow_run.event == 'pull_request' &&
|
||||
github.event.workflow_run.conclusion == 'success' }}
|
||||
steps:
|
||||
- name: "Download artifact"
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
var artifacts = await github.rest.actions.listWorkflowRunArtifacts({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
run_id: ${{github.event.workflow_run.id }},
|
||||
});
|
||||
var matchArtifact = artifacts.data.artifacts.filter((artifact) => {
|
||||
return artifact.name == "pr"
|
||||
})[0];
|
||||
var download = await github.rest.actions.downloadArtifact({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
artifact_id: matchArtifact.id,
|
||||
archive_format: 'zip',
|
||||
});
|
||||
var fs = require('fs');
|
||||
fs.writeFileSync('${{github.workspace}}/pr.zip', Buffer.from(download.data));
|
||||
|
||||
- name: "Read NR & SHA"
|
||||
run: |
|
||||
unzip pr.zip
|
||||
cat NR
|
||||
cat SHA
|
||||
echo HEAD=$(cat SHA) >> $GITHUB_ENV
|
||||
echo NR=$(cat NR) >> $GITHUB_ENV
|
||||
|
||||
- name: "Comment on PR"
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const { NR } = process.env
|
||||
var fs = require('fs');
|
||||
var issue_number = NR;
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue_number,
|
||||
body: 'Thank you for the PR! The db: ${{ matrix.db }} redis: ${{ matrix.redis }} mongo: ${{ matrix.mongo }} test workflow is running, the results of the run will be commented later.'
|
||||
});
|
||||
|
||||
- name: Checkout Kun
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: yaoapp/kun
|
||||
path: kun
|
||||
|
||||
- name: Checkout Xun
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: yaoapp/xun
|
||||
path: xun
|
||||
|
||||
- name: Checkout Gou
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: yaoapp/gou
|
||||
path: gou
|
||||
|
||||
- name: Checkout V8Go
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: yaoapp/v8go
|
||||
path: v8go
|
||||
|
||||
- name: Unzip libv8
|
||||
run: |
|
||||
files=$(find ./v8go -name "libv8*.zip")
|
||||
for file in $files; do
|
||||
dir=$(dirname "$file") # Get the directory where the ZIP file is located
|
||||
echo "Extracting $file to directory $dir"
|
||||
unzip -o -d $dir $file
|
||||
rm -rf $dir/__MACOSX
|
||||
done
|
||||
|
||||
- name: Checkout Demo App
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: yaoapp/yao-dev-app
|
||||
path: app
|
||||
|
||||
- name: Checkout Yao Startup Webapp
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: yaoapp/yao-startup-webapp
|
||||
submodules: true
|
||||
token: ${{ secrets.YAO_TEST_TOKEN }}
|
||||
path: yao-startup-webapp
|
||||
|
||||
- name: Checkout Extension
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: yaoapp/yao-extensions-dev
|
||||
path: extension
|
||||
|
||||
- name: Move Kun, Xun, Gou, V8Go
|
||||
run: |
|
||||
mv kun ../
|
||||
mv xun ../
|
||||
mv gou ../
|
||||
mv v8go ../
|
||||
mv app ../
|
||||
mv extension ../
|
||||
mv yao-startup-webapp ../
|
||||
ls -l .
|
||||
ls -l ../
|
||||
|
||||
- name: Checkout pull request HEAD commit
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ env.HEAD }}
|
||||
|
||||
- name: Setup Apple Private Key
|
||||
run: |
|
||||
mkdir -p ../app/openapi/certs/apple
|
||||
echo "${{ secrets.APPLE_PRIVATE_KEY_USER }}" > ../app/openapi/certs/apple/signin_client_secret_key.p8
|
||||
|
||||
- name: Start Redis
|
||||
uses: supercharge/redis-github-action@1.4.0
|
||||
with:
|
||||
redis-version: ${{ matrix.redis }}
|
||||
|
||||
- name: Setup Go ${{ matrix.go }}
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ matrix.go }}
|
||||
|
||||
- name: Install FFmpeg 7.x
|
||||
run: |
|
||||
wget https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-linux64-gpl.tar.xz
|
||||
tar -xf ffmpeg-master-latest-linux64-gpl.tar.xz
|
||||
sudo cp ffmpeg-master-latest-linux64-gpl/bin/ffmpeg /usr/local/bin/
|
||||
sudo cp ffmpeg-master-latest-linux64-gpl/bin/ffprobe /usr/local/bin/
|
||||
sudo chmod +x /usr/local/bin/ffmpeg /usr/local/bin/ffprobe
|
||||
|
||||
- name: Test FFmpeg
|
||||
run: ffmpeg -version
|
||||
|
||||
- name: Install pdftoppm, mutool, imagemagick
|
||||
run: |
|
||||
sudo apt update
|
||||
sudo apt install -y poppler-utils mupdf-tools imagemagick
|
||||
|
||||
- name: Test pdftoppm, mutool, imagemagick
|
||||
run: |
|
||||
pdftoppm -v
|
||||
mutool -v
|
||||
convert -version
|
||||
|
||||
- name: Start MongoDB
|
||||
uses: supercharge/mongodb-github-action@1.8.0
|
||||
with:
|
||||
mongodb-version: ${{ matrix.mongo }}
|
||||
mongodb-username: root
|
||||
mongodb-password: 123456
|
||||
mongodb-db: test
|
||||
|
||||
- name: Setup MySQL8.0 (connector)
|
||||
uses: ./.github/actions/setup-db
|
||||
with:
|
||||
kind: "MySQL8.0"
|
||||
db: "test"
|
||||
user: "test"
|
||||
password: "123456"
|
||||
port: "3308"
|
||||
|
||||
- name: Setup ${{ matrix.db }}
|
||||
uses: ./.github/actions/setup-db
|
||||
with:
|
||||
kind: "${{ matrix.db }}"
|
||||
db: "xiang"
|
||||
user: "xiang"
|
||||
password: ${{ secrets.UNIT_PASS }}
|
||||
|
||||
- name: Setup Go Tools
|
||||
run: |
|
||||
make tools
|
||||
|
||||
- name: Setup ENV & Host
|
||||
env:
|
||||
PASSWORD: ${{ secrets.UNIT_PASS }}
|
||||
run: |
|
||||
sudo echo "127.0.0.1 local.iqka.com" | sudo tee -a /etc/hosts
|
||||
echo "YAO_DB_DRIVER=$DB_DRIVER" >> $GITHUB_ENV
|
||||
echo "GITHUB_WORKSPACE:\n" && ls -l $GITHUB_WORKSPACE
|
||||
|
||||
if [ "$DB_DRIVER" = "mysql" ]; then
|
||||
echo "YAO_DB_PRIMARY=$DB_USER:$PASSWORD@$DB_HOST" >> $GITHUB_ENV
|
||||
elif [ "$DB_DRIVER" = "postgres" ]; then
|
||||
echo "YAO_DB_PRIMARY=postgres://$DB_USER:$PASSWORD@$DB_HOST" >> $GITHUB_ENV
|
||||
else
|
||||
echo "YAO_DB_PRIMARY=$YAO_ROOT/$DB_HOST" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
echo ".:\n" && ls -l .
|
||||
echo "..:\n" && ls -l ..
|
||||
ping -c 1 -t 1 local.iqka.com
|
||||
|
||||
- name: Test Prepare
|
||||
run: |
|
||||
make vet
|
||||
make fmt-check
|
||||
make misspell-check
|
||||
|
||||
- name: Run test
|
||||
run: |
|
||||
make test
|
||||
|
||||
- name: Codecov Report
|
||||
uses: codecov/codecov-action@v4
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }} # not required for public repos
|
||||
|
||||
- name: "Comment on PR"
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const { NR } = process.env
|
||||
var fs = require('fs');
|
||||
var issue_number = NR;
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue_number,
|
||||
body: '✨DONE✨ db: ${{ matrix.db }} redis: ${{ matrix.redis }} mongo: ${{ matrix.mongo }} passed.'
|
||||
});
|
||||
323
.github/workflows/unit-test.yml
vendored
Normal file
323
.github/workflows/unit-test.yml
vendored
Normal file
|
|
@ -0,0 +1,323 @@
|
|||
name: Unit Test
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tags:
|
||||
description: "Version"
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
env:
|
||||
YAO_DEV: ${{ github.WORKSPACE }}
|
||||
YAO_ENV: development
|
||||
YAO_ROOT: ${{ github.WORKSPACE }}/../app
|
||||
YAO_HOST: 0.0.0.0
|
||||
YAO_PORT: 5099
|
||||
YAO_SESSION: "memory"
|
||||
YAO_LOG: "./logs/application.log"
|
||||
YAO_LOG_MODE: "TEXT"
|
||||
YAO_JWT_SECRET: "bLp@bi!oqo-2U+hoTRUG"
|
||||
YAO_DB_AESKEY: "ZLX=T&f6refeCh-ro*r@"
|
||||
OSS_TEST_ID: ${{ secrets.OSS_TEST_ID}}
|
||||
OSS_TEST_SECRET: ${{ secrets.OSS_TEST_SECRET}}
|
||||
ROOT_PLUGIN: ${{ github.WORKSPACE }}/../../../data/gou-unit/plugins
|
||||
REPO_KUN: ${{ github.repository_owner }}/kun
|
||||
REPO_XUN: ${{ github.repository_owner }}/xun
|
||||
REPO_GOU: ${{ github.repository_owner }}/gou
|
||||
|
||||
MYSQL_TEST_HOST: "127.0.0.1"
|
||||
MYSQL_TEST_PORT: "3308"
|
||||
MYSQL_TEST_USER: test
|
||||
MYSQL_TEST_PASS: "123456"
|
||||
|
||||
SQLITE_DB: "./app/db/yao.db"
|
||||
|
||||
REDIS_TEST_HOST: "127.0.0.1"
|
||||
REDIS_TEST_PORT: "6379"
|
||||
REDIS_TEST_DB: "2"
|
||||
|
||||
MONGO_TEST_HOST: "127.0.0.1"
|
||||
MONGO_TEST_PORT: "27017"
|
||||
MONGO_TEST_USER: "root"
|
||||
MONGO_TEST_PASS: "123456"
|
||||
|
||||
OPENAI_TEST_KEY: ${{ secrets.OPENAI_TEST_KEY }}
|
||||
TEST_MOAPI_SECRET: ${{ secrets.OPENAI_TEST_KEY }}
|
||||
TEST_MOAPI_MIRROR: https://api.openai.com
|
||||
|
||||
TAB_NAME: "::PET ADMIN"
|
||||
PAGE_SIZE: "20"
|
||||
PAGE_LINK: "https://yaoapps.com"
|
||||
PAGE_ICON: "icon-trash"
|
||||
|
||||
DEMO_APP: ${{ github.WORKSPACE }}/../app
|
||||
|
||||
# Application Setting
|
||||
|
||||
## Path
|
||||
YAO_EXTENSION_ROOT: ${{ github.WORKSPACE }}/../extension
|
||||
YAO_TEST_APPLICATION: ${{ github.WORKSPACE }}/../app
|
||||
YAO_SUI_TEST_APPLICATION: ${{ github.WORKSPACE }}/../yao-startup-webapp
|
||||
|
||||
## Runtime
|
||||
YAO_RUNTIME_MIN: 3
|
||||
YAO_RUNTIME_MAX: 6
|
||||
YAO_RUNTIME_HEAP_LIMIT: 1500000000
|
||||
YAO_RUNTIME_HEAP_RELEASE: 10000000
|
||||
YAO_RUNTIME_HEAP_AVAILABLE: 550000000
|
||||
YAO_RUNTIME_PRECOMPILE: true
|
||||
|
||||
# Neo4j
|
||||
NEO4J_TEST_URL: "neo4j://localhost:7686"
|
||||
NEO4J_TEST_USER: "neo4j"
|
||||
NEO4J_TEST_PASS: "Yao2026Neo4j"
|
||||
|
||||
# Qdrant
|
||||
QDRANT_TEST_HOST: "127.0.0.1"
|
||||
QDRANT_TEST_PORT: "6334"
|
||||
|
||||
# S3
|
||||
S3_API: ${{ secrets.S3_API }}
|
||||
S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }}
|
||||
S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }}
|
||||
S3_BUCKET: ${{ secrets.S3_BUCKET }}
|
||||
S3_PUBLIC_URL: ${{ secrets.S3_PUBLIC_URL }}
|
||||
|
||||
|
||||
# === Openapi Signin Configs ===
|
||||
## Google
|
||||
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
|
||||
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
|
||||
|
||||
## Microsoft
|
||||
MICROSOFT_CLIENT_ID: ${{ secrets.MICROSOFT_CLIENT_ID }}
|
||||
MICROSOFT_CLIENT_SECRET: ${{ secrets.MICROSOFT_CLIENT_SECRET }}
|
||||
|
||||
## Apple
|
||||
APPLE_SERVICE_ID: ${{ secrets.APPLE_SERVICE_ID }}
|
||||
APPLE_PRIVATE_KEY_PATH: "apple/signin_client_secret_key.p8"
|
||||
APPLE_KEY_ID: ${{ secrets.APPLE_KEY_ID }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
|
||||
## Github
|
||||
GITHUBUSER_CLIENT_ID: ${{ secrets.GITHUBUSER_CLIENT_ID }}
|
||||
GITHUBUSER_CLIENT_SECRET: ${{ secrets.GITHUBUSER_CLIENT_SECRET }}
|
||||
|
||||
## Cloudflare Turnstile
|
||||
CLOUDFLARE_TURNSTILE_SITEKEY: ${{ secrets.CLOUDFLARE_TURNSTILE_SITEKEY }}
|
||||
CLOUDFLARE_TURNSTILE_SECRET: ${{ secrets.CLOUDFLARE_TURNSTILE_SECRET }}
|
||||
|
||||
jobs:
|
||||
unit-test:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
qdrant:
|
||||
image: qdrant/qdrant:latest
|
||||
ports:
|
||||
- 6333:6333 # HTTP API
|
||||
- 6334:6334 # gRPC
|
||||
|
||||
fastembed:
|
||||
image: yaoapp/fastembed:latest-amd64
|
||||
env:
|
||||
FASTEMBED_PASSWORD: Yao@2026
|
||||
ports:
|
||||
- 6001:8000
|
||||
|
||||
neo4j:
|
||||
image: neo4j:latest
|
||||
ports:
|
||||
- "7687:7687"
|
||||
env:
|
||||
NEO4J_AUTH: neo4j/Yao2026Neo4j
|
||||
|
||||
mcp-everything:
|
||||
image: yaoapp/mcp-everything:latest
|
||||
ports:
|
||||
- "3021:3021"
|
||||
- "3022:3022"
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
go: [1.24]
|
||||
db: [MySQL8.0, MySQL5.7, SQLite3]
|
||||
redis: [4, 5, 6]
|
||||
mongo: ["6.0"]
|
||||
steps:
|
||||
- name: Checkout Kun
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: ${{ env.REPO_KUN }}
|
||||
path: kun
|
||||
|
||||
- name: Checkout Xun
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: ${{ env.REPO_XUN }}
|
||||
path: xun
|
||||
|
||||
- name: Checkout Gou
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: ${{ env.REPO_GOU }}
|
||||
path: gou
|
||||
|
||||
- name: Checkout V8Go
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: yaoapp/v8go
|
||||
path: v8go
|
||||
|
||||
- name: Unzip libv8
|
||||
run: |
|
||||
files=$(find ./v8go -name "libv8*.zip")
|
||||
for file in $files; do
|
||||
dir=$(dirname "$file") # Get the directory where the ZIP file is located
|
||||
echo "Extracting $file to directory $dir"
|
||||
unzip -o -d $dir $file
|
||||
rm -rf $dir/__MACOSX
|
||||
done
|
||||
|
||||
- name: Checkout Demo App
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: yaoapp/yao-dev-app
|
||||
path: app
|
||||
|
||||
- name: Checkout Yao Startup Webapp
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: yaoapp/yao-startup-webapp
|
||||
submodules: true
|
||||
token: ${{ secrets.YAO_TEST_TOKEN }}
|
||||
path: yao-startup-webapp
|
||||
|
||||
- name: Checkout Extension
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: yaoapp/yao-extensions-dev
|
||||
path: extension
|
||||
|
||||
- name: Move Kun, Xun, Gou, V8Go, Extension
|
||||
run: |
|
||||
mv kun ../
|
||||
mv xun ../
|
||||
mv gou ../
|
||||
mv v8go ../
|
||||
mv app ../
|
||||
mv extension ../
|
||||
mv yao-startup-webapp ../
|
||||
ls -l .
|
||||
ls -l ../
|
||||
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Apple Private Key
|
||||
run: |
|
||||
mkdir -p ../app/openapi/certs/apple
|
||||
echo "${{ secrets.APPLE_PRIVATE_KEY_USER }}" > ../app/openapi/certs/apple/signin_client_secret_key.p8
|
||||
|
||||
- name: Setup Go ${{ matrix.go }}
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ matrix.go }}
|
||||
|
||||
- name: Install FFmpeg 7.x
|
||||
run: |
|
||||
wget https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-linux64-gpl.tar.xz
|
||||
tar -xf ffmpeg-master-latest-linux64-gpl.tar.xz
|
||||
sudo cp ffmpeg-master-latest-linux64-gpl/bin/ffmpeg /usr/local/bin/
|
||||
sudo cp ffmpeg-master-latest-linux64-gpl/bin/ffprobe /usr/local/bin/
|
||||
sudo chmod +x /usr/local/bin/ffmpeg /usr/local/bin/ffprobe
|
||||
|
||||
- name: Test FFmpeg
|
||||
run: ffmpeg -version
|
||||
|
||||
- name: Install pdftoppm, mutool, imagemagick
|
||||
run: |
|
||||
sudo apt update
|
||||
sudo apt install -y poppler-utils mupdf-tools imagemagick
|
||||
|
||||
- name: Test pdftoppm, mutool, imagemagick
|
||||
run: |
|
||||
pdftoppm -v
|
||||
mutool -v
|
||||
convert -version
|
||||
|
||||
- name: Start Redis
|
||||
uses: supercharge/redis-github-action@1.4.0
|
||||
with:
|
||||
redis-version: ${{ matrix.redis }}
|
||||
|
||||
- name: Start MongoDB
|
||||
uses: supercharge/mongodb-github-action@1.8.0
|
||||
with:
|
||||
mongodb-version: ${{ matrix.mongo }}
|
||||
mongodb-username: root
|
||||
mongodb-password: 123456
|
||||
mongodb-db: test
|
||||
|
||||
- name: Setup MySQL8.0 (connector)
|
||||
uses: ./.github/actions/setup-db
|
||||
with:
|
||||
kind: "MySQL8.0"
|
||||
db: "test"
|
||||
user: "test"
|
||||
password: "123456"
|
||||
port: "3308"
|
||||
|
||||
- name: Setup ${{ matrix.db }}
|
||||
uses: ./.github/actions/setup-db
|
||||
with:
|
||||
kind: "${{ matrix.db }}"
|
||||
db: "xiang"
|
||||
user: "xiang"
|
||||
password: ${{ secrets.UNIT_PASS }}
|
||||
|
||||
- name: Setup Go Tools
|
||||
run: |
|
||||
make tools
|
||||
|
||||
- name: Setup ENV & Host
|
||||
env:
|
||||
PASSWORD: ${{ secrets.UNIT_PASS }}
|
||||
run: |
|
||||
sudo echo "127.0.0.1 local.iqka.com" | sudo tee -a /etc/hosts
|
||||
echo "YAO_DB_DRIVER=$DB_DRIVER" >> $GITHUB_ENV
|
||||
echo "GITHUB_WORKSPACE:\n" && ls -l $GITHUB_WORKSPACE
|
||||
|
||||
if [ "$DB_DRIVER" = "mysql" ]; then
|
||||
echo "YAO_DB_PRIMARY=$DB_USER:$PASSWORD@$DB_HOST" >> $GITHUB_ENV
|
||||
elif [ "$DB_DRIVER" = "postgres" ]; then
|
||||
echo "YAO_DB_PRIMARY=postgres://$DB_USER:$PASSWORD@$DB_HOST" >> $GITHUB_ENV
|
||||
else
|
||||
echo "YAO_DB_PRIMARY=$YAO_ROOT/$DB_HOST" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
echo ".:\n" && ls -l .
|
||||
echo "..:\n" && ls -l ..
|
||||
echo "../app:\n" && ls -l ../app
|
||||
ping -c 1 -t 1 local.iqka.com
|
||||
|
||||
- name: Test Prepare
|
||||
run: |
|
||||
make vet
|
||||
make fmt-check
|
||||
make misspell-check
|
||||
|
||||
- name: Inspect
|
||||
run: |
|
||||
go run . run utils.env.Get MONGO_TEST_HOST
|
||||
go run . run utils.env.Get REDIS_TEST_HOST
|
||||
go run . inspect
|
||||
|
||||
- name: Run test
|
||||
run: |
|
||||
make test
|
||||
|
||||
- name: Codecov Report
|
||||
uses: codecov/codecov-action@v4
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }} # not required for public repos
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -45,3 +45,8 @@ db
|
|||
!get-yao.sh
|
||||
!docker/base/**
|
||||
__debug*
|
||||
data/bindata.go.bak
|
||||
share/const.go.bak
|
||||
share/const.goe
|
||||
.cursor
|
||||
openapi/*.md
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
> **DEPRECATED**: This license is no longer in effect. Please refer to the [LICENSE](LICENSE) file for current licensing terms.
|
||||
|
||||
# Commercial License for Yao
|
||||
|
||||
This document outlines the terms for the commercial license of the **Yao** project. While the Yao project is primarily licensed under the **Apache License, Version 2.0**, certain commercial use cases require a separate commercial license.
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
> **已废弃**: 本许可证已不再生效。请参考 [LICENSE](LICENSE) 文件获取当前的许可条款。
|
||||
|
||||
# Yao 商业许可证
|
||||
|
||||
本文件概述了 **Yao** 项目的商业许可证条款。虽然 Yao 项目主要使用 **Apache 许可证 2.0 版** 授权,但某些商业使用场景需要单独的商业许可证。
|
||||
|
|
@ -12,7 +14,7 @@
|
|||
|
||||
### 定义:应用托管服务
|
||||
|
||||
“应用托管服务”指任何涉及托管基于 Yao 的应用程序或使用 Yao 创建的 WEB 应用程序(包括 Yao 的修改版本)的服务,服务对象为第三方用户。包括但不限于:
|
||||
"应用托管服务"指任何涉及托管基于 Yao 的应用程序或使用 Yao 创建的 WEB 应用程序(包括 Yao 的修改版本)的服务,服务对象为第三方用户。包括但不限于:
|
||||
|
||||
- **托管平台** 提供基于 Yao 的软件或服务给第三方用户。
|
||||
- **SaaS 或 PaaS 服务**,在这些服务中,您管理并托管基于或利用 Yao 的应用程序,可能是原版或修改版。
|
||||
|
|
@ -25,7 +27,7 @@
|
|||
|
||||
### 定义:AI WEB 应用生成服务
|
||||
|
||||
“AI WEB 应用生成服务”指任何利用 Yao(或任何分支版本或修改版本的 Yao)自动化创建具有 AI 功能的 WEB 应用程序的服务或功能。包括但不限于,为第三方用户提供以下服务:
|
||||
"AI WEB 应用生成服务"指任何利用 Yao(或任何分支版本或修改版本的 Yao)自动化创建具有 AI 功能的 WEB 应用程序的服务或功能。包括但不限于,为第三方用户提供以下服务:
|
||||
|
||||
- **AI 驱动的自动化 WEB 应用开发**,该服务生成完整或部分 WEB 应用程序。
|
||||
- **可定制的 WEB 解决方案**,这些解决方案由 AI 提供支持,并以 Yao 作为核心技术构建。
|
||||
|
|
|
|||
207
LICENSE
207
LICENSE
|
|
@ -1,201 +1,24 @@
|
|||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
# Open Source License
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
Yao App Engine is licensed under a modified version of the Apache License 2.0, with the following additional conditions:
|
||||
|
||||
1. Definitions.
|
||||
1. Commercial Usage Terms:
|
||||
Yao App Engine may be utilized commercially, A commercial license from the producer is required if:
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
a. Trademark and Branding Requirements
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
- The Yao App Engine console/application logo and copyright information must not be removed or modified
|
||||
- Logo and copyright information can only be changed with an authorization certificate issued through Yao Developer Certificate
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
b. Authorization Verification Requirements
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
- The Yao certificate verification logic, processes, and related pages (marked in code comments) must be preserved
|
||||
- The complete Yao certificate verification system must be maintained regardless of usage purpose
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
2. Contributor Agreement:
|
||||
- The producer reserves the right to modify the open-source agreement terms
|
||||
- Contributed code may be used for commercial purposes, including cloud business operations
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
All other rights and restrictions follow the Apache License 2.0 (http://www.apache.org/licenses/LICENSE-2.0).
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
© 2025 Infinite Wisdom Software.
|
||||
|
|
|
|||
123
Makefile
123
Makefile
|
|
@ -129,7 +129,7 @@ bindata:
|
|||
cp -r .tmp/yao-init .tmp/data/init
|
||||
cp -r ui .tmp/data/
|
||||
cp -r ui .tmp/data/public
|
||||
cp -r xgen .tmp/data/
|
||||
cp -r cui .tmp/data/
|
||||
cp -r yao .tmp/data/
|
||||
cp -r sui/libsui .tmp/data/
|
||||
find .tmp/data -name ".DS_Store" -type f -delete
|
||||
|
|
@ -142,11 +142,11 @@ bindata:
|
|||
artifacts-linux: clean
|
||||
mkdir -p dist/release
|
||||
|
||||
# Building XGEN v1.0
|
||||
# Building CUI v1.0
|
||||
export NODE_ENV=production
|
||||
# rm -f ../xgen-v1.0/pnpm-lock.yaml
|
||||
echo "BASE=__yao_admin_root" > ../xgen-v1.0/packages/xgen/.env
|
||||
cd ../xgen-v1.0 && pnpm install --no-frozen-lockfile && pnpm run build
|
||||
# rm -f ../cui-v1.0/pnpm-lock.yaml
|
||||
echo "BASE=__yao_admin_root" > ../cui-v1.0/packages/cui/.env
|
||||
cd ../cui-v1.0 && pnpm install --no-frozen-lockfile && pnpm run build
|
||||
|
||||
# Init Application
|
||||
cd ../yao-init && rm -rf .git
|
||||
|
|
@ -162,11 +162,11 @@ artifacts-linux: clean
|
|||
# rm -rf .tmp/yao-builder-latest.tar.gz
|
||||
|
||||
# Packing
|
||||
# ** XGEN will be renamed to DUI in the feature. and move to the new repository. **
|
||||
# ** new repository: https://github.com/YaoApp/dui.git **
|
||||
mkdir -p .tmp/data/xgen
|
||||
# ** CUI will be renamed to CUI in the feature. and move to the new repository. **
|
||||
# ** new repository: https://github.com/YaoApp/cui.git **
|
||||
mkdir -p .tmp/data/cui
|
||||
cp -r ./ui .tmp/data/ui
|
||||
cp -r ../xgen-v1.0/packages/xgen/dist .tmp/data/xgen/v1.0
|
||||
cp -r ../cui-v1.0/packages/cui/dist .tmp/data/cui/v1.0
|
||||
cp -r ../yao-init .tmp/data/init
|
||||
cp -r yao .tmp/data/
|
||||
cp -r sui/libsui .tmp/data/
|
||||
|
|
@ -175,6 +175,8 @@ artifacts-linux: clean
|
|||
|
||||
# Replace PRVERSION
|
||||
sed -ie "s/const PRVERSION = \"DEV\"/const PRVERSION = \"${COMMIT}-${NOW}\"/g" share/const.go
|
||||
@CUI_COMMIT=$$(cd ../cui-v1.0 && git log | head -n 1 | awk '{print substr($$2, 0, 12)}') && \
|
||||
sed -ie "s/const PRCUI = \"DEV\"/const PRCUI = \"$$CUI_COMMIT-${NOW}\"/g" share/const.go
|
||||
|
||||
# Making artifacts
|
||||
mkdir -p dist
|
||||
|
|
@ -197,11 +199,11 @@ artifacts-macos: clean
|
|||
|
||||
mkdir -p dist/release
|
||||
|
||||
# Building XGEN v1.0
|
||||
# Building CUI v1.0
|
||||
export NODE_ENV=production
|
||||
# rm -f ../xgen-v1.0/pnpm-lock.yaml
|
||||
echo "BASE=__yao_admin_root" > ../xgen-v1.0/packages/xgen/.env
|
||||
cd ../xgen-v1.0 && pnpm install --no-frozen-lockfile && pnpm run build
|
||||
# rm -f ../cui-v1.0/pnpm-lock.yaml
|
||||
echo "BASE=__yao_admin_root" > ../cui-v1.0/packages/cui/.env
|
||||
cd ../cui-v1.0 && pnpm install --no-frozen-lockfile && pnpm run build
|
||||
|
||||
# Init Application
|
||||
cd ../yao-init && rm -rf .git
|
||||
|
|
@ -209,19 +211,10 @@ artifacts-macos: clean
|
|||
cd ../yao-init && rm -rf LICENSE
|
||||
# cd ../yao-init && rm -rf README.md
|
||||
|
||||
# Yao Builder
|
||||
# Remove Yao Builder - DUI PageBuilder component will provide online design for pure HTML pages or SUI pages in the future.
|
||||
# mkdir -p .tmp/data/builder
|
||||
# curl -o .tmp/yao-builder-latest.tar.gz https://release-sv.yaoapps.com/archives/yao-builder-latest.tar.gz
|
||||
# tar -zxvf .tmp/yao-builder-latest.tar.gz -C .tmp/data/builder
|
||||
# rm -rf .tmp/yao-builder-latest.tar.gz
|
||||
|
||||
# Packing
|
||||
# ** XGEN will be renamed to DUI in the feature. and move to the new repository. **
|
||||
# ** new repository: https://github.com/YaoApp/dui.git **
|
||||
mkdir -p .tmp/data/xgen
|
||||
mkdir -p .tmp/data/cui
|
||||
cp -r ./ui .tmp/data/ui
|
||||
cp -r ../xgen-v1.0/packages/xgen/dist .tmp/data/xgen/v1.0
|
||||
cp -r ../cui-v1.0/packages/cui/dist .tmp/data/cui/v1.0
|
||||
cp -r ../yao-init .tmp/data/init
|
||||
cp -r yao .tmp/data/
|
||||
cp -r sui/libsui .tmp/data/
|
||||
|
|
@ -230,6 +223,8 @@ artifacts-macos: clean
|
|||
|
||||
# Replace PRVERSION
|
||||
sed -ie "s/const PRVERSION = \"DEV\"/const PRVERSION = \"${COMMIT}-${NOW}\"/g" share/const.go
|
||||
@CUI_COMMIT=$$(cd ../cui-v1.0 && git log | head -n 1 | awk '{print substr($$2, 0, 12)}') && \
|
||||
sed -ie "s/const PRCUI = \"DEV\"/const PRCUI = \"$$CUI_COMMIT-${NOW}\"/g" share/const.go
|
||||
|
||||
# Making artifacts
|
||||
mkdir -p dist
|
||||
|
|
@ -242,9 +237,6 @@ artifacts-macos: clean
|
|||
ls -l dist/release/
|
||||
dist/release/yao-${VERSION}-dev-darwin-amd64 version
|
||||
|
||||
# Reset const
|
||||
# cp -f share/const.goe share/const.go
|
||||
# rm -f share/const.goe
|
||||
|
||||
.PHONY: debug
|
||||
debug: clean
|
||||
|
|
@ -275,18 +267,19 @@ release: clean
|
|||
mkdir -p dist/release
|
||||
mkdir .tmp
|
||||
|
||||
# Building XGEN v0.9
|
||||
mkdir -p .tmp/xgen/v0.9/dist
|
||||
echo "XGEN v0.9" > .tmp/xgen/v0.9/dist/index.html
|
||||
# Building CUI v0.9
|
||||
mkdir -p .tmp/cui/v0.9/dist
|
||||
echo "CUI v0.9" > .tmp/cui/v0.9/dist/index.html
|
||||
|
||||
# Building XGEN v1.0
|
||||
# ** XGEN will be renamed to DUI in the feature. and move to the new repository. **
|
||||
# ** new repository: https://github.com/YaoApp/dui.git **
|
||||
# Building CUI v1.0
|
||||
# ** CUI will be renamed to CUI in the feature. and move to the new repository. **
|
||||
# ** new repository: https://github.com/YaoApp/cui.git **
|
||||
export NODE_ENV=production
|
||||
git clone https://github.com/sjzsdu/xgen.git .tmp/xgen/v1.0
|
||||
# cd .tmp/xgen/v1.0 && git checkout 5002c3fded585aaa69a4366135b415ea3234964e
|
||||
echo "BASE=__yao_admin_root" > .tmp/xgen/v1.0/packages/xgen/.env
|
||||
cd .tmp/xgen/v1.0 && pnpm install --no-frozen-lockfile && pnpm run build
|
||||
git clone https://github.com/YaoApp/cui.git .tmp/cui/v1.0
|
||||
# cd .tmp/cui/v1.0 && git checkout 5002c3fded585aaa69a4366135b415ea3234964e
|
||||
echo "BASE=__yao_admin_root" > .tmp/cui/v1.0/packages/cui/.env
|
||||
cd .tmp/cui/v1.0 && pnpm install --no-frozen-lockfile && pnpm run build
|
||||
CUI_COMMIT=$$(cd .tmp/cui/v1.0 && git rev-parse --short HEAD)
|
||||
|
||||
# Checkout init
|
||||
git clone https://github.com/YaoApp/yao-init.git .tmp/yao-init
|
||||
|
|
@ -303,57 +296,57 @@ release: clean
|
|||
# rm -rf .tmp/yao-builder-latest.tar.gz
|
||||
|
||||
# Packing
|
||||
mkdir -p .tmp/data/xgen
|
||||
cp -f data/bindata.go data/bindata.go.bak
|
||||
mkdir -p .tmp/data/cui
|
||||
cp -r ./ui .tmp/data/ui
|
||||
cp -r ./yao .tmp/data/yao
|
||||
cp -r ./sui/libsui .tmp/data/libsui
|
||||
cp -r .tmp/xgen/v0.9/dist .tmp/data/xgen/v0.9
|
||||
cp -r .tmp/xgen/v1.0/packages/xgen/dist .tmp/data/xgen/v1.0
|
||||
cp -r .tmp/cui/v0.9/dist .tmp/data/cui/v0.9
|
||||
cp -r .tmp/cui/v1.0/packages/cui/dist .tmp/data/cui/v1.0
|
||||
cp -r .tmp/yao-init .tmp/data/init
|
||||
go-bindata -fs -pkg data -o data/bindata.go -prefix ".tmp/data/" .tmp/data/...
|
||||
rm -rf .tmp/data
|
||||
rm -rf .tmp/xgen
|
||||
|
||||
|
||||
# Replace PRVERSION
|
||||
cp -f share/const.go share/const.go.bak
|
||||
sed -ie "s/const PRVERSION = \"DEV\"/const PRVERSION = \"${COMMIT}-${NOW}\"/g" share/const.go
|
||||
@CUI_COMMIT=$$(cd .tmp/cui/v1.0 && git log | head -n 1 | awk '{print substr($$2, 0, 12)}') && \
|
||||
sed -ie "s/const PRCUI = \"DEV\"/const PRCUI = \"$$CUI_COMMIT-${NOW}\"/g" share/const.go
|
||||
|
||||
# Making artifacts
|
||||
mkdir -p dist
|
||||
CGO_ENABLED=1 go build -v -o dist/release/yao
|
||||
chmod +x dist/release/yao
|
||||
|
||||
# Clean up and restore bindata.go and const.go
|
||||
cp data/bindata.go.bak data/bindata.go
|
||||
cp share/const.go.bak share/const.go
|
||||
rm data/bindata.go.bak
|
||||
rm share/const.go.bak
|
||||
rm -rf .tmp
|
||||
|
||||
# MacOS Application Signing
|
||||
@if [ "$(OS)" = "Darwin" ]; then \
|
||||
codesign --deep --force --verify --verbose --sign "${APPLE_SIGN}" dist/release/yao ; \
|
||||
fi
|
||||
|
||||
# Reset const
|
||||
cp -f share/const.goe share/const.go
|
||||
rm share/const.goe
|
||||
|
||||
.PHONY: linux-release
|
||||
linux-release: clean
|
||||
mkdir -p dist/release
|
||||
mkdir .tmp
|
||||
|
||||
# Building XGEN v0.9
|
||||
git clone https://github.com/YaoApp/xgen-deprecated.git .tmp/xgen/v0.9
|
||||
sed -ie "s/url('\/icon/url('\/xiang\/icon/g" .tmp/xgen/v0.9/public/icon/md_icon.css
|
||||
cd .tmp/xgen/v0.9 && yarn install && yarn build
|
||||
mkdir -p .tmp/xgen/v0.9
|
||||
cp -r xgen/v0.9 .tmp/xgen/v0.9/dist
|
||||
|
||||
# Building XGEN v1.0
|
||||
# ** XGEN will be renamed to DUI in the feature. and move to the new repository. **
|
||||
# ** new repository: https://github.com/YaoApp/dui.git **
|
||||
# Building CUI v1.0
|
||||
# ** CUI will be renamed to CUI in the feature. and move to the new repository. **
|
||||
# ** new repository: https://github.com/YaoApp/cui.git **
|
||||
export NODE_ENV=production
|
||||
git clone https://github.com/sjzsdu/xgen.git .tmp/xgen/v1.0
|
||||
rm -f .tmp/xgen/v1.0/pnpm-lock.yaml
|
||||
echo "BASE=__yao_admin_root" > .tmp/xgen/v1.0/packages/xgen/.env
|
||||
cd .tmp/xgen/v1.0 && pnpm install --no-frozen-lockfile && pnpm run build
|
||||
git clone https://github.com/YaoApp/cui.git .tmp/cui/v1.0
|
||||
rm -f .tmp/cui/v1.0/pnpm-lock.yaml
|
||||
echo "BASE=__yao_admin_root" > .tmp/cui/v1.0/packages/cui/.env
|
||||
cd .tmp/cui/v1.0 && pnpm install --no-frozen-lockfile && pnpm run build
|
||||
|
||||
# Setup UI
|
||||
cd .tmp/xgen/v1.0/packages/setup && pnpm install --no-frozen-lockfile && pnpm run build
|
||||
cd .tmp/cui/v1.0/packages/setup && pnpm install --no-frozen-lockfile && pnpm run build
|
||||
|
||||
|
||||
# Checkout init
|
||||
|
|
@ -371,16 +364,16 @@ linux-release: clean
|
|||
# rm -rf .tmp/yao-builder-latest.tar.gz
|
||||
|
||||
# Packing
|
||||
mkdir -p .tmp/data/xgen
|
||||
mkdir -p .tmp/data/cui
|
||||
cp -r ./ui .tmp/data/ui
|
||||
cp -r ./yao .tmp/data/yao
|
||||
cp -r .tmp/xgen/v0.9/dist .tmp/data/xgen/v0.9
|
||||
cp -r .tmp/xgen/v1.0/packages/setup/build .tmp/data/xgen/setup
|
||||
cp -r .tmp/xgen/v1.0/packages/xgen/dist .tmp/data/xgen/v1.0
|
||||
cp -r .tmp/cui/v0.9/dist .tmp/data/cui/v0.9
|
||||
cp -r .tmp/cui/v1.0/packages/setup/build .tmp/data/cui/setup
|
||||
cp -r .tmp/cui/v1.0/packages/cui/dist .tmp/data/cui/v1.0
|
||||
cp -r .tmp/yao-init .tmp/data/init
|
||||
go-bindata -fs -pkg data -o data/bindata.go -prefix ".tmp/data/" .tmp/data/...
|
||||
rm -rf .tmp/data
|
||||
rm -rf .tmp/xgen
|
||||
rm -rf .tmp/cui
|
||||
|
||||
# Making artifacts
|
||||
mkdir -p dist
|
||||
|
|
|
|||
12
aigc/load.go
12
aigc/load.go
|
|
@ -11,9 +11,19 @@ import (
|
|||
|
||||
// Load load AIGC
|
||||
func Load(cfg config.Config) error {
|
||||
|
||||
// Ignore if the aigcs directory does not exist
|
||||
exists, err := application.App.Exists("aigcs")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
return nil
|
||||
}
|
||||
|
||||
exts := []string{"*.ai.yml", "*.ai.yaml"}
|
||||
messages := []string{}
|
||||
err := application.App.Walk("aigcs", func(root, file string, isdir bool) error {
|
||||
err = application.App.Walk("aigcs", func(root, file string, isdir bool) error {
|
||||
if isdir {
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
11
api/api.go
11
api/api.go
|
|
@ -14,8 +14,17 @@ import (
|
|||
func Load(cfg config.Config) error {
|
||||
messages := []string{}
|
||||
|
||||
// Ignore if the apis directory does not exist
|
||||
exists, err := application.App.Exists("apis")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
return nil
|
||||
}
|
||||
|
||||
exts := []string{"*.http.yao", "*.http.json", "*.http.jsonc"}
|
||||
err := application.App.Walk("apis", func(root, file string, isdir bool) error {
|
||||
err = application.App.Walk("apis", func(root, file string, isdir bool) error {
|
||||
if isdir {
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
664
attachment/README.md
Normal file
664
attachment/README.md
Normal file
|
|
@ -0,0 +1,664 @@
|
|||
# Attachment Package
|
||||
|
||||
A comprehensive file upload package for Go that supports chunked uploads, file format validation, compression, and multiple storage backends.
|
||||
|
||||
## Features
|
||||
|
||||
- **Multiple Storage Backends**: Local filesystem and S3-compatible storage
|
||||
- **Chunked Upload Support**: Handle large files with standard HTTP Content-Range headers
|
||||
- **File Deduplication**: Content-based fingerprinting to avoid duplicate uploads
|
||||
- **File Compression**:
|
||||
- Gzip compression for any file type
|
||||
- Image compression with configurable size limits
|
||||
- **File Validation**:
|
||||
- File size limits
|
||||
- MIME type and extension validation
|
||||
- Wildcard pattern support (e.g., `image/*`, `text/*`)
|
||||
- **Flexible File Organization**: Hierarchical storage with multi-level group organization
|
||||
- **Multiple Read Methods**: Stream, bytes, and base64 encoding
|
||||
- **Global Manager Registry**: Support for registering and accessing managers globally
|
||||
- **Upload Status Tracking**: Track upload progress with status field
|
||||
- **Content Synchronization**: Support for synchronized uploads with Content-Sync header
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
go get github.com/yaoapp/yao/neo/attachment
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"mime/multipart"
|
||||
"github.com/yaoapp/yao/neo/attachment"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Create a manager with default settings
|
||||
manager, err := attachment.RegisterDefault("uploads")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Or create a custom manager
|
||||
customManager, err := attachment.New(attachment.ManagerOption{
|
||||
Driver: "local",
|
||||
MaxSize: "20M",
|
||||
ChunkSize: "2M",
|
||||
AllowedTypes: []string{"text/*", "image/*", ".pdf"},
|
||||
Options: map[string]interface{}{
|
||||
"path": "/var/uploads",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Upload a file
|
||||
content := "Hello, World!"
|
||||
fileHeader := &attachment.FileHeader{
|
||||
FileHeader: &multipart.FileHeader{
|
||||
Filename: "hello.txt",
|
||||
Size: int64(len(content)),
|
||||
Header: make(map[string][]string),
|
||||
},
|
||||
}
|
||||
fileHeader.Header.Set("Content-Type", "text/plain")
|
||||
|
||||
option := attachment.UploadOption{
|
||||
Groups: []string{"user123", "chat456"}, // Multi-level groups (e.g., user, chat, knowledge, etc.)
|
||||
OriginalFilename: "my_document.txt", // Preserve original filename
|
||||
}
|
||||
|
||||
file, err := manager.Upload(context.Background(), fileHeader, strings.NewReader(content), option)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Check upload status
|
||||
if file.Status == "uploaded" {
|
||||
fmt.Printf("File uploaded successfully: %s\n", file.ID)
|
||||
}
|
||||
|
||||
// Read the file back
|
||||
data, err := manager.Read(context.Background(), file.ID)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
println(string(data)) // Output: Hello, World!
|
||||
}
|
||||
```
|
||||
|
||||
### Storage Backends
|
||||
|
||||
#### Local Storage
|
||||
|
||||
```go
|
||||
manager, err := attachment.New(attachment.ManagerOption{
|
||||
Driver: "local",
|
||||
MaxSize: "20M",
|
||||
Options: map[string]interface{}{
|
||||
"path": "/var/uploads",
|
||||
"base_url": "https://example.com/files",
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
#### S3 Storage
|
||||
|
||||
```go
|
||||
manager, err := attachment.New(attachment.ManagerOption{
|
||||
Driver: "s3",
|
||||
MaxSize: "100M",
|
||||
Options: map[string]interface{}{
|
||||
"endpoint": "https://s3.amazonaws.com",
|
||||
"region": "us-east-1",
|
||||
"key": "your-access-key",
|
||||
"secret": "your-secret-key",
|
||||
"bucket": "your-bucket-name",
|
||||
"prefix": "attachments/",
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Chunked Upload
|
||||
|
||||
For large files, you can upload in chunks using standard HTTP Content-Range headers:
|
||||
|
||||
```go
|
||||
// Upload chunks
|
||||
totalSize := int64(1024000) // 1MB file
|
||||
chunkSize := int64(1024) // 1KB chunks
|
||||
uid := "unique-file-id-123"
|
||||
|
||||
for start := int64(0); start < totalSize; start += chunkSize {
|
||||
end := start + chunkSize - 1
|
||||
if end >= totalSize {
|
||||
end = totalSize - 1
|
||||
}
|
||||
|
||||
chunkData := make([]byte, end-start+1)
|
||||
// ... fill chunkData with actual data ...
|
||||
|
||||
chunkHeader := &attachment.FileHeader{
|
||||
FileHeader: &multipart.FileHeader{
|
||||
Filename: "large_file.zip",
|
||||
Size: end - start + 1,
|
||||
Header: make(map[string][]string),
|
||||
},
|
||||
}
|
||||
chunkHeader.Header.Set("Content-Type", "application/zip")
|
||||
chunkHeader.Header.Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, totalSize))
|
||||
chunkHeader.Header.Set("Content-Uid", uid)
|
||||
|
||||
file, err := manager.Upload(ctx, chunkHeader, bytes.NewReader(chunkData), option)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// File is complete when the last chunk is uploaded
|
||||
if chunkHeader.Complete() {
|
||||
fmt.Printf("Upload complete: %s\n", file.ID)
|
||||
break
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Compression
|
||||
|
||||
#### Gzip Compression
|
||||
|
||||
```go
|
||||
option := attachment.UploadOption{
|
||||
Gzip: true, // Enable gzip compression
|
||||
}
|
||||
|
||||
file, err := manager.Upload(ctx, fileHeader, reader, option)
|
||||
```
|
||||
|
||||
#### Image Compression
|
||||
|
||||
```go
|
||||
option := attachment.UploadOption{
|
||||
CompressImage: true,
|
||||
CompressSize: 1920, // Max dimension in pixels (default: 1920)
|
||||
}
|
||||
|
||||
file, err := manager.Upload(ctx, imageHeader, imageReader, option)
|
||||
```
|
||||
|
||||
### Multi-level Groups
|
||||
|
||||
The `Groups` field supports hierarchical file organization:
|
||||
|
||||
```go
|
||||
// Single level grouping
|
||||
option := attachment.UploadOption{
|
||||
Groups: []string{"users"},
|
||||
}
|
||||
|
||||
// Multi-level grouping
|
||||
option := attachment.UploadOption{
|
||||
Groups: []string{"users", "user123", "chats", "chat456"},
|
||||
}
|
||||
|
||||
// Knowledge base organization
|
||||
option := attachment.UploadOption{
|
||||
Groups: []string{"knowledge", "documents", "technical"},
|
||||
}
|
||||
```
|
||||
|
||||
This creates nested directory structures for better organization and access control.
|
||||
|
||||
### File Validation
|
||||
|
||||
#### Size Limits
|
||||
|
||||
```go
|
||||
manager, err := attachment.New(attachment.ManagerOption{
|
||||
MaxSize: "20M", // Maximum file size
|
||||
// Supports: B, K, M, G (e.g., "1024B", "2K", "10M", "1G")
|
||||
})
|
||||
```
|
||||
|
||||
#### Type Validation
|
||||
|
||||
```go
|
||||
manager, err := attachment.New(attachment.ManagerOption{
|
||||
AllowedTypes: []string{
|
||||
"text/*", // All text types
|
||||
"image/*", // All image types
|
||||
"application/pdf", // Specific MIME type
|
||||
".txt", // File extension
|
||||
".jpg", // File extension
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Reading Files
|
||||
|
||||
#### Stream Reading
|
||||
|
||||
```go
|
||||
response, err := manager.Download(ctx, fileID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Reader.Close()
|
||||
|
||||
// Use response.Reader as io.ReadCloser
|
||||
// response.ContentType contains the MIME type
|
||||
// response.Extension contains the file extension
|
||||
```
|
||||
|
||||
#### Read as Bytes
|
||||
|
||||
```go
|
||||
data, err := manager.Read(ctx, fileID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// data is []byte
|
||||
```
|
||||
|
||||
#### Read as Base64
|
||||
|
||||
```go
|
||||
base64Data, err := manager.ReadBase64(ctx, fileID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// base64Data is string
|
||||
```
|
||||
|
||||
### Global Managers
|
||||
|
||||
You can register managers globally for easy access:
|
||||
|
||||
```go
|
||||
// Register default manager with sensible defaults
|
||||
attachment.RegisterDefault("main")
|
||||
|
||||
// Register custom managers
|
||||
attachment.Register("local", "local", attachment.ManagerOption{
|
||||
Driver: "local",
|
||||
Options: map[string]interface{}{
|
||||
"path": "/var/uploads",
|
||||
},
|
||||
})
|
||||
|
||||
attachment.Register("s3", "s3", attachment.ManagerOption{
|
||||
Driver: "s3",
|
||||
Options: map[string]interface{}{
|
||||
"bucket": "my-bucket",
|
||||
"key": "access-key",
|
||||
"secret": "secret-key",
|
||||
},
|
||||
})
|
||||
|
||||
// Use global managers
|
||||
localManager := attachment.Managers["local"]
|
||||
s3Manager := attachment.Managers["s3"]
|
||||
defaultManager := attachment.Managers["main"]
|
||||
```
|
||||
|
||||
## File Organization
|
||||
|
||||
Files are organized in a hierarchical structure:
|
||||
|
||||
```
|
||||
attachments/
|
||||
├── 20240101/ # Date (YYYYMMDD)
|
||||
│ └── user123/ # First level group (optional)
|
||||
│ └── chat456/ # Second level group (optional)
|
||||
│ └── knowledge/ # Additional group levels (optional)
|
||||
│ └── ab/ # First 2 chars of hash
|
||||
│ └── cd/ # Next 2 chars of hash
|
||||
│ └── abcdef12.txt # Hash + extension
|
||||
```
|
||||
|
||||
The file ID generation includes:
|
||||
|
||||
- Date prefix for organization
|
||||
- Multi-level groups for access control and organization
|
||||
- Content hash for deduplication
|
||||
- Original file extension
|
||||
|
||||
## API Reference
|
||||
|
||||
### Manager
|
||||
|
||||
#### `New(option ManagerOption) (*Manager, error)`
|
||||
|
||||
Creates a new attachment manager.
|
||||
|
||||
#### `Register(name string, driver string, option ManagerOption) (*Manager, error)`
|
||||
|
||||
Registers a global attachment manager.
|
||||
|
||||
#### `Upload(ctx context.Context, fileheader *FileHeader, reader io.Reader, option UploadOption) (*File, error)`
|
||||
|
||||
Uploads a file (supports chunked upload).
|
||||
|
||||
#### `Download(ctx context.Context, fileID string) (*FileResponse, error)`
|
||||
|
||||
Downloads a file as a stream.
|
||||
|
||||
#### `Read(ctx context.Context, fileID string) ([]byte, error)`
|
||||
|
||||
Reads a file as bytes.
|
||||
|
||||
#### `ReadBase64(ctx context.Context, fileID string) (string, error)`
|
||||
|
||||
Reads a file as base64 encoded string.
|
||||
|
||||
### Storage Interface
|
||||
|
||||
All storage backends implement the following interface:
|
||||
|
||||
```go
|
||||
type Storage interface {
|
||||
Upload(ctx context.Context, fileID string, reader io.Reader, contentType string) (string, error)
|
||||
UploadChunk(ctx context.Context, fileID string, chunkIndex int, reader io.Reader, contentType string) error
|
||||
MergeChunks(ctx context.Context, fileID string, totalChunks int) error
|
||||
Download(ctx context.Context, fileID string) (io.ReadCloser, string, error)
|
||||
Reader(ctx context.Context, fileID string) (io.ReadCloser, error)
|
||||
URL(ctx context.Context, fileID string) string
|
||||
Exists(ctx context.Context, fileID string) bool
|
||||
Delete(ctx context.Context, fileID string) error
|
||||
}
|
||||
```
|
||||
|
||||
### Types
|
||||
|
||||
#### `ManagerOption`
|
||||
|
||||
Configuration for creating a manager:
|
||||
|
||||
- `Driver`: "local" or "s3"
|
||||
- `MaxSize`: Maximum file size (e.g., "20M")
|
||||
- `ChunkSize`: Chunk size for uploads (e.g., "2M")
|
||||
- `AllowedTypes`: Array of allowed MIME types/extensions
|
||||
- `Options`: Driver-specific options
|
||||
|
||||
#### `UploadOption`
|
||||
|
||||
Options for file upload:
|
||||
|
||||
- `CompressImage`: Enable image compression
|
||||
- `CompressSize`: Maximum image dimension (default: 1920)
|
||||
- `Gzip`: Enable gzip compression
|
||||
- `Groups`: Multi-level group identifiers for hierarchical file organization (e.g., []string{"user123", "chat456", "knowledge"})
|
||||
- `OriginalFilename`: Original filename to preserve (avoids encoding issues)
|
||||
|
||||
#### `File`
|
||||
|
||||
Uploaded file information:
|
||||
|
||||
- `ID`: Unique file identifier
|
||||
- `Filename`: Original filename
|
||||
- `ContentType`: MIME type
|
||||
- `Bytes`: File size
|
||||
- `CreatedAt`: Upload timestamp
|
||||
- `Status`: Upload status ("uploading", "uploaded", "indexing", "indexed", "upload_failed", "index_failed")
|
||||
|
||||
#### `FileResponse`
|
||||
|
||||
Download response:
|
||||
|
||||
- `Reader`: io.ReadCloser for file content
|
||||
- `ContentType`: MIME type
|
||||
- `Extension`: File extension
|
||||
|
||||
## Chunked Upload Details
|
||||
|
||||
The package supports chunked uploads using standard HTTP headers:
|
||||
|
||||
- `Content-Range`: Specifies byte range (e.g., "bytes 0-1023/2048")
|
||||
- `Content-Uid`: Unique identifier for the file being uploaded
|
||||
|
||||
### Chunk Index Calculation
|
||||
|
||||
The package uses a standard chunk size (1024 bytes by default) to calculate chunk indices consistently. This ensures proper chunk ordering during merge operations.
|
||||
|
||||
### Content Type Preservation
|
||||
|
||||
For chunked uploads, the content type is preserved from the first chunk and applied to the final merged file, ensuring proper MIME type handling across all storage backends.
|
||||
|
||||
## Error Handling
|
||||
|
||||
The package returns descriptive errors for common issues:
|
||||
|
||||
- File size exceeds limit
|
||||
- Unsupported file type
|
||||
- Storage backend errors
|
||||
- Invalid chunk information
|
||||
- Missing required configuration
|
||||
|
||||
## Testing
|
||||
|
||||
Run the tests:
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
go test ./...
|
||||
|
||||
# Run with S3 credentials (optional)
|
||||
export S3_ACCESS_KEY="your-key"
|
||||
export S3_SECRET_KEY="your-secret"
|
||||
export S3_BUCKET="your-bucket"
|
||||
export S3_API="https://your-s3-endpoint"
|
||||
go test ./...
|
||||
```
|
||||
|
||||
The package includes comprehensive tests for:
|
||||
|
||||
- Basic file upload/download
|
||||
- Chunked uploads with content type preservation
|
||||
- Compression (gzip and image)
|
||||
- File validation (size, type, wildcards)
|
||||
- Multiple storage backends (local and S3)
|
||||
- Error handling and edge cases
|
||||
|
||||
### Test Coverage
|
||||
|
||||
- **Manager Tests**: Upload, download, validation, compression
|
||||
- **Local Storage Tests**: File operations, chunked uploads, directory management
|
||||
- **S3 Storage Tests**: S3 operations, chunked uploads, presigned URLs (requires credentials)
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
- **Chunked Uploads**: Use appropriate chunk sizes (1-5MB) for optimal performance
|
||||
- **Image Compression**: Automatically resizes large images to reduce storage costs
|
||||
- **Gzip Compression**: Reduces storage size for text-based files
|
||||
- **Content Type Detection**: Efficient MIME type detection and preservation
|
||||
|
||||
## Security Features
|
||||
|
||||
- **File Type Validation**: Prevents upload of unauthorized file types
|
||||
- **Size Limits**: Configurable file size restrictions
|
||||
- **Path Sanitization**: Secure file path generation
|
||||
- **Access Control**: Multi-level hierarchical file organization
|
||||
|
||||
## License
|
||||
|
||||
This package is part of the Yao project and follows the same license terms.
|
||||
|
||||
### File Deduplication with Fingerprints
|
||||
|
||||
The package supports file deduplication using content fingerprints:
|
||||
|
||||
```go
|
||||
// Set a content fingerprint to enable deduplication
|
||||
fileHeader := &attachment.FileHeader{
|
||||
FileHeader: &multipart.FileHeader{
|
||||
Filename: "document.pdf",
|
||||
Size: fileSize,
|
||||
Header: make(map[string][]string),
|
||||
},
|
||||
}
|
||||
fileHeader.Header.Set("Content-Type", "application/pdf")
|
||||
fileHeader.Header.Set("Content-Fingerprint", "sha256:abcdef123456") // Content-based hash
|
||||
|
||||
file, err := manager.Upload(ctx, fileHeader, reader, option)
|
||||
```
|
||||
|
||||
### Content Synchronization
|
||||
|
||||
For synchronized uploads across multiple clients:
|
||||
|
||||
```go
|
||||
// Enable content synchronization
|
||||
fileHeader.Header.Set("Content-Sync", "true")
|
||||
|
||||
// Each client can upload the same content with the same fingerprint
|
||||
// The system will deduplicate based on the content fingerprint
|
||||
```
|
||||
|
||||
### Chunked Upload with Enhanced Headers
|
||||
|
||||
For large files, you can upload in chunks using standard HTTP Content-Range headers with additional metadata:
|
||||
|
||||
```go
|
||||
// Upload chunks with unique identifier and fingerprint
|
||||
totalSize := int64(1024000) // 1MB file
|
||||
chunkSize := int64(1024) // 1KB chunks
|
||||
uid := "unique-file-id-123"
|
||||
fingerprint := "sha256:content-hash-here"
|
||||
|
||||
for start := int64(0); start < totalSize; start += chunkSize {
|
||||
end := start + chunkSize - 1
|
||||
if end >= totalSize {
|
||||
end = totalSize - 1
|
||||
}
|
||||
|
||||
chunkData := make([]byte, end-start+1)
|
||||
// ... fill chunkData with actual data ...
|
||||
|
||||
chunkHeader := &attachment.FileHeader{
|
||||
FileHeader: &multipart.FileHeader{
|
||||
Filename: "large_file.zip",
|
||||
Size: end - start + 1,
|
||||
Header: make(map[string][]string),
|
||||
},
|
||||
}
|
||||
chunkHeader.Header.Set("Content-Type", "application/zip")
|
||||
chunkHeader.Header.Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, totalSize))
|
||||
chunkHeader.Header.Set("Content-Uid", uid)
|
||||
chunkHeader.Header.Set("Content-Fingerprint", fingerprint)
|
||||
chunkHeader.Header.Set("Content-Sync", "true") // Enable synchronization
|
||||
|
||||
option := attachment.UploadOption{
|
||||
Groups: []string{"user123", "chat456"}, // Multi-level groups
|
||||
OriginalFilename: "my_large_file.zip", // Preserve original name
|
||||
}
|
||||
|
||||
file, err := manager.Upload(ctx, chunkHeader, bytes.NewReader(chunkData), option)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if upload is complete
|
||||
if file.Status == "uploaded" {
|
||||
fmt.Printf("Upload complete: %s\n", file.ID)
|
||||
break
|
||||
} else if file.Status == "uploading" {
|
||||
fmt.Printf("Chunk uploaded, progress: %d/%d\n", chunkHeader.GetChunkSize(), chunkHeader.GetTotalSize())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### FileHeader Methods
|
||||
|
||||
The `FileHeader` type provides several utility methods:
|
||||
|
||||
```go
|
||||
// Get unique identifier for chunked uploads
|
||||
uid := fileHeader.UID()
|
||||
|
||||
// Get content fingerprint for deduplication
|
||||
fingerprint := fileHeader.Fingerprint()
|
||||
|
||||
// Get byte range for chunked uploads
|
||||
rangeHeader := fileHeader.Range()
|
||||
|
||||
// Check if synchronization is enabled
|
||||
isSync := fileHeader.Sync()
|
||||
|
||||
// Check if this is a chunked upload
|
||||
isChunk := fileHeader.IsChunk()
|
||||
|
||||
// Check if upload is complete (for chunked uploads)
|
||||
isComplete := fileHeader.Complete()
|
||||
|
||||
// Get detailed chunk information
|
||||
start, end, total, err := fileHeader.GetChunkInfo()
|
||||
|
||||
// Get total file size (for chunked uploads)
|
||||
totalSize := fileHeader.GetTotalSize()
|
||||
|
||||
// Get current chunk size
|
||||
chunkSize := fileHeader.GetChunkSize()
|
||||
```
|
||||
|
||||
## File Headers and Metadata
|
||||
|
||||
The package supports several HTTP headers for enhanced functionality:
|
||||
|
||||
- `Content-Range`: Standard HTTP range header for chunked uploads (e.g., "bytes 0-1023/2048")
|
||||
- `Content-Uid`: Unique identifier for file uploads (for deduplication and tracking)
|
||||
- `Content-Fingerprint`: Content-based hash for deduplication (e.g., "sha256:abc123")
|
||||
- `Content-Sync`: Enable synchronized uploads across multiple clients ("true"/"false")
|
||||
|
||||
### Header Processing
|
||||
|
||||
When processing uploads, headers can be extracted from both HTTP request headers and multipart file headers:
|
||||
|
||||
```go
|
||||
// Extract headers from HTTP request and file headers
|
||||
header := attachment.GetHeader(requestHeader, fileHeader, fileSize)
|
||||
|
||||
// The resulting FileHeader will contain merged headers from both sources
|
||||
uid := header.UID()
|
||||
fingerprint := header.Fingerprint()
|
||||
isSync := header.Sync()
|
||||
```
|
||||
|
||||
## Upload Status Tracking
|
||||
|
||||
Files have a status field that tracks the upload lifecycle:
|
||||
|
||||
- `"uploading"`: File upload is in progress (for chunked uploads)
|
||||
- `"uploaded"`: File has been successfully uploaded
|
||||
- `"indexing"`: File is being processed for search indexing
|
||||
- `"indexed"`: File has been indexed and is fully processed
|
||||
- `"upload_failed"`: Upload failed due to an error
|
||||
- `"index_failed"`: Indexing failed but file is still accessible
|
||||
|
||||
```go
|
||||
file, err := manager.Upload(ctx, fileHeader, reader, option)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch file.Status {
|
||||
case "uploading":
|
||||
fmt.Println("Upload in progress...")
|
||||
case "uploaded":
|
||||
fmt.Println("Upload completed successfully")
|
||||
case "upload_failed":
|
||||
fmt.Println("Upload failed")
|
||||
}
|
||||
```
|
||||
|
||||
#### `RegisterDefault(name string) (*Manager, error)`
|
||||
|
||||
Registers a default attachment manager with sensible defaults for common file types.
|
||||
76
attachment/compresses.go
Normal file
76
attachment/compresses.go
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
package attachment
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"io"
|
||||
)
|
||||
|
||||
// CompressImage compresses the image while maintaining aspect ratio
|
||||
func CompressImage(reader io.Reader, contentType string, maxSize int) ([]byte, error) {
|
||||
// Read all data first
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read image data: %w", err)
|
||||
}
|
||||
|
||||
// Decode image
|
||||
img, _, err := image.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode image: %w", err)
|
||||
}
|
||||
|
||||
// Calculate new dimensions
|
||||
bounds := img.Bounds()
|
||||
width := bounds.Dx()
|
||||
height := bounds.Dy()
|
||||
var newWidth, newHeight int
|
||||
|
||||
if width > height {
|
||||
if width > maxSize {
|
||||
newWidth = maxSize
|
||||
newHeight = int(float64(height) * (float64(maxSize) / float64(width)))
|
||||
} else {
|
||||
return data, nil // No need to resize, return original data
|
||||
}
|
||||
} else {
|
||||
if height > maxSize {
|
||||
newHeight = maxSize
|
||||
newWidth = int(float64(width) * (float64(maxSize) / float64(height)))
|
||||
} else {
|
||||
return data, nil // No need to resize, return original data
|
||||
}
|
||||
}
|
||||
|
||||
// Create new image with new dimensions
|
||||
newImg := image.NewRGBA(image.Rect(0, 0, newWidth, newHeight))
|
||||
|
||||
// Scale the image using bilinear interpolation
|
||||
for y := 0; y < newHeight; y++ {
|
||||
for x := 0; x < newWidth; x++ {
|
||||
srcX := float64(x) * float64(width) / float64(newWidth)
|
||||
srcY := float64(y) * float64(height) / float64(newHeight)
|
||||
newImg.Set(x, y, img.At(int(srcX), int(srcY)))
|
||||
}
|
||||
}
|
||||
|
||||
// Encode image
|
||||
var buf bytes.Buffer
|
||||
switch contentType {
|
||||
case "image/jpeg":
|
||||
err = jpeg.Encode(&buf, newImg, &jpeg.Options{Quality: 85})
|
||||
case "image/png":
|
||||
err = png.Encode(&buf, newImg)
|
||||
default:
|
||||
return data, nil // Unsupported format, return original data
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encode image: %w", err)
|
||||
}
|
||||
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
512
attachment/example_usage.go
Normal file
512
attachment/example_usage.go
Normal file
|
|
@ -0,0 +1,512 @@
|
|||
package attachment
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"mime/multipart"
|
||||
"strings"
|
||||
|
||||
"github.com/yaoapp/yao/attachment/s3"
|
||||
)
|
||||
|
||||
// ExampleUsage demonstrates how to use the attachment package
|
||||
func ExampleUsage() {
|
||||
// 1. Create a local storage manager
|
||||
localManager, err := New(ManagerOption{
|
||||
Driver: "local",
|
||||
MaxSize: "20M",
|
||||
ChunkSize: "2M",
|
||||
AllowedTypes: []string{"text/*", "image/*", "application/pdf", ".txt", ".jpg", ".png", ".pdf"},
|
||||
Options: map[string]interface{}{
|
||||
"path": "/var/uploads",
|
||||
"base_url": "https://example.com/files",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create local manager: %v", err)
|
||||
}
|
||||
|
||||
// 2. Create an S3 storage manager
|
||||
s3Manager, err := New(ManagerOption{
|
||||
Driver: "s3",
|
||||
MaxSize: "100M",
|
||||
ChunkSize: "5M",
|
||||
AllowedTypes: []string{"*"}, // Allow all types
|
||||
Options: map[string]interface{}{
|
||||
"endpoint": "https://s3.amazonaws.com",
|
||||
"region": "us-east-1",
|
||||
"key": "your-access-key",
|
||||
"secret": "your-secret-key",
|
||||
"bucket": "your-bucket-name",
|
||||
"prefix": "attachments/",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("Failed to create S3 manager (this is expected without credentials): %v", err)
|
||||
} else {
|
||||
fmt.Printf("Created S3 manager successfully\n")
|
||||
// Demonstrate S3 manager usage if credentials are available
|
||||
if s3Manager != nil {
|
||||
fmt.Printf("S3 manager is ready for use with bucket: %s\n",
|
||||
s3Manager.storage.(*s3.Storage).Bucket)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Register managers globally
|
||||
_, err = Register("local", "local", ManagerOption{
|
||||
Driver: "local",
|
||||
MaxSize: "20M",
|
||||
AllowedTypes: []string{"text/*", "image/*"},
|
||||
Options: map[string]interface{}{
|
||||
"path": "/var/uploads",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("Failed to register local manager: %v", err)
|
||||
}
|
||||
|
||||
// Try to register S3 manager (will fail without credentials)
|
||||
_, err = Register("s3", "s3", ManagerOption{
|
||||
Driver: "s3",
|
||||
MaxSize: "100M",
|
||||
Options: map[string]interface{}{
|
||||
"bucket": "your-bucket",
|
||||
"key": "your-key",
|
||||
"secret": "your-secret",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("Failed to register S3 manager (expected without credentials): %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// 4. Example: Simple file upload
|
||||
content := "Hello, World! This is a test file with some content to demonstrate the attachment package."
|
||||
fileHeader := &FileHeader{
|
||||
FileHeader: &multipart.FileHeader{
|
||||
Filename: "hello.txt",
|
||||
Size: int64(len(content)),
|
||||
Header: make(map[string][]string),
|
||||
},
|
||||
}
|
||||
fileHeader.Header.Set("Content-Type", "text/plain")
|
||||
|
||||
uploadOption := UploadOption{
|
||||
Groups: []string{"user123"},
|
||||
Gzip: false, // No compression for small text files
|
||||
}
|
||||
|
||||
file, err := localManager.Upload(ctx, fileHeader, strings.NewReader(content), uploadOption)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to upload file: %v", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Uploaded file: %s (ID: %s, Size: %d bytes)\n", file.Filename, file.ID, file.Bytes)
|
||||
|
||||
// 5. Example: File upload with gzip compression
|
||||
largeContent := strings.Repeat("This is a large text file that benefits from compression. ", 100)
|
||||
gzipFileHeader := &FileHeader{
|
||||
FileHeader: &multipart.FileHeader{
|
||||
Filename: "large_text.txt",
|
||||
Size: int64(len(largeContent)),
|
||||
Header: make(map[string][]string),
|
||||
},
|
||||
}
|
||||
gzipFileHeader.Header.Set("Content-Type", "text/plain")
|
||||
|
||||
gzipOption := UploadOption{
|
||||
Groups: []string{"user123"},
|
||||
Gzip: true, // Enable compression
|
||||
}
|
||||
|
||||
gzipFile, err := localManager.Upload(ctx, gzipFileHeader, strings.NewReader(largeContent), gzipOption)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to upload gzipped file: %v", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Uploaded compressed file: %s (ID: %s)\n", gzipFile.Filename, gzipFile.ID)
|
||||
|
||||
// 6. Example: Image upload with compression
|
||||
imageUploadOption := UploadOption{
|
||||
Groups: []string{"user123"},
|
||||
CompressImage: true,
|
||||
CompressSize: 1920, // Resize to max 1920px
|
||||
Gzip: false,
|
||||
}
|
||||
|
||||
// Simulate image upload (you would get this from multipart form)
|
||||
imageHeader := &FileHeader{
|
||||
FileHeader: &multipart.FileHeader{
|
||||
Filename: "photo.jpg",
|
||||
Size: 1024000, // 1MB
|
||||
Header: make(map[string][]string),
|
||||
},
|
||||
}
|
||||
imageHeader.Header.Set("Content-Type", "image/jpeg")
|
||||
|
||||
fmt.Printf("Image upload option configured: compress=%v, size=%d\n",
|
||||
imageUploadOption.CompressImage, imageUploadOption.CompressSize)
|
||||
|
||||
// 6.5. Example: Multi-level groups
|
||||
fmt.Println("\n--- Multi-level Groups Examples ---")
|
||||
|
||||
// Single level grouping
|
||||
singleGroupOption := UploadOption{
|
||||
Groups: []string{"knowledge"},
|
||||
}
|
||||
|
||||
singleGroupHeader := &FileHeader{
|
||||
FileHeader: &multipart.FileHeader{
|
||||
Filename: "knowledge_doc.txt",
|
||||
Size: int64(len("Knowledge base document")),
|
||||
Header: make(map[string][]string),
|
||||
},
|
||||
}
|
||||
singleGroupHeader.Header.Set("Content-Type", "text/plain")
|
||||
|
||||
singleFile, err := localManager.Upload(ctx, singleGroupHeader, strings.NewReader("Knowledge base document"), singleGroupOption)
|
||||
if err != nil {
|
||||
log.Printf("Failed to upload single group file: %v", err)
|
||||
} else {
|
||||
fmt.Printf("Single group file uploaded: %s (ID: %s)\n", singleFile.Filename, singleFile.ID)
|
||||
}
|
||||
|
||||
// Multi-level grouping
|
||||
multiGroupOption := UploadOption{
|
||||
Groups: []string{"users", "user123", "chats", "chat456", "documents"},
|
||||
}
|
||||
|
||||
multiGroupHeader := &FileHeader{
|
||||
FileHeader: &multipart.FileHeader{
|
||||
Filename: "chat_document.txt",
|
||||
Size: int64(len("Document in user chat")),
|
||||
Header: make(map[string][]string),
|
||||
},
|
||||
}
|
||||
multiGroupHeader.Header.Set("Content-Type", "text/plain")
|
||||
|
||||
multiFile, err := localManager.Upload(ctx, multiGroupHeader, strings.NewReader("Document in user chat"), multiGroupOption)
|
||||
if err != nil {
|
||||
log.Printf("Failed to upload multi-group file: %v", err)
|
||||
} else {
|
||||
fmt.Printf("Multi-level group file uploaded: %s (ID: %s)\n", multiFile.Filename, multiFile.ID)
|
||||
fmt.Printf("File path includes hierarchy: users/user123/chats/chat456/documents\n")
|
||||
}
|
||||
|
||||
// Knowledge base organization
|
||||
knowledgeOption := UploadOption{
|
||||
Groups: []string{"knowledge", "technical", "api", "documentation"},
|
||||
}
|
||||
|
||||
knowledgeHeader := &FileHeader{
|
||||
FileHeader: &multipart.FileHeader{
|
||||
Filename: "api_guide.md",
|
||||
Size: int64(len("# API Documentation\n\nThis is technical documentation.")),
|
||||
Header: make(map[string][]string),
|
||||
},
|
||||
}
|
||||
knowledgeHeader.Header.Set("Content-Type", "text/markdown")
|
||||
|
||||
knowledgeFile, err := localManager.Upload(ctx, knowledgeHeader,
|
||||
strings.NewReader("# API Documentation\n\nThis is technical documentation."), knowledgeOption)
|
||||
if err != nil {
|
||||
log.Printf("Failed to upload knowledge file: %v", err)
|
||||
} else {
|
||||
fmt.Printf("Knowledge base file uploaded: %s (ID: %s)\n", knowledgeFile.Filename, knowledgeFile.ID)
|
||||
fmt.Printf("Organized in: knowledge/technical/api/documentation\n")
|
||||
}
|
||||
|
||||
// 7. Example: Chunked upload
|
||||
largeContent = strings.Repeat("This is a large file content that will be uploaded in chunks. ", 1000)
|
||||
chunkSize := 1024
|
||||
totalSize := len(largeContent)
|
||||
uid := "unique-large-file-123"
|
||||
|
||||
fmt.Printf("Starting chunked upload: total size=%d, chunk size=%d\n", totalSize, chunkSize)
|
||||
|
||||
var lastFile *File
|
||||
chunkCount := 0
|
||||
|
||||
// Split into chunks and upload
|
||||
for i := 0; i < totalSize; i += chunkSize {
|
||||
end := i + chunkSize
|
||||
if end > totalSize {
|
||||
end = totalSize
|
||||
}
|
||||
chunk := largeContent[i:end]
|
||||
|
||||
chunkHeader := &FileHeader{
|
||||
FileHeader: &multipart.FileHeader{
|
||||
Filename: "large_file.txt",
|
||||
Size: int64(len(chunk)),
|
||||
Header: make(map[string][]string),
|
||||
},
|
||||
}
|
||||
chunkHeader.Header.Set("Content-Type", "text/plain")
|
||||
chunkHeader.Header.Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", i, end-1, totalSize))
|
||||
chunkHeader.Header.Set("Content-Uid", uid)
|
||||
|
||||
chunkOption := UploadOption{
|
||||
Groups: []string{"user123"},
|
||||
Gzip: true, // Compress chunks
|
||||
}
|
||||
|
||||
chunkFile, err := localManager.Upload(ctx, chunkHeader, strings.NewReader(chunk), chunkOption)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to upload chunk %d: %v", chunkCount, err)
|
||||
}
|
||||
|
||||
chunkCount++
|
||||
lastFile = chunkFile
|
||||
|
||||
// Check if this is the last chunk
|
||||
if chunkHeader.Complete() {
|
||||
fmt.Printf("Uploaded large file in %d chunks: %s (ID: %s)\n", chunkCount, chunkFile.Filename, chunkFile.ID)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Example: Download and read files
|
||||
if file != nil {
|
||||
// Download as stream
|
||||
response, err := localManager.Download(ctx, file.ID)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to download file: %v", err)
|
||||
}
|
||||
defer response.Reader.Close()
|
||||
|
||||
fmt.Printf("Downloaded file content type: %s, extension: %s\n", response.ContentType, response.Extension)
|
||||
|
||||
// Read as bytes
|
||||
data, err := localManager.Read(ctx, file.ID)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to read file: %v", err)
|
||||
}
|
||||
|
||||
fmt.Printf("File content length: %d bytes\n", len(data))
|
||||
if len(data) < 100 {
|
||||
fmt.Printf("File content: %s\n", string(data))
|
||||
} else {
|
||||
fmt.Printf("File content preview: %s...\n", string(data[:100]))
|
||||
}
|
||||
|
||||
// Read as base64
|
||||
base64Data, err := localManager.ReadBase64(ctx, file.ID)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to read file as base64: %v", err)
|
||||
}
|
||||
|
||||
fmt.Printf("File as base64 (first 50 chars): %s...\n", base64Data[:min(50, len(base64Data))])
|
||||
}
|
||||
|
||||
// 9. Example: Read chunked file
|
||||
if lastFile != nil {
|
||||
chunkData, err := localManager.Read(ctx, lastFile.ID)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to read chunked file: %v", err)
|
||||
}
|
||||
|
||||
// Since the chunks were compressed, we need to decompress
|
||||
decompressed, err := Gunzip(chunkData)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to decompress chunked file: %v", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Chunked file content length: %d bytes (decompressed)\n", len(decompressed))
|
||||
if len(decompressed) < 200 {
|
||||
fmt.Printf("Chunked file content: %s\n", string(decompressed))
|
||||
} else {
|
||||
fmt.Printf("Chunked file content preview: %s...\n", string(decompressed[:200]))
|
||||
}
|
||||
}
|
||||
|
||||
// 10. Example: Using global managers
|
||||
globalManager := Managers["local"]
|
||||
if globalManager != nil {
|
||||
fmt.Println("Using global manager for local storage")
|
||||
|
||||
// Test a simple upload with global manager
|
||||
testContent := "Test content using global manager"
|
||||
testHeader := &FileHeader{
|
||||
FileHeader: &multipart.FileHeader{
|
||||
Filename: "global_test.txt",
|
||||
Size: int64(len(testContent)),
|
||||
Header: make(map[string][]string),
|
||||
},
|
||||
}
|
||||
testHeader.Header.Set("Content-Type", "text/plain")
|
||||
|
||||
testFile, err := globalManager.Upload(ctx, testHeader, strings.NewReader(testContent), UploadOption{
|
||||
Groups: []string{"global_user"},
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("Failed to upload with global manager: %v", err)
|
||||
} else {
|
||||
fmt.Printf("Global manager upload successful: %s\n", testFile.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// 11. Example: File validation
|
||||
fmt.Println("\n--- File Validation Examples ---")
|
||||
|
||||
// Test file size validation
|
||||
tooLargeContent := strings.Repeat("x", 25*1024*1024) // 25MB, exceeds 20MB limit
|
||||
largeFileHeader := &FileHeader{
|
||||
FileHeader: &multipart.FileHeader{
|
||||
Filename: "too_large.txt",
|
||||
Size: int64(len(tooLargeContent)),
|
||||
Header: make(map[string][]string),
|
||||
},
|
||||
}
|
||||
largeFileHeader.Header.Set("Content-Type", "text/plain")
|
||||
|
||||
_, err = localManager.Upload(ctx, largeFileHeader, strings.NewReader(tooLargeContent), UploadOption{})
|
||||
if err != nil {
|
||||
fmt.Printf("Expected error for large file: %v\n", err)
|
||||
}
|
||||
|
||||
// Test file type validation
|
||||
invalidFileHeader := &FileHeader{
|
||||
FileHeader: &multipart.FileHeader{
|
||||
Filename: "script.exe",
|
||||
Size: 1024,
|
||||
Header: make(map[string][]string),
|
||||
},
|
||||
}
|
||||
invalidFileHeader.Header.Set("Content-Type", "application/x-executable")
|
||||
|
||||
_, err = localManager.Upload(ctx, invalidFileHeader, strings.NewReader("fake exe content"), UploadOption{})
|
||||
if err != nil {
|
||||
fmt.Printf("Expected error for invalid file type: %v\n", err)
|
||||
}
|
||||
|
||||
fmt.Println("\n--- Example Usage Complete ---")
|
||||
}
|
||||
|
||||
// ExampleChunkedUpload demonstrates how to handle chunked uploads properly
|
||||
func ExampleChunkedUpload(manager *Manager, filename string, totalSize int64, contentType string) error {
|
||||
ctx := context.Background()
|
||||
chunkSize := int64(1024 * 1024) // 1MB chunks
|
||||
uid := "unique-file-" + filename
|
||||
|
||||
fmt.Printf("Starting chunked upload: file=%s, total=%d bytes, chunks=%d\n",
|
||||
filename, totalSize, (totalSize+chunkSize-1)/chunkSize)
|
||||
|
||||
for offset := int64(0); offset < totalSize; offset += chunkSize {
|
||||
end := offset + chunkSize - 1
|
||||
if end >= totalSize {
|
||||
end = totalSize - 1
|
||||
}
|
||||
|
||||
chunkSize := end - offset + 1
|
||||
|
||||
// Create chunk header
|
||||
chunkHeader := &FileHeader{
|
||||
FileHeader: &multipart.FileHeader{
|
||||
Filename: filename,
|
||||
Size: chunkSize,
|
||||
Header: make(map[string][]string),
|
||||
},
|
||||
}
|
||||
chunkHeader.Header.Set("Content-Type", contentType)
|
||||
chunkHeader.Header.Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", offset, end, totalSize))
|
||||
chunkHeader.Header.Set("Content-Uid", uid)
|
||||
|
||||
// In real usage, you would read the actual chunk data from the source
|
||||
chunkData := make([]byte, chunkSize)
|
||||
// Fill with sample data for demonstration
|
||||
for i := range chunkData {
|
||||
chunkData[i] = byte('A' + (i % 26))
|
||||
}
|
||||
|
||||
option := UploadOption{
|
||||
Groups: []string{"user123"},
|
||||
Gzip: false, // Disable compression for this example
|
||||
}
|
||||
|
||||
file, err := manager.Upload(ctx, chunkHeader, bytes.NewReader(chunkData), option)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to upload chunk at offset %d: %w", offset, err)
|
||||
}
|
||||
|
||||
fmt.Printf("Uploaded chunk %d-%d/%d\n", offset, end, totalSize)
|
||||
|
||||
// Check if this was the last chunk
|
||||
if chunkHeader.Complete() {
|
||||
fmt.Printf("File upload completed: %s (ID: %s)\n", file.Filename, file.ID)
|
||||
|
||||
// Verify the uploaded file
|
||||
data, err := manager.Read(ctx, file.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read uploaded file: %w", err)
|
||||
}
|
||||
|
||||
if int64(len(data)) != totalSize {
|
||||
return fmt.Errorf("uploaded file size mismatch: expected %d, got %d", totalSize, len(data))
|
||||
}
|
||||
|
||||
fmt.Printf("File verification successful: %d bytes\n", len(data))
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExampleS3Upload demonstrates S3-specific features
|
||||
func ExampleS3Upload() {
|
||||
// This example requires actual S3 credentials
|
||||
s3Manager, err := New(ManagerOption{
|
||||
Driver: "s3",
|
||||
MaxSize: "50M",
|
||||
Options: map[string]interface{}{
|
||||
"endpoint": "https://s3.amazonaws.com",
|
||||
"region": "us-east-1",
|
||||
"key": "your-access-key",
|
||||
"secret": "your-secret-key",
|
||||
"bucket": "your-bucket",
|
||||
"prefix": "test-uploads/",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("S3 manager creation failed (expected without credentials): %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
content := "Test content for S3 upload"
|
||||
|
||||
fileHeader := &FileHeader{
|
||||
FileHeader: &multipart.FileHeader{
|
||||
Filename: "s3_test.txt",
|
||||
Size: int64(len(content)),
|
||||
Header: make(map[string][]string),
|
||||
},
|
||||
}
|
||||
fileHeader.Header.Set("Content-Type", "text/plain")
|
||||
|
||||
file, err := s3Manager.Upload(ctx, fileHeader, strings.NewReader(content), UploadOption{
|
||||
Groups: []string{"s3_user"},
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("S3 upload failed: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("S3 upload successful: %s\n", file.ID)
|
||||
|
||||
// Get presigned URL
|
||||
url := s3Manager.storage.URL(ctx, file.ID)
|
||||
fmt.Printf("Presigned URL: %s\n", url)
|
||||
}
|
||||
|
||||
// Helper function for min
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
139
attachment/fileheader.go
Normal file
139
attachment/fileheader.go
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
package attachment
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// UID is the uid of the file, it is the unique identifier of the file
|
||||
func (fileheader *FileHeader) UID() string {
|
||||
return fileheader.Header.Get("Content-Uid")
|
||||
}
|
||||
|
||||
// Fingerprint is the fingerprint of the file, it is the fingerprint of the file
|
||||
func (fileheader *FileHeader) Fingerprint() string {
|
||||
return fileheader.Header.Get("Content-Fingerprint")
|
||||
}
|
||||
|
||||
// Range is the range of the file, it is the start and end of the file
|
||||
func (fileheader *FileHeader) Range() string {
|
||||
return fileheader.Header.Get("Content-Range")
|
||||
}
|
||||
|
||||
// Sync is the sync of the file, it is the sync of the file
|
||||
func (fileheader *FileHeader) Sync() bool {
|
||||
return fileheader.Header.Get("Content-Sync") == "true"
|
||||
}
|
||||
|
||||
// IsChunk is the chunk of the file, it is the chunk of the file
|
||||
func (fileheader *FileHeader) IsChunk() bool {
|
||||
return fileheader.Range() != ""
|
||||
}
|
||||
|
||||
// Complete checks if the chunk upload is completed
|
||||
// For non-chunk files, it returns true
|
||||
// For chunk files, it parses the Content-Range header to determine if this is the last chunk
|
||||
func (fileheader *FileHeader) Complete() bool {
|
||||
if !fileheader.IsChunk() {
|
||||
return true
|
||||
}
|
||||
|
||||
// Parse Content-Range header: "bytes start-end/total"
|
||||
rangeHeader := fileheader.Range()
|
||||
if rangeHeader == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
// Remove "bytes " prefix
|
||||
rangeStr := strings.TrimPrefix(rangeHeader, "bytes ")
|
||||
|
||||
// Split by "/"
|
||||
parts := strings.Split(rangeStr, "/")
|
||||
if len(parts) != 2 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Parse total size
|
||||
total, err := strconv.ParseInt(parts[1], 10, 64)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Parse range "start-end"
|
||||
rangeParts := strings.Split(parts[0], "-")
|
||||
if len(rangeParts) != 2 {
|
||||
return false
|
||||
}
|
||||
|
||||
end, err := strconv.ParseInt(rangeParts[1], 10, 64)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if this is the last chunk: end + 1 == total
|
||||
return end+1 == total
|
||||
}
|
||||
|
||||
// GetChunkInfo returns the chunk information parsed from Content-Range header
|
||||
// Returns start, end, total, and error
|
||||
func (fileheader *FileHeader) GetChunkInfo() (start, end, total int64, err error) {
|
||||
if !fileheader.IsChunk() {
|
||||
return 0, 0, 0, nil
|
||||
}
|
||||
|
||||
rangeHeader := fileheader.Range()
|
||||
if rangeHeader == "" {
|
||||
return 0, 0, 0, nil
|
||||
}
|
||||
|
||||
// Remove "bytes " prefix
|
||||
rangeStr := strings.TrimPrefix(rangeHeader, "bytes ")
|
||||
|
||||
// Split by "/"
|
||||
parts := strings.Split(rangeStr, "/")
|
||||
if len(parts) != 2 {
|
||||
return 0, 0, 0, nil
|
||||
}
|
||||
|
||||
// Parse total size
|
||||
total, err = strconv.ParseInt(parts[1], 10, 64)
|
||||
if err != nil {
|
||||
return 0, 0, 0, err
|
||||
}
|
||||
|
||||
// Parse range "start-end"
|
||||
rangeParts := strings.Split(parts[0], "-")
|
||||
if len(rangeParts) != 2 {
|
||||
return 0, 0, 0, nil
|
||||
}
|
||||
|
||||
start, err = strconv.ParseInt(rangeParts[0], 10, 64)
|
||||
if err != nil {
|
||||
return 0, 0, 0, err
|
||||
}
|
||||
|
||||
end, err = strconv.ParseInt(rangeParts[1], 10, 64)
|
||||
if err != nil {
|
||||
return 0, 0, 0, err
|
||||
}
|
||||
|
||||
return start, end, total, nil
|
||||
}
|
||||
|
||||
// GetTotalSize returns the total file size from Content-Range header
|
||||
func (fileheader *FileHeader) GetTotalSize() int64 {
|
||||
_, _, total, err := fileheader.GetChunkInfo()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// GetChunkSize returns the current chunk size
|
||||
func (fileheader *FileHeader) GetChunkSize() int64 {
|
||||
start, end, _, err := fileheader.GetChunkInfo()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return end - start + 1
|
||||
}
|
||||
285
attachment/gzip.go
Normal file
285
attachment/gzip.go
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
package attachment
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
// GzipCompressor supports chunked compression for Gzip
|
||||
type GzipCompressor struct {
|
||||
writer *gzip.Writer
|
||||
buffer *bytes.Buffer
|
||||
file *os.File // optional file handle
|
||||
}
|
||||
|
||||
// NewGzipCompressor creates a new Gzip compressor
|
||||
func NewGzipCompressor() *GzipCompressor {
|
||||
buf := &bytes.Buffer{}
|
||||
gz := gzip.NewWriter(buf)
|
||||
return &GzipCompressor{
|
||||
writer: gz,
|
||||
buffer: buf,
|
||||
file: nil,
|
||||
}
|
||||
}
|
||||
|
||||
// NewGzipCompressorFromFile creates a Gzip compressor from file, supports streaming read
|
||||
func NewGzipCompressorFromFile(filePath string) (*GzipCompressor, error) {
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open file %s: %w", filePath, err)
|
||||
}
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
gz := gzip.NewWriter(buf)
|
||||
return &GzipCompressor{
|
||||
writer: gz,
|
||||
buffer: buf,
|
||||
file: file,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ReadChunk reads a chunk of specified size from file and compresses it
|
||||
func (gc *GzipCompressor) ReadChunk(chunkSize int) (bool, error) {
|
||||
if gc.file == nil {
|
||||
return false, fmt.Errorf("no file associated with this compressor")
|
||||
}
|
||||
|
||||
chunk := make([]byte, chunkSize)
|
||||
n, err := gc.file.Read(chunk)
|
||||
if err != nil && err != io.EOF {
|
||||
return false, fmt.Errorf("failed to read from file: %w", err)
|
||||
}
|
||||
|
||||
if n > 0 {
|
||||
if err := gc.Write(chunk[:n]); err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
|
||||
// return whether there is more data
|
||||
return err != io.EOF, nil
|
||||
}
|
||||
|
||||
// CompressFileInChunks compresses the entire file in chunks
|
||||
func (gc *GzipCompressor) CompressFileInChunks(chunkSize int) error {
|
||||
if gc.file == nil {
|
||||
return fmt.Errorf("no file associated with this compressor")
|
||||
}
|
||||
|
||||
for {
|
||||
hasMore, err := gc.ReadChunk(chunkSize)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !hasMore {
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Write writes data for compression (supports chunked writing)
|
||||
func (gc *GzipCompressor) Write(data []byte) error {
|
||||
_, err := gc.writer.Write(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write data to gzip: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Flush flushes the buffer but does not close the compressor
|
||||
func (gc *GzipCompressor) Flush() error {
|
||||
return gc.writer.Flush()
|
||||
}
|
||||
|
||||
// Close closes the compressor and returns the final compressed data
|
||||
func (gc *GzipCompressor) Close() ([]byte, error) {
|
||||
err := gc.writer.Close()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to close gzip writer: %w", err)
|
||||
}
|
||||
|
||||
// if there is an associated file, close it too
|
||||
if gc.file != nil {
|
||||
gc.file.Close()
|
||||
gc.file = nil
|
||||
}
|
||||
|
||||
return gc.buffer.Bytes(), nil
|
||||
}
|
||||
|
||||
// GetCompressedData gets the current compressed data (without closing the compressor)
|
||||
func (gc *GzipCompressor) GetCompressedData() []byte {
|
||||
// flush the buffer first
|
||||
gc.writer.Flush()
|
||||
return gc.buffer.Bytes()
|
||||
}
|
||||
|
||||
// Reset resets the compressor for reuse
|
||||
func (gc *GzipCompressor) Reset() {
|
||||
gc.buffer.Reset()
|
||||
gc.writer.Reset(gc.buffer)
|
||||
if gc.file != nil {
|
||||
gc.file.Close()
|
||||
gc.file = nil
|
||||
}
|
||||
}
|
||||
|
||||
// Gzip compresses data in one go
|
||||
func Gzip(data []byte) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
gz := gzip.NewWriter(&buf)
|
||||
_, err := gz.Write(data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to gzip data: %w", err)
|
||||
}
|
||||
err = gz.Close()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to close gzip writer: %w", err)
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// GzipChunks compresses multiple data chunks
|
||||
func GzipChunks(chunks [][]byte) ([]byte, error) {
|
||||
compressor := NewGzipCompressor()
|
||||
|
||||
for _, chunk := range chunks {
|
||||
if err := compressor.Write(chunk); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return compressor.Close()
|
||||
}
|
||||
|
||||
// GzipFromReader compresses data from Reader stream
|
||||
func GzipFromReader(reader io.Reader) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
gz := gzip.NewWriter(&buf)
|
||||
|
||||
_, err := io.Copy(gz, reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to copy data to gzip: %w", err)
|
||||
}
|
||||
|
||||
err = gz.Close()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to close gzip writer: %w", err)
|
||||
}
|
||||
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// Gunzip decompresses gzip data
|
||||
func Gunzip(data []byte) ([]byte, error) {
|
||||
reader, err := gzip.NewReader(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create gzip reader: %w", err)
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
var buf bytes.Buffer
|
||||
_, err = io.Copy(&buf, reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decompress data: %w", err)
|
||||
}
|
||||
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// GzipToWriter writes compressed data to Writer
|
||||
func GzipToWriter(data []byte, writer io.Writer) error {
|
||||
gz := gzip.NewWriter(writer)
|
||||
defer gz.Close()
|
||||
|
||||
_, err := gz.Write(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write gzip data: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GzipFromReaderToWriter reads data from Reader and writes compressed data to Writer
|
||||
func GzipFromReaderToWriter(reader io.Reader, writer io.Writer) error {
|
||||
gz := gzip.NewWriter(writer)
|
||||
defer gz.Close()
|
||||
|
||||
_, err := io.Copy(gz, reader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to copy and compress data: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GzipFile compresses entire file (loads into memory at once)
|
||||
func GzipFile(filePath string) ([]byte, error) {
|
||||
data, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read file %s: %w", filePath, err)
|
||||
}
|
||||
return Gzip(data)
|
||||
}
|
||||
|
||||
// GzipFileInChunks compresses file in chunks (memory friendly)
|
||||
func GzipFileInChunks(filePath string, chunkSize int) ([]byte, error) {
|
||||
compressor, err := NewGzipCompressorFromFile(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer compressor.Close()
|
||||
|
||||
err = compressor.CompressFileInChunks(chunkSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return compressor.Close()
|
||||
}
|
||||
|
||||
// GzipFileToFile compresses file and saves to another file
|
||||
func GzipFileToFile(srcPath, dstPath string, chunkSize int) error {
|
||||
srcFile, err := os.Open(srcPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open source file %s: %w", srcPath, err)
|
||||
}
|
||||
defer srcFile.Close()
|
||||
|
||||
dstFile, err := os.Create(dstPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create destination file %s: %w", dstPath, err)
|
||||
}
|
||||
defer dstFile.Close()
|
||||
|
||||
return GzipFromReaderToWriter(srcFile, dstFile)
|
||||
}
|
||||
|
||||
// GzipFileStream compresses file in streaming mode, returns Reader interface
|
||||
func GzipFileStream(filePath string) (io.Reader, error) {
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open file %s: %w", filePath, err)
|
||||
}
|
||||
|
||||
pr, pw := io.Pipe()
|
||||
go func() {
|
||||
defer pw.Close()
|
||||
defer file.Close()
|
||||
|
||||
gz := gzip.NewWriter(pw)
|
||||
defer gz.Close()
|
||||
|
||||
_, err := io.Copy(gz, file)
|
||||
if err != nil {
|
||||
pw.CloseWithError(err)
|
||||
}
|
||||
}()
|
||||
|
||||
return pr, nil
|
||||
}
|
||||
152
attachment/load.go
Normal file
152
attachment/load.go
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
package attachment
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/yaoapp/gou/application"
|
||||
"github.com/yaoapp/kun/log"
|
||||
"github.com/yaoapp/yao/config"
|
||||
"github.com/yaoapp/yao/data"
|
||||
"github.com/yaoapp/yao/share"
|
||||
)
|
||||
|
||||
// SystemUploaders system uploaders
|
||||
var systemUploaders = map[string]string{
|
||||
"__yao.attachment": "yao/uploaders/attachment.local.yao",
|
||||
}
|
||||
|
||||
// Load load uploaders
|
||||
func Load(cfg config.Config) error {
|
||||
messages := []string{}
|
||||
|
||||
// Load system uploaders
|
||||
err := loadSystemUploaders(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Load filesystem uploaders
|
||||
exts := []string{"*.s3.yao", "*.local.yao", "*.s3.json", "*.local.json", "*.s3.jsonc", "*.local.jsonc"}
|
||||
err = application.App.Walk("uploaders", func(root, file string, isdir bool) error {
|
||||
if isdir {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Skip if not uploader file
|
||||
if !isUploaderFile(file) {
|
||||
return nil
|
||||
}
|
||||
|
||||
err := loadUploaderFile(root, file, cfg)
|
||||
if err != nil {
|
||||
messages = append(messages, err.Error())
|
||||
}
|
||||
return err
|
||||
}, exts...)
|
||||
|
||||
if len(messages) > 0 {
|
||||
for _, message := range messages {
|
||||
log.Error("Load filesystem uploaders error: %s", message)
|
||||
}
|
||||
return fmt.Errorf(strings.Join(messages, ";\n"))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadSystemUploaders load system uploaders
|
||||
func loadSystemUploaders(cfg config.Config) error {
|
||||
for id, path := range systemUploaders {
|
||||
content, err := data.Read(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Parse uploader config
|
||||
var option ManagerOption
|
||||
err = application.Parse(path, content, &option)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Replace environment variables and paths
|
||||
option.ReplaceEnv(cfg.DataRoot)
|
||||
|
||||
// Register the uploader manager
|
||||
_, err = Register(id, option.Driver, option)
|
||||
if err != nil {
|
||||
log.Error("register system uploader %s error: %s", id, err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
log.Info("loaded system uploader: %s (%s)", id, option.Label)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadUploaderFile load a single uploader file
|
||||
func loadUploaderFile(root, file string, cfg config.Config) error {
|
||||
// Generate uploader ID
|
||||
id := share.ID(root, file)
|
||||
|
||||
// Read file content
|
||||
content, err := application.App.Read(file)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read uploader file %s: %v", file, err)
|
||||
}
|
||||
|
||||
// Parse uploader config
|
||||
var option ManagerOption
|
||||
err = application.Parse(file, content, &option)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse uploader file %s: %v", file, err)
|
||||
}
|
||||
|
||||
// Validate driver consistency between filename and config
|
||||
filenameDriver := extractDriverFromFilename(file)
|
||||
if filenameDriver != "" && option.Driver != "" && filenameDriver != option.Driver {
|
||||
log.Warn("Driver mismatch in uploader file %s: filename suggests '%s' but config has '%s'",
|
||||
file, filenameDriver, option.Driver)
|
||||
}
|
||||
|
||||
// Replace environment variables and paths
|
||||
option.ReplaceEnv(cfg.DataRoot)
|
||||
|
||||
// Register the uploader manager
|
||||
_, err = Register(id, option.Driver, option)
|
||||
if err != nil {
|
||||
log.Error("register uploader %s error: %s", id, err.Error())
|
||||
return fmt.Errorf("failed to register uploader %s: %v", id, err)
|
||||
}
|
||||
|
||||
log.Info("loaded uploader: %s (%s)", id, option.Label)
|
||||
return nil
|
||||
}
|
||||
|
||||
// isUploaderFile checks if the file is an uploader configuration file
|
||||
func isUploaderFile(filename string) bool {
|
||||
// Accept files with specific driver patterns: *.s3.yao, *.local.yao, etc.
|
||||
lower := strings.ToLower(filename)
|
||||
return strings.HasSuffix(lower, ".s3.yao") ||
|
||||
strings.HasSuffix(lower, ".local.yao") ||
|
||||
strings.HasSuffix(lower, ".s3.json") ||
|
||||
strings.HasSuffix(lower, ".local.json") ||
|
||||
strings.HasSuffix(lower, ".s3.jsonc") ||
|
||||
strings.HasSuffix(lower, ".local.jsonc")
|
||||
}
|
||||
|
||||
// extractDriverFromFilename extracts the driver name from filename (e.g., "test.s3.yao" -> "s3")
|
||||
func extractDriverFromFilename(filename string) string {
|
||||
lower := strings.ToLower(filename)
|
||||
|
||||
// Extract driver from patterns like "*.s3.yao", "*.local.json", etc.
|
||||
if strings.Contains(lower, ".s3.") {
|
||||
return "s3"
|
||||
} else if strings.Contains(lower, ".local.") {
|
||||
return "local"
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
48
attachment/load_test.go
Normal file
48
attachment/load_test.go
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
package attachment
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/yaoapp/yao/config"
|
||||
"github.com/yaoapp/yao/test"
|
||||
)
|
||||
|
||||
func TestLoad(t *testing.T) {
|
||||
test.Prepare(t, config.Conf)
|
||||
defer test.Clean()
|
||||
|
||||
err := Load(config.Conf)
|
||||
assert.NoError(t, err)
|
||||
check(t)
|
||||
}
|
||||
|
||||
func check(t *testing.T) {
|
||||
// Check that managers are loaded
|
||||
assert.NotEmpty(t, Managers, "Managers should not be empty after loading")
|
||||
|
||||
// Check system uploader
|
||||
_, exists := Managers["__yao.attachment"]
|
||||
assert.True(t, exists, "System uploader __yao.attachment should be loaded")
|
||||
|
||||
// Check test app uploaders (must exist)
|
||||
// These are the uploaders in yao-dev-app/uploaders/
|
||||
_, hasData := Managers["data"]
|
||||
_, hasTest := Managers["test"]
|
||||
|
||||
// Both test uploaders should be loaded
|
||||
assert.True(t, hasData, "Test uploader 'data' should be loaded from data.local.yao")
|
||||
assert.True(t, hasTest, "Test uploader 'test' should be loaded from test.s3.yao")
|
||||
|
||||
// Log all loaded managers for debugging
|
||||
t.Logf("Loaded managers: %v", getManagerNames())
|
||||
}
|
||||
|
||||
// getManagerNames returns a slice of manager names for testing
|
||||
func getManagerNames() []string {
|
||||
names := make([]string, 0, len(Managers))
|
||||
for name := range Managers {
|
||||
names = append(names, name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
510
attachment/local/storage.go
Normal file
510
attachment/local/storage.go
Normal file
|
|
@ -0,0 +1,510 @@
|
|||
package local
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// MaxImageSize maximum image size (1920x1080)
|
||||
const MaxImageSize = 1920
|
||||
|
||||
// Storage the local storage driver
|
||||
type Storage struct {
|
||||
Path string `json:"path" yaml:"path"`
|
||||
Compression bool `json:"compression" yaml:"compression"`
|
||||
BaseURL string `json:"base_url" yaml:"base_url"`
|
||||
PreviewURL func(fileID string) string `json:"-" yaml:"-"`
|
||||
}
|
||||
|
||||
// New create a new local storage
|
||||
func New(options map[string]interface{}) (*Storage, error) {
|
||||
storage := &Storage{
|
||||
Compression: true,
|
||||
}
|
||||
|
||||
if path, ok := options["path"].(string); ok {
|
||||
storage.Path = path
|
||||
}
|
||||
|
||||
if compression, ok := options["compression"].(bool); ok {
|
||||
storage.Compression = compression
|
||||
}
|
||||
|
||||
if baseURL, ok := options["base_url"].(string); ok {
|
||||
storage.BaseURL = baseURL
|
||||
}
|
||||
|
||||
if previewURL, ok := options["preview_url"].(func(string) string); ok {
|
||||
storage.PreviewURL = previewURL
|
||||
}
|
||||
|
||||
if storage.Path == "" {
|
||||
return nil, fmt.Errorf("path is required")
|
||||
}
|
||||
|
||||
// Ensure the base path exists
|
||||
if err := os.MkdirAll(storage.Path, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create base path: %w", err)
|
||||
}
|
||||
|
||||
return storage, nil
|
||||
}
|
||||
|
||||
// Upload upload file to local storage
|
||||
func (storage *Storage) Upload(ctx context.Context, path string, reader io.Reader, contentType string) (string, error) {
|
||||
fullPath := filepath.Join(storage.Path, path)
|
||||
|
||||
// Create directory if not exists
|
||||
dir := filepath.Dir(fullPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Create and write file
|
||||
file, err := os.Create(fullPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
_, err = io.Copy(file, reader)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// UploadChunk uploads a chunk of a file
|
||||
func (storage *Storage) UploadChunk(ctx context.Context, path string, chunkIndex int, reader io.Reader, contentType string) error {
|
||||
// Create chunks directory
|
||||
chunksDir := filepath.Join(storage.Path, ".chunks", path)
|
||||
if err := os.MkdirAll(chunksDir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Write chunk file
|
||||
chunkPath := filepath.Join(chunksDir, fmt.Sprintf("chunk_%d", chunkIndex))
|
||||
file, err := os.Create(chunkPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
_, err = io.Copy(file, reader)
|
||||
return err
|
||||
}
|
||||
|
||||
// MergeChunks merges all chunks into the final file
|
||||
func (storage *Storage) MergeChunks(ctx context.Context, path string, totalChunks int) error {
|
||||
chunksDir := filepath.Join(storage.Path, ".chunks", path)
|
||||
finalPath := filepath.Join(storage.Path, path)
|
||||
|
||||
// Create directory for final file
|
||||
dir := filepath.Dir(finalPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create final file
|
||||
finalFile, err := os.Create(finalPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer finalFile.Close()
|
||||
|
||||
// Read and merge chunks in order
|
||||
for i := 0; i < totalChunks; i++ {
|
||||
chunkPath := filepath.Join(chunksDir, fmt.Sprintf("chunk_%d", i))
|
||||
chunkFile, err := os.Open(chunkPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read chunk %d: %w", i, err)
|
||||
}
|
||||
|
||||
_, err = io.Copy(finalFile, chunkFile)
|
||||
chunkFile.Close()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to copy chunk %d: %w", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up chunks directory
|
||||
os.RemoveAll(chunksDir)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reader read file from local storage
|
||||
func (storage *Storage) Reader(ctx context.Context, path string) (io.ReadCloser, error) {
|
||||
fullpath := filepath.Join(storage.Path, path)
|
||||
|
||||
reader, err := os.Open(fullpath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If the file is a gzip file, decompress it
|
||||
if strings.HasSuffix(path, ".gz") {
|
||||
reader, err := gzip.NewReader(reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return reader, nil
|
||||
}
|
||||
|
||||
return reader, nil
|
||||
}
|
||||
|
||||
// Download download file from local storage
|
||||
func (storage *Storage) Download(ctx context.Context, path string) (io.ReadCloser, string, error) {
|
||||
fullPath := filepath.Join(storage.Path, path)
|
||||
reader, err := os.Open(fullPath)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
// Try to detect content type from file extension
|
||||
contentType := "application/octet-stream"
|
||||
ext := filepath.Ext(strings.TrimSuffix(path, ".gz"))
|
||||
switch strings.ToLower(ext) {
|
||||
case ".txt":
|
||||
contentType = "text/plain"
|
||||
case ".html":
|
||||
contentType = "text/html"
|
||||
case ".css":
|
||||
contentType = "text/css"
|
||||
case ".js":
|
||||
contentType = "application/javascript"
|
||||
case ".json":
|
||||
contentType = "application/json"
|
||||
case ".jpg", ".jpeg":
|
||||
contentType = "image/jpeg"
|
||||
case ".png":
|
||||
contentType = "image/png"
|
||||
case ".gif":
|
||||
contentType = "image/gif"
|
||||
case ".pdf":
|
||||
contentType = "application/pdf"
|
||||
case ".mp4":
|
||||
contentType = "video/mp4"
|
||||
case ".mp3":
|
||||
contentType = "audio/mpeg"
|
||||
case ".wav":
|
||||
contentType = "audio/wav"
|
||||
case ".ogg":
|
||||
contentType = "audio/ogg"
|
||||
case ".webm":
|
||||
contentType = "video/webm"
|
||||
case ".webp":
|
||||
contentType = "image/webp"
|
||||
case ".zip":
|
||||
}
|
||||
|
||||
// If the file is a gzip file, decompress it
|
||||
if strings.HasSuffix(path, ".gz") {
|
||||
reader, err := gzip.NewReader(reader)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return reader, contentType, nil
|
||||
}
|
||||
|
||||
return reader, contentType, nil
|
||||
}
|
||||
|
||||
// URL get file url
|
||||
func (storage *Storage) URL(ctx context.Context, path string) string {
|
||||
if storage.PreviewURL != nil {
|
||||
return storage.PreviewURL(path)
|
||||
}
|
||||
if storage.BaseURL != "" {
|
||||
return fmt.Sprintf("%s/%s", strings.TrimRight(storage.BaseURL, "/"), path)
|
||||
}
|
||||
return fmt.Sprintf("%s/%s", storage.Path, path)
|
||||
}
|
||||
|
||||
// GetContent gets file content as bytes
|
||||
func (storage *Storage) GetContent(ctx context.Context, path string) ([]byte, error) {
|
||||
reader, err := storage.Reader(ctx, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
return io.ReadAll(reader)
|
||||
}
|
||||
|
||||
// Exists checks if a file exists
|
||||
func (storage *Storage) Exists(ctx context.Context, path string) bool {
|
||||
fullpath := filepath.Join(storage.Path, path)
|
||||
_, err := os.Stat(fullpath)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// Delete deletes a file
|
||||
func (storage *Storage) Delete(ctx context.Context, path string) error {
|
||||
fullpath := filepath.Join(storage.Path, path)
|
||||
return os.Remove(fullpath)
|
||||
}
|
||||
|
||||
func (storage *Storage) makeID(filename string, ext string) string {
|
||||
date := time.Now().Format("20060102")
|
||||
hash := fmt.Sprintf("%x", sha256.Sum256([]byte(filename)))[:8]
|
||||
name := strings.TrimSuffix(filepath.Base(filename), ext)
|
||||
return fmt.Sprintf("%s/%s-%s%s", date, name, hash, ext)
|
||||
}
|
||||
|
||||
// LocalPath returns the absolute path of the file and its content type
|
||||
func (storage *Storage) LocalPath(ctx context.Context, path string) (string, string, error) {
|
||||
fullPath := filepath.Join(storage.Path, path)
|
||||
|
||||
// Check if file exists
|
||||
if _, err := os.Stat(fullPath); os.IsNotExist(err) {
|
||||
return "", "", fmt.Errorf("file not found: %s", path)
|
||||
}
|
||||
|
||||
// For gzipped files, we need to detect the original content type, not the gzip wrapper
|
||||
var contentType string
|
||||
var err error
|
||||
|
||||
if strings.HasSuffix(path, ".gz") {
|
||||
// For gzipped files, detect content type of the decompressed content
|
||||
originalPath := strings.TrimSuffix(path, ".gz")
|
||||
ext := filepath.Ext(originalPath)
|
||||
|
||||
// First try to detect by original file extension
|
||||
contentType, err = detectContentTypeFromExtension(ext)
|
||||
if err != nil || contentType == "application/octet-stream" {
|
||||
// Fallback: decompress and detect from content
|
||||
contentType, err = detectContentTypeFromGzippedFile(fullPath)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to detect content type from gzipped file: %w", err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Regular file content type detection
|
||||
contentType, err = detectContentType(fullPath)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to detect content type: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Return absolute path
|
||||
absPath, err := filepath.Abs(fullPath)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to get absolute path: %w", err)
|
||||
}
|
||||
|
||||
return absPath, contentType, nil
|
||||
}
|
||||
|
||||
// detectContentType detects content type based on file extension and content
|
||||
func detectContentType(filePath string) (string, error) {
|
||||
// First try to detect by file extension
|
||||
ext := strings.ToLower(filepath.Ext(filePath))
|
||||
|
||||
// Common file extensions mapping
|
||||
switch ext {
|
||||
case ".txt":
|
||||
return "text/plain", nil
|
||||
case ".html", ".htm":
|
||||
return "text/html", nil
|
||||
case ".css":
|
||||
return "text/css", nil
|
||||
case ".js":
|
||||
return "application/javascript", nil
|
||||
case ".json":
|
||||
return "application/json", nil
|
||||
case ".xml":
|
||||
return "application/xml", nil
|
||||
case ".jpg", ".jpeg":
|
||||
return "image/jpeg", nil
|
||||
case ".png":
|
||||
return "image/png", nil
|
||||
case ".gif":
|
||||
return "image/gif", nil
|
||||
case ".webp":
|
||||
return "image/webp", nil
|
||||
case ".svg":
|
||||
return "image/svg+xml", nil
|
||||
case ".pdf":
|
||||
return "application/pdf", nil
|
||||
case ".doc":
|
||||
return "application/msword", nil
|
||||
case ".docx":
|
||||
return "application/vnd.openxmlformats-officedocument.wordprocessingml.document", nil
|
||||
case ".xls":
|
||||
return "application/vnd.ms-excel", nil
|
||||
case ".xlsx":
|
||||
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", nil
|
||||
case ".ppt":
|
||||
return "application/vnd.ms-powerpoint", nil
|
||||
case ".pptx":
|
||||
return "application/vnd.openxmlformats-officedocument.presentationml.presentation", nil
|
||||
case ".zip":
|
||||
return "application/zip", nil
|
||||
case ".tar":
|
||||
return "application/x-tar", nil
|
||||
case ".gz":
|
||||
return "application/gzip", nil
|
||||
case ".mp3":
|
||||
return "audio/mpeg", nil
|
||||
case ".wav":
|
||||
return "audio/wav", nil
|
||||
case ".m4a":
|
||||
return "audio/mp4", nil
|
||||
case ".ogg":
|
||||
return "audio/ogg", nil
|
||||
case ".mp4":
|
||||
return "video/mp4", nil
|
||||
case ".avi":
|
||||
return "video/x-msvideo", nil
|
||||
case ".mov":
|
||||
return "video/quicktime", nil
|
||||
case ".webm":
|
||||
return "video/webm", nil
|
||||
case ".md", ".mdx":
|
||||
return "text/markdown", nil
|
||||
case ".yao":
|
||||
return "application/yao", nil
|
||||
case ".csv":
|
||||
return "text/csv", nil
|
||||
}
|
||||
|
||||
// Try to detect by MIME package
|
||||
if contentType := mime.TypeByExtension(ext); contentType != "" {
|
||||
return contentType, nil
|
||||
}
|
||||
|
||||
// Fallback: detect by reading file content
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return "application/octet-stream", nil // Default fallback
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Read first 512 bytes for content detection
|
||||
buffer := make([]byte, 512)
|
||||
n, err := file.Read(buffer)
|
||||
if err != nil && err != io.EOF {
|
||||
return "application/octet-stream", nil
|
||||
}
|
||||
|
||||
// Use http.DetectContentType to detect based on content
|
||||
contentType := http.DetectContentType(buffer[:n])
|
||||
return contentType, nil
|
||||
}
|
||||
|
||||
// detectContentTypeFromExtension detects content type based only on file extension
|
||||
func detectContentTypeFromExtension(ext string) (string, error) {
|
||||
ext = strings.ToLower(ext)
|
||||
|
||||
// Common file extensions mapping
|
||||
switch ext {
|
||||
case ".txt":
|
||||
return "text/plain", nil
|
||||
case ".html", ".htm":
|
||||
return "text/html", nil
|
||||
case ".css":
|
||||
return "text/css", nil
|
||||
case ".js":
|
||||
return "application/javascript", nil
|
||||
case ".json":
|
||||
return "application/json", nil
|
||||
case ".xml":
|
||||
return "application/xml", nil
|
||||
case ".jpg", ".jpeg":
|
||||
return "image/jpeg", nil
|
||||
case ".png":
|
||||
return "image/png", nil
|
||||
case ".gif":
|
||||
return "image/gif", nil
|
||||
case ".webp":
|
||||
return "image/webp", nil
|
||||
case ".svg":
|
||||
return "image/svg+xml", nil
|
||||
case ".pdf":
|
||||
return "application/pdf", nil
|
||||
case ".doc":
|
||||
return "application/msword", nil
|
||||
case ".docx":
|
||||
return "application/vnd.openxmlformats-officedocument.wordprocessingml.document", nil
|
||||
case ".xls":
|
||||
return "application/vnd.ms-excel", nil
|
||||
case ".xlsx":
|
||||
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", nil
|
||||
case ".ppt":
|
||||
return "application/vnd.ms-powerpoint", nil
|
||||
case ".pptx":
|
||||
return "application/vnd.openxmlformats-officedocument.presentationml.presentation", nil
|
||||
case ".zip":
|
||||
return "application/zip", nil
|
||||
case ".tar":
|
||||
return "application/x-tar", nil
|
||||
case ".mp3":
|
||||
return "audio/mpeg", nil
|
||||
case ".wav":
|
||||
return "audio/wav", nil
|
||||
case ".m4a":
|
||||
return "audio/mp4", nil
|
||||
case ".ogg":
|
||||
return "audio/ogg", nil
|
||||
case ".mp4":
|
||||
return "video/mp4", nil
|
||||
case ".avi":
|
||||
return "video/x-msvideo", nil
|
||||
case ".mov":
|
||||
return "video/quicktime", nil
|
||||
case ".webm":
|
||||
return "video/webm", nil
|
||||
case ".md", ".mdx":
|
||||
return "text/markdown", nil
|
||||
case ".yao":
|
||||
return "application/yao", nil
|
||||
case ".csv":
|
||||
return "text/csv", nil
|
||||
}
|
||||
|
||||
// Try to detect by MIME package
|
||||
if contentType := mime.TypeByExtension(ext); contentType != "" {
|
||||
return contentType, nil
|
||||
}
|
||||
|
||||
// Return default if not found
|
||||
return "application/octet-stream", nil
|
||||
}
|
||||
|
||||
// detectContentTypeFromGzippedFile detects content type by decompressing and reading gzipped file
|
||||
func detectContentTypeFromGzippedFile(gzippedFilePath string) (string, error) {
|
||||
file, err := os.Open(gzippedFilePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Create gzip reader
|
||||
gzipReader, err := gzip.NewReader(file)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer gzipReader.Close()
|
||||
|
||||
// Read first 512 bytes of decompressed content
|
||||
buffer := make([]byte, 512)
|
||||
n, err := gzipReader.Read(buffer)
|
||||
if err != nil && err != io.EOF {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Use http.DetectContentType to detect based on decompressed content
|
||||
contentType := http.DetectContentType(buffer[:n])
|
||||
return contentType, nil
|
||||
}
|
||||
315
attachment/local/storage_test.go
Normal file
315
attachment/local/storage_test.go
Normal file
|
|
@ -0,0 +1,315 @@
|
|||
package local
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"image"
|
||||
"image/png"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// generateTestFileName generates a unique test filename with the given prefix and extension
|
||||
func generateTestFileName(prefix, ext string) string {
|
||||
return prefix + "-" + uuid.New().String() + ext
|
||||
}
|
||||
|
||||
func TestLocalStorage(t *testing.T) {
|
||||
// Create a temporary directory for testing
|
||||
tempDir, err := os.MkdirTemp("", "local_storage_test")
|
||||
assert.NoError(t, err)
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
testPath := filepath.Join(tempDir, "test_storage")
|
||||
|
||||
t.Run("Create Storage", func(t *testing.T) {
|
||||
storage, err := New(map[string]interface{}{
|
||||
"path": testPath,
|
||||
"compression": true,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, storage)
|
||||
assert.Equal(t, testPath, storage.Path)
|
||||
assert.True(t, storage.Compression)
|
||||
})
|
||||
|
||||
t.Run("Upload and Download", func(t *testing.T) {
|
||||
storage, err := New(map[string]interface{}{
|
||||
"path": testPath,
|
||||
"compression": true,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
content := []byte("test content")
|
||||
reader := bytes.NewReader(content)
|
||||
fileID := generateTestFileName("upload-download", ".txt")
|
||||
_, err = storage.Upload(context.Background(), fileID, reader, "text/plain")
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, fileID)
|
||||
|
||||
// Download
|
||||
reader2, contentType, err := storage.Download(context.Background(), fileID)
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t, contentType, "text/plain")
|
||||
|
||||
downloaded, err := io.ReadAll(reader2)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, content, downloaded)
|
||||
})
|
||||
|
||||
t.Run("Upload and Download Image with Compression", func(t *testing.T) {
|
||||
storage, err := New(map[string]interface{}{
|
||||
"path": testPath,
|
||||
"compression": true,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Create a test image (100x100 pixels - smaller for faster testing)
|
||||
img := image.NewRGBA(image.Rect(0, 0, 100, 100))
|
||||
var buf bytes.Buffer
|
||||
err = png.Encode(&buf, img)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Upload
|
||||
reader := bytes.NewReader(buf.Bytes())
|
||||
fileID := generateTestFileName("image-with-compression", ".png")
|
||||
_, err = storage.Upload(context.Background(), fileID, reader, "image/png")
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, fileID)
|
||||
|
||||
// Download and verify
|
||||
reader2, contentType, err := storage.Download(context.Background(), fileID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "image/png", contentType)
|
||||
|
||||
downloaded, err := io.ReadAll(reader2)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Decode the downloaded image
|
||||
downloadedImg, _, err := image.Decode(bytes.NewReader(downloaded))
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify image was processed
|
||||
bounds := downloadedImg.Bounds()
|
||||
assert.True(t, bounds.Dx() > 0)
|
||||
assert.True(t, bounds.Dy() > 0)
|
||||
})
|
||||
|
||||
t.Run("Upload Image without Compression", func(t *testing.T) {
|
||||
storage, err := New(map[string]interface{}{
|
||||
"path": testPath,
|
||||
"compression": false,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Create a test image (100x100 pixels)
|
||||
img := image.NewRGBA(image.Rect(0, 0, 100, 100))
|
||||
var buf bytes.Buffer
|
||||
err = png.Encode(&buf, img)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Upload
|
||||
reader := bytes.NewReader(buf.Bytes())
|
||||
fileID := generateTestFileName("image-without-compression", ".png")
|
||||
_, err = storage.Upload(context.Background(), fileID, reader, "image/png")
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, fileID)
|
||||
|
||||
// Download and verify
|
||||
reader2, contentType, err := storage.Download(context.Background(), fileID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "image/png", contentType)
|
||||
|
||||
downloaded, err := io.ReadAll(reader2)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Decode the downloaded image
|
||||
downloadedImg, _, err := image.Decode(bytes.NewReader(downloaded))
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify dimensions are unchanged
|
||||
bounds := downloadedImg.Bounds()
|
||||
assert.Equal(t, 100, bounds.Dx())
|
||||
assert.Equal(t, 100, bounds.Dy())
|
||||
})
|
||||
|
||||
t.Run("URL Generation", func(t *testing.T) {
|
||||
storage, err := New(map[string]interface{}{
|
||||
"path": testPath,
|
||||
"compression": true,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
fileID := "20240101/test-12345678.txt"
|
||||
url := storage.URL(context.Background(), fileID)
|
||||
expected := filepath.Join(testPath, fileID)
|
||||
assert.Equal(t, expected, url)
|
||||
})
|
||||
|
||||
t.Run("Download Non-existent File", func(t *testing.T) {
|
||||
storage, err := New(map[string]interface{}{
|
||||
"path": testPath,
|
||||
"compression": true,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
_, _, err = storage.Download(context.Background(), "non-existent.txt")
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("Chunked Upload", func(t *testing.T) {
|
||||
storage, err := New(map[string]interface{}{
|
||||
"path": testPath,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
fileID := "test-chunked.txt"
|
||||
content1 := []byte("chunk1")
|
||||
content2 := []byte("chunk2")
|
||||
|
||||
// Upload chunks
|
||||
err = storage.UploadChunk(context.Background(), fileID, 0, bytes.NewReader(content1), "text/plain")
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = storage.UploadChunk(context.Background(), fileID, 1, bytes.NewReader(content2), "text/plain")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Merge chunks
|
||||
err = storage.MergeChunks(context.Background(), fileID, 2)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Download and verify
|
||||
reader, contentType, err := storage.Download(context.Background(), fileID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "text/plain", contentType)
|
||||
|
||||
downloaded, err := io.ReadAll(reader)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, append(content1, content2...), downloaded)
|
||||
})
|
||||
|
||||
t.Run("File Operations", func(t *testing.T) {
|
||||
storage, err := New(map[string]interface{}{
|
||||
"path": testPath,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
fileID := "test-ops.txt"
|
||||
content := []byte("test content")
|
||||
|
||||
// Upload file
|
||||
_, err = storage.Upload(context.Background(), fileID, bytes.NewReader(content), "text/plain")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Check if file exists
|
||||
exists := storage.Exists(context.Background(), fileID)
|
||||
assert.True(t, exists)
|
||||
|
||||
// Read file
|
||||
reader, err := storage.Reader(context.Background(), fileID)
|
||||
assert.NoError(t, err)
|
||||
defer reader.Close()
|
||||
|
||||
data, err := io.ReadAll(reader)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, content, data)
|
||||
|
||||
// Get file content directly
|
||||
directContent, err := storage.GetContent(context.Background(), fileID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, content, directContent)
|
||||
|
||||
// Delete file
|
||||
err = storage.Delete(context.Background(), fileID)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Check if file no longer exists
|
||||
exists = storage.Exists(context.Background(), fileID)
|
||||
assert.False(t, exists)
|
||||
})
|
||||
|
||||
t.Run("LocalPath", func(t *testing.T) {
|
||||
storage, err := New(map[string]interface{}{
|
||||
"path": testPath,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Test different file types to verify content type detection
|
||||
testFiles := []struct {
|
||||
ext string
|
||||
content []byte
|
||||
contentType string
|
||||
expectedCT string
|
||||
}{
|
||||
{".txt", []byte("Hello World"), "text/plain", "text/plain"},
|
||||
{".json", []byte(`{"key": "value"}`), "application/json", "application/json"},
|
||||
{".html", []byte("<html><body>Test</body></html>"), "text/html", "text/html"},
|
||||
{".csv", []byte("col1,col2\nval1,val2"), "text/csv", "text/csv"},
|
||||
{".md", []byte("# Markdown Content"), "text/markdown", "text/markdown"},
|
||||
{".yao", []byte("yao file content"), "application/yao", "application/yao"},
|
||||
}
|
||||
|
||||
for _, tf := range testFiles {
|
||||
// Generate unique filename with UUID to avoid conflicts
|
||||
fileName := generateTestFileName("localpath-test", tf.ext)
|
||||
|
||||
// Upload file
|
||||
_, err = storage.Upload(context.Background(), fileName, bytes.NewReader(tf.content), tf.contentType)
|
||||
assert.NoError(t, err, "Failed to upload %s", fileName)
|
||||
|
||||
// Get local path and content type
|
||||
localPath, detectedCT, err := storage.LocalPath(context.Background(), fileName)
|
||||
assert.NoError(t, err, "Failed to get local path for %s", fileName)
|
||||
assert.NotEmpty(t, localPath, "Local path should not be empty for %s", fileName)
|
||||
assert.Equal(t, tf.expectedCT, detectedCT, "Content type mismatch for %s", fileName)
|
||||
|
||||
// Verify the path is absolute
|
||||
assert.True(t, filepath.IsAbs(localPath), "Path should be absolute for %s", fileName)
|
||||
|
||||
// Verify the file exists at the returned path
|
||||
_, err = os.Stat(localPath)
|
||||
assert.NoError(t, err, "File should exist at local path for %s", fileName)
|
||||
|
||||
// Verify file content
|
||||
fileContent, err := os.ReadFile(localPath)
|
||||
assert.NoError(t, err, "Failed to read file at local path for %s", fileName)
|
||||
assert.Equal(t, tf.content, fileContent, "File content mismatch for %s", fileName)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("LocalPath_NonExistentFile", func(t *testing.T) {
|
||||
storage, err := New(map[string]interface{}{
|
||||
"path": testPath,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Test with non-existent file
|
||||
_, _, err = storage.LocalPath(context.Background(), "non-existent.txt")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "file not found")
|
||||
})
|
||||
|
||||
t.Run("LocalPath_ContentDetection", func(t *testing.T) {
|
||||
storage, err := New(map[string]interface{}{
|
||||
"path": testPath,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Upload a file without extension but with recognizable content
|
||||
htmlContent := []byte("<!DOCTYPE html><html><head><title>Test</title></head><body><h1>Hello</h1></body></html>")
|
||||
_, err = storage.Upload(context.Background(), "noext", bytes.NewReader(htmlContent), "application/octet-stream")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Get local path - should detect HTML content type
|
||||
localPath, contentType, err := storage.LocalPath(context.Background(), "noext")
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, localPath)
|
||||
// Content detection should identify this as HTML
|
||||
assert.Equal(t, "text/html; charset=utf-8", contentType)
|
||||
})
|
||||
}
|
||||
1052
attachment/manager.go
Normal file
1052
attachment/manager.go
Normal file
File diff suppressed because it is too large
Load diff
1110
attachment/manager_test.go
Normal file
1110
attachment/manager_test.go
Normal file
File diff suppressed because it is too large
Load diff
739
attachment/s3/storage.go
Normal file
739
attachment/s3/storage.go
Normal file
|
|
@ -0,0 +1,739 @@
|
|||
package s3
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
"github.com/aws/aws-sdk-go-v2/credentials"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
)
|
||||
|
||||
// DefaultExpiration default expiration time for presigned URLs (5 minutes)
|
||||
const DefaultExpiration = 5 * time.Minute
|
||||
|
||||
// MaxImageSize maximum image size (1920x1080)
|
||||
const MaxImageSize = 1920
|
||||
|
||||
// Storage the S3 storage driver
|
||||
type Storage struct {
|
||||
Endpoint string `json:"endpoint" yaml:"endpoint"`
|
||||
Region string `json:"region" yaml:"region"`
|
||||
Key string `json:"key" yaml:"key"`
|
||||
Secret string `json:"secret" yaml:"secret"`
|
||||
Bucket string `json:"bucket" yaml:"bucket"`
|
||||
Expiration time.Duration `json:"expiration" yaml:"expiration"`
|
||||
CacheDir string `json:"cache_dir" yaml:"cache_dir"`
|
||||
client *s3.Client
|
||||
prefix string
|
||||
compression bool
|
||||
}
|
||||
|
||||
// New create a new S3 storage
|
||||
func New(options map[string]interface{}) (*Storage, error) {
|
||||
storage := &Storage{
|
||||
Region: "auto",
|
||||
Expiration: DefaultExpiration,
|
||||
compression: true,
|
||||
}
|
||||
|
||||
if endpoint, ok := options["endpoint"].(string); ok {
|
||||
storage.Endpoint = endpoint
|
||||
}
|
||||
|
||||
if region, ok := options["region"].(string); ok {
|
||||
storage.Region = region
|
||||
}
|
||||
|
||||
if key, ok := options["key"].(string); ok {
|
||||
storage.Key = key
|
||||
}
|
||||
|
||||
if secret, ok := options["secret"].(string); ok {
|
||||
storage.Secret = secret
|
||||
}
|
||||
|
||||
if bucket, ok := options["bucket"].(string); ok {
|
||||
storage.Bucket = bucket
|
||||
}
|
||||
|
||||
if prefix, ok := options["prefix"].(string); ok {
|
||||
storage.prefix = prefix
|
||||
}
|
||||
|
||||
if cacheDir, ok := options["cache_dir"].(string); ok {
|
||||
storage.CacheDir = cacheDir
|
||||
} else {
|
||||
// Use system temp directory as default
|
||||
storage.CacheDir = os.TempDir()
|
||||
}
|
||||
|
||||
if exp, ok := options["expiration"].(time.Duration); ok {
|
||||
storage.Expiration = exp
|
||||
}
|
||||
|
||||
if compression, ok := options["compression"].(bool); ok {
|
||||
storage.compression = compression
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if storage.Key == "" || storage.Secret == "" {
|
||||
return nil, fmt.Errorf("key and secret are required")
|
||||
}
|
||||
|
||||
if storage.Bucket == "" {
|
||||
return nil, fmt.Errorf("bucket is required")
|
||||
}
|
||||
|
||||
// Create S3 client
|
||||
opts := s3.Options{
|
||||
Region: storage.Region,
|
||||
Credentials: credentials.NewStaticCredentialsProvider(storage.Key, storage.Secret, ""),
|
||||
UsePathStyle: true,
|
||||
}
|
||||
|
||||
if storage.Endpoint != "" {
|
||||
// Remove bucket name from endpoint if present
|
||||
endpoint := storage.Endpoint
|
||||
if strings.Contains(endpoint, "/"+storage.Bucket) {
|
||||
endpoint = strings.TrimSuffix(endpoint, "/"+storage.Bucket)
|
||||
}
|
||||
opts.BaseEndpoint = aws.String(endpoint)
|
||||
}
|
||||
|
||||
storage.client = s3.New(opts)
|
||||
|
||||
// Ensure cache directory exists
|
||||
if err := os.MkdirAll(storage.CacheDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create cache directory %s: %w", storage.CacheDir, err)
|
||||
}
|
||||
|
||||
return storage, nil
|
||||
}
|
||||
|
||||
// Upload upload file to S3
|
||||
func (storage *Storage) Upload(ctx context.Context, path string, reader io.Reader, contentType string) (string, error) {
|
||||
if storage.client == nil {
|
||||
return "", fmt.Errorf("s3 client not initialized")
|
||||
}
|
||||
|
||||
key := filepath.Join(storage.prefix, path)
|
||||
|
||||
// Upload file
|
||||
_, err := storage.client.PutObject(ctx, &s3.PutObjectInput{
|
||||
Bucket: aws.String(storage.Bucket),
|
||||
Key: aws.String(key),
|
||||
Body: reader,
|
||||
ContentType: aws.String(contentType),
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to upload file %s: %w", path, err)
|
||||
}
|
||||
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// UploadChunk uploads a chunk of a file to S3
|
||||
func (storage *Storage) UploadChunk(ctx context.Context, path string, chunkIndex int, reader io.Reader, contentType string) error {
|
||||
if storage.client == nil {
|
||||
return fmt.Errorf("s3 client not initialized")
|
||||
}
|
||||
|
||||
// Store chunks with a special prefix
|
||||
chunkKey := filepath.Join(storage.prefix, ".chunks", path, fmt.Sprintf("chunk_%d", chunkIndex))
|
||||
|
||||
_, err := storage.client.PutObject(ctx, &s3.PutObjectInput{
|
||||
Bucket: aws.String(storage.Bucket),
|
||||
Key: aws.String(chunkKey),
|
||||
Body: reader,
|
||||
ContentType: aws.String(contentType),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to upload chunk %s %d: %w", path, chunkIndex, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// MergeChunks merges all chunks into the final file in S3
|
||||
func (storage *Storage) MergeChunks(ctx context.Context, path string, totalChunks int) error {
|
||||
if storage.client == nil {
|
||||
return fmt.Errorf("s3 client not initialized")
|
||||
}
|
||||
|
||||
finalKey := filepath.Join(storage.prefix, path)
|
||||
|
||||
// Create a buffer to hold the merged content
|
||||
var mergedContent bytes.Buffer
|
||||
var contentType string
|
||||
|
||||
// Download and merge chunks in order
|
||||
for i := 0; i < totalChunks; i++ {
|
||||
chunkKey := filepath.Join(storage.prefix, ".chunks", path, fmt.Sprintf("chunk_%d", i))
|
||||
|
||||
result, err := storage.client.GetObject(ctx, &s3.GetObjectInput{
|
||||
Bucket: aws.String(storage.Bucket),
|
||||
Key: aws.String(chunkKey),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get chunk %d: %w", i, err)
|
||||
}
|
||||
|
||||
// Get content type from the first chunk
|
||||
if i == 0 && result.ContentType != nil {
|
||||
contentType = *result.ContentType
|
||||
}
|
||||
|
||||
_, err = io.Copy(&mergedContent, result.Body)
|
||||
result.Body.Close()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to copy chunk %s %d: %w", path, i, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Default content type if not found
|
||||
if contentType == "" {
|
||||
contentType = "application/octet-stream"
|
||||
}
|
||||
|
||||
// Upload the merged content as the final file with proper content type
|
||||
_, err := storage.client.PutObject(ctx, &s3.PutObjectInput{
|
||||
Bucket: aws.String(storage.Bucket),
|
||||
Key: aws.String(finalKey),
|
||||
Body: bytes.NewReader(mergedContent.Bytes()),
|
||||
ContentType: aws.String(contentType),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to upload merged file %s: %w", path, err)
|
||||
}
|
||||
|
||||
// Clean up chunks
|
||||
for i := 0; i < totalChunks; i++ {
|
||||
chunkKey := filepath.Join(storage.prefix, ".chunks", path, fmt.Sprintf("chunk_%d", i))
|
||||
storage.client.DeleteObject(ctx, &s3.DeleteObjectInput{
|
||||
Bucket: aws.String(storage.Bucket),
|
||||
Key: aws.String(chunkKey),
|
||||
})
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reader read file from S3
|
||||
func (storage *Storage) Reader(ctx context.Context, path string) (io.ReadCloser, error) {
|
||||
if storage.client == nil {
|
||||
return nil, fmt.Errorf("s3 client not initialized")
|
||||
}
|
||||
|
||||
key := filepath.Join(storage.prefix, path)
|
||||
|
||||
result, err := storage.client.GetObject(ctx, &s3.GetObjectInput{
|
||||
Bucket: aws.String(storage.Bucket),
|
||||
Key: aws.String(key),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get file %s: %w", path, err)
|
||||
}
|
||||
|
||||
// If the file is a gzip file, decompress it
|
||||
if strings.HasSuffix(path, ".gz") {
|
||||
reader, err := gzip.NewReader(result.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return reader, nil
|
||||
}
|
||||
|
||||
return result.Body, nil
|
||||
}
|
||||
|
||||
// Download download file from S3
|
||||
func (storage *Storage) Download(ctx context.Context, path string) (io.ReadCloser, string, error) {
|
||||
if storage.client == nil {
|
||||
return nil, "", fmt.Errorf("s3 client not initialized")
|
||||
}
|
||||
|
||||
key := filepath.Join(storage.prefix, path)
|
||||
|
||||
// Get object
|
||||
result, err := storage.client.GetObject(ctx, &s3.GetObjectInput{
|
||||
Bucket: aws.String(storage.Bucket),
|
||||
Key: aws.String(key),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to download file %s: %w", path, err)
|
||||
}
|
||||
|
||||
contentType := "application/octet-stream"
|
||||
if result.ContentType != nil {
|
||||
contentType = *result.ContentType
|
||||
}
|
||||
|
||||
// Try to detect content type from file extension
|
||||
ext := filepath.Ext(strings.TrimSuffix(path, ".gz"))
|
||||
switch strings.ToLower(ext) {
|
||||
case ".txt":
|
||||
contentType = "text/plain"
|
||||
case ".html":
|
||||
contentType = "text/html"
|
||||
case ".css":
|
||||
contentType = "text/css"
|
||||
case ".js":
|
||||
contentType = "application/javascript"
|
||||
case ".json":
|
||||
contentType = "application/json"
|
||||
case ".jpg", ".jpeg":
|
||||
contentType = "image/jpeg"
|
||||
case ".png":
|
||||
contentType = "image/png"
|
||||
case ".gif":
|
||||
contentType = "image/gif"
|
||||
case ".pdf":
|
||||
contentType = "application/pdf"
|
||||
case ".mp4":
|
||||
contentType = "video/mp4"
|
||||
case ".mp3":
|
||||
contentType = "audio/mpeg"
|
||||
case ".wav":
|
||||
contentType = "audio/wav"
|
||||
case ".ogg":
|
||||
contentType = "audio/ogg"
|
||||
case ".webm":
|
||||
contentType = "video/webm"
|
||||
case ".webp":
|
||||
contentType = "image/webp"
|
||||
case ".zip":
|
||||
}
|
||||
|
||||
// If the file is a gzip file, decompress it
|
||||
if strings.HasSuffix(path, ".gz") {
|
||||
reader, err := gzip.NewReader(result.Body)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return reader, contentType, nil
|
||||
}
|
||||
|
||||
return result.Body, contentType, nil
|
||||
}
|
||||
|
||||
// GetContent gets file content as bytes
|
||||
func (storage *Storage) GetContent(ctx context.Context, path string) ([]byte, error) {
|
||||
reader, err := storage.Reader(ctx, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
return io.ReadAll(reader)
|
||||
}
|
||||
|
||||
// URL get file url with expiration
|
||||
func (storage *Storage) URL(ctx context.Context, path string) string {
|
||||
if storage.client == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
key := filepath.Join(storage.prefix, path)
|
||||
presignClient := s3.NewPresignClient(storage.client)
|
||||
request, err := presignClient.PresignGetObject(ctx, &s3.GetObjectInput{
|
||||
Bucket: aws.String(storage.Bucket),
|
||||
Key: aws.String(key),
|
||||
}, s3.WithPresignExpires(storage.Expiration))
|
||||
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return request.URL
|
||||
}
|
||||
|
||||
// Exists checks if a file exists in S3
|
||||
func (storage *Storage) Exists(ctx context.Context, path string) bool {
|
||||
if storage.client == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
key := filepath.Join(storage.prefix, path)
|
||||
_, err := storage.client.HeadObject(ctx, &s3.HeadObjectInput{
|
||||
Bucket: aws.String(storage.Bucket),
|
||||
Key: aws.String(key),
|
||||
})
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// Delete deletes a file from S3
|
||||
func (storage *Storage) Delete(ctx context.Context, path string) error {
|
||||
if storage.client == nil {
|
||||
return fmt.Errorf("s3 client not initialized")
|
||||
}
|
||||
|
||||
key := filepath.Join(storage.prefix, path)
|
||||
_, err := storage.client.DeleteObject(ctx, &s3.DeleteObjectInput{
|
||||
Bucket: aws.String(storage.Bucket),
|
||||
Key: aws.String(key),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (storage *Storage) makeID(filename string, ext string) string {
|
||||
date := time.Now().Format("20060102")
|
||||
name := strings.TrimSuffix(filepath.Base(filename), ext)
|
||||
return fmt.Sprintf("%s/%s-%d%s", date, name, time.Now().UnixNano(), ext)
|
||||
}
|
||||
|
||||
// isImage checks if the content type is an image
|
||||
func isImage(contentType string) bool {
|
||||
return strings.HasPrefix(contentType, "image/")
|
||||
}
|
||||
|
||||
// compressImage compresses the image while maintaining aspect ratio
|
||||
func compressImage(data []byte, contentType string) ([]byte, error) {
|
||||
// Decode image
|
||||
img, _, err := image.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode image: %w", err)
|
||||
}
|
||||
|
||||
// Calculate new dimensions
|
||||
bounds := img.Bounds()
|
||||
width := bounds.Dx()
|
||||
height := bounds.Dy()
|
||||
var newWidth, newHeight int
|
||||
|
||||
if width > height {
|
||||
if width > MaxImageSize {
|
||||
newWidth = MaxImageSize
|
||||
newHeight = int(float64(height) * (float64(MaxImageSize) / float64(width)))
|
||||
} else {
|
||||
return data, nil // No need to resize
|
||||
}
|
||||
} else {
|
||||
if height > MaxImageSize {
|
||||
newHeight = MaxImageSize
|
||||
newWidth = int(float64(width) * (float64(MaxImageSize) / float64(height)))
|
||||
} else {
|
||||
return data, nil // No need to resize
|
||||
}
|
||||
}
|
||||
|
||||
// Create new image with new dimensions
|
||||
newImg := image.NewRGBA(image.Rect(0, 0, newWidth, newHeight))
|
||||
|
||||
// Scale the image using bilinear interpolation
|
||||
for y := 0; y < newHeight; y++ {
|
||||
for x := 0; x < newWidth; x++ {
|
||||
srcX := float64(x) * float64(width) / float64(newWidth)
|
||||
srcY := float64(y) * float64(height) / float64(newHeight)
|
||||
newImg.Set(x, y, img.At(int(srcX), int(srcY)))
|
||||
}
|
||||
}
|
||||
|
||||
// Encode image
|
||||
var buf bytes.Buffer
|
||||
switch contentType {
|
||||
case "image/jpeg":
|
||||
err = jpeg.Encode(&buf, newImg, &jpeg.Options{Quality: 85})
|
||||
case "image/png":
|
||||
err = png.Encode(&buf, newImg)
|
||||
default:
|
||||
return data, nil // Unsupported format, return original
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encode image: %w", err)
|
||||
}
|
||||
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// LocalPath downloads the file to cache directory and returns absolute path with content type
|
||||
func (storage *Storage) LocalPath(ctx context.Context, path string) (string, string, error) {
|
||||
if storage.client == nil {
|
||||
return "", "", fmt.Errorf("s3 client not initialized")
|
||||
}
|
||||
|
||||
// Create cache file path using the same structure as storage path
|
||||
cacheFilePath := filepath.Join(storage.CacheDir, "s3_cache", path)
|
||||
|
||||
// Create directory for cache file
|
||||
dir := filepath.Dir(cacheFilePath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return "", "", fmt.Errorf("failed to create cache directory: %w", err)
|
||||
}
|
||||
|
||||
// Check if file already exists in cache and is not outdated
|
||||
if _, err := os.Stat(cacheFilePath); err == nil {
|
||||
// File exists in cache, detect content type and return
|
||||
contentType, err := detectContentType(cacheFilePath)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to detect content type: %w", err)
|
||||
}
|
||||
return cacheFilePath, contentType, nil
|
||||
}
|
||||
|
||||
// Download file from S3 to cache
|
||||
key := filepath.Join(storage.prefix, path)
|
||||
result, err := storage.client.GetObject(ctx, &s3.GetObjectInput{
|
||||
Bucket: aws.String(storage.Bucket),
|
||||
Key: aws.String(key),
|
||||
})
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to download file %s: %w", path, err)
|
||||
}
|
||||
defer result.Body.Close()
|
||||
|
||||
// Create cache file
|
||||
cacheFile, err := os.Create(cacheFilePath)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to create cache file: %w", err)
|
||||
}
|
||||
defer cacheFile.Close()
|
||||
|
||||
// Handle gzipped files - decompress during download
|
||||
var reader io.Reader = result.Body
|
||||
if strings.HasSuffix(path, ".gz") {
|
||||
gzipReader, err := gzip.NewReader(result.Body)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to create gzip reader: %w", err)
|
||||
}
|
||||
defer gzipReader.Close()
|
||||
reader = gzipReader
|
||||
|
||||
// Remove .gz extension from cache file path since we're decompressing
|
||||
newCacheFilePath := strings.TrimSuffix(cacheFilePath, ".gz")
|
||||
cacheFile.Close()
|
||||
os.Remove(cacheFilePath)
|
||||
|
||||
cacheFile, err = os.Create(newCacheFilePath)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to create decompressed cache file: %w", err)
|
||||
}
|
||||
defer cacheFile.Close()
|
||||
cacheFilePath = newCacheFilePath
|
||||
}
|
||||
|
||||
// Copy file content to cache
|
||||
_, err = io.Copy(cacheFile, reader)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to copy file to cache: %w", err)
|
||||
}
|
||||
|
||||
// For files that were decompressed from .gz, we need to detect the original content type
|
||||
var contentType string
|
||||
if strings.HasSuffix(path, ".gz") {
|
||||
// Original path was gzipped, detect content type of decompressed content
|
||||
originalPath := strings.TrimSuffix(path, ".gz")
|
||||
ext := filepath.Ext(originalPath)
|
||||
|
||||
// First try to detect by original file extension
|
||||
contentType, err = detectContentTypeFromExtension(ext)
|
||||
if err != nil || contentType == "application/octet-stream" {
|
||||
// Fallback: detect from decompressed content
|
||||
contentType, err = detectContentType(cacheFilePath)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to detect content type: %w", err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Regular file content type detection
|
||||
contentType, err = detectContentType(cacheFilePath)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to detect content type: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return cacheFilePath, contentType, nil
|
||||
}
|
||||
|
||||
// detectContentType detects content type based on file extension and content
|
||||
func detectContentType(filePath string) (string, error) {
|
||||
// First try to detect by file extension
|
||||
ext := strings.ToLower(filepath.Ext(filePath))
|
||||
|
||||
// Common file extensions mapping
|
||||
switch ext {
|
||||
case ".txt":
|
||||
return "text/plain", nil
|
||||
case ".html", ".htm":
|
||||
return "text/html", nil
|
||||
case ".css":
|
||||
return "text/css", nil
|
||||
case ".js":
|
||||
return "application/javascript", nil
|
||||
case ".json":
|
||||
return "application/json", nil
|
||||
case ".xml":
|
||||
return "application/xml", nil
|
||||
case ".jpg", ".jpeg":
|
||||
return "image/jpeg", nil
|
||||
case ".png":
|
||||
return "image/png", nil
|
||||
case ".gif":
|
||||
return "image/gif", nil
|
||||
case ".webp":
|
||||
return "image/webp", nil
|
||||
case ".svg":
|
||||
return "image/svg+xml", nil
|
||||
case ".pdf":
|
||||
return "application/pdf", nil
|
||||
case ".doc":
|
||||
return "application/msword", nil
|
||||
case ".docx":
|
||||
return "application/vnd.openxmlformats-officedocument.wordprocessingml.document", nil
|
||||
case ".xls":
|
||||
return "application/vnd.ms-excel", nil
|
||||
case ".xlsx":
|
||||
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", nil
|
||||
case ".ppt":
|
||||
return "application/vnd.ms-powerpoint", nil
|
||||
case ".pptx":
|
||||
return "application/vnd.openxmlformats-officedocument.presentationml.presentation", nil
|
||||
case ".zip":
|
||||
return "application/zip", nil
|
||||
case ".tar":
|
||||
return "application/x-tar", nil
|
||||
case ".gz":
|
||||
return "application/gzip", nil
|
||||
case ".mp3":
|
||||
return "audio/mpeg", nil
|
||||
case ".wav":
|
||||
return "audio/wav", nil
|
||||
case ".m4a":
|
||||
return "audio/mp4", nil
|
||||
case ".ogg":
|
||||
return "audio/ogg", nil
|
||||
case ".mp4":
|
||||
return "video/mp4", nil
|
||||
case ".avi":
|
||||
return "video/x-msvideo", nil
|
||||
case ".mov":
|
||||
return "video/quicktime", nil
|
||||
case ".webm":
|
||||
return "video/webm", nil
|
||||
case ".md", ".mdx":
|
||||
return "text/markdown", nil
|
||||
case ".yao":
|
||||
return "application/yao", nil
|
||||
case ".csv":
|
||||
return "text/csv", nil
|
||||
}
|
||||
|
||||
// Try to detect by MIME package
|
||||
if contentType := mime.TypeByExtension(ext); contentType != "" {
|
||||
return contentType, nil
|
||||
}
|
||||
|
||||
// Fallback: detect by reading file content
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return "application/octet-stream", nil // Default fallback
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Read first 512 bytes for content detection
|
||||
buffer := make([]byte, 512)
|
||||
n, err := file.Read(buffer)
|
||||
if err != nil && err != io.EOF {
|
||||
return "application/octet-stream", nil
|
||||
}
|
||||
|
||||
// Use http.DetectContentType to detect based on content
|
||||
contentType := http.DetectContentType(buffer[:n])
|
||||
return contentType, nil
|
||||
}
|
||||
|
||||
// detectContentTypeFromExtension detects content type based only on file extension
|
||||
func detectContentTypeFromExtension(ext string) (string, error) {
|
||||
ext = strings.ToLower(ext)
|
||||
|
||||
// Common file extensions mapping
|
||||
switch ext {
|
||||
case ".txt":
|
||||
return "text/plain", nil
|
||||
case ".html", ".htm":
|
||||
return "text/html", nil
|
||||
case ".css":
|
||||
return "text/css", nil
|
||||
case ".js":
|
||||
return "application/javascript", nil
|
||||
case ".json":
|
||||
return "application/json", nil
|
||||
case ".xml":
|
||||
return "application/xml", nil
|
||||
case ".jpg", ".jpeg":
|
||||
return "image/jpeg", nil
|
||||
case ".png":
|
||||
return "image/png", nil
|
||||
case ".gif":
|
||||
return "image/gif", nil
|
||||
case ".webp":
|
||||
return "image/webp", nil
|
||||
case ".svg":
|
||||
return "image/svg+xml", nil
|
||||
case ".pdf":
|
||||
return "application/pdf", nil
|
||||
case ".doc":
|
||||
return "application/msword", nil
|
||||
case ".docx":
|
||||
return "application/vnd.openxmlformats-officedocument.wordprocessingml.document", nil
|
||||
case ".xls":
|
||||
return "application/vnd.ms-excel", nil
|
||||
case ".xlsx":
|
||||
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", nil
|
||||
case ".ppt":
|
||||
return "application/vnd.ms-powerpoint", nil
|
||||
case ".pptx":
|
||||
return "application/vnd.openxmlformats-officedocument.presentationml.presentation", nil
|
||||
case ".zip":
|
||||
return "application/zip", nil
|
||||
case ".tar":
|
||||
return "application/x-tar", nil
|
||||
case ".mp3":
|
||||
return "audio/mpeg", nil
|
||||
case ".wav":
|
||||
return "audio/wav", nil
|
||||
case ".m4a":
|
||||
return "audio/mp4", nil
|
||||
case ".ogg":
|
||||
return "audio/ogg", nil
|
||||
case ".mp4":
|
||||
return "video/mp4", nil
|
||||
case ".avi":
|
||||
return "video/x-msvideo", nil
|
||||
case ".mov":
|
||||
return "video/quicktime", nil
|
||||
case ".webm":
|
||||
return "video/webm", nil
|
||||
case ".md", ".mdx":
|
||||
return "text/markdown", nil
|
||||
case ".yao":
|
||||
return "application/yao", nil
|
||||
case ".csv":
|
||||
return "text/csv", nil
|
||||
}
|
||||
|
||||
// Try to detect by MIME package
|
||||
if contentType := mime.TypeByExtension(ext); contentType != "" {
|
||||
return contentType, nil
|
||||
}
|
||||
|
||||
// Return default if not found
|
||||
return "application/octet-stream", nil
|
||||
}
|
||||
369
attachment/s3/storage_test.go
Normal file
369
attachment/s3/storage_test.go
Normal file
|
|
@ -0,0 +1,369 @@
|
|||
package s3
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"compress/gzip"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// generateTestFileName generates a unique test filename with the given prefix and extension
|
||||
func generateTestFileName(prefix, ext string) string {
|
||||
return prefix + "-" + uuid.New().String() + ext
|
||||
}
|
||||
|
||||
func getS3Config() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"endpoint": os.Getenv("S3_API"),
|
||||
"region": "auto",
|
||||
"key": os.Getenv("S3_ACCESS_KEY"),
|
||||
"secret": os.Getenv("S3_SECRET_KEY"),
|
||||
"bucket": os.Getenv("S3_BUCKET"),
|
||||
"prefix": "attachment-test",
|
||||
"expiration": 5 * time.Minute,
|
||||
"compression": true,
|
||||
}
|
||||
}
|
||||
|
||||
func skipIfNoS3Config(t *testing.T) {
|
||||
if os.Getenv("S3_ACCESS_KEY") == "" || os.Getenv("S3_SECRET_KEY") == "" || os.Getenv("S3_BUCKET") == "" {
|
||||
t.Skip("S3 configuration not available (set S3_ACCESS_KEY, S3_SECRET_KEY, S3_BUCKET environment variables)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestS3Storage(t *testing.T) {
|
||||
t.Run("Create Storage", func(t *testing.T) {
|
||||
options := getS3Config()
|
||||
|
||||
storage, err := New(options)
|
||||
if os.Getenv("S3_ACCESS_KEY") == "" || os.Getenv("S3_SECRET_KEY") == "" || os.Getenv("S3_BUCKET") == "" {
|
||||
// Should fail without credentials
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, storage)
|
||||
if storage != nil {
|
||||
assert.Equal(t, os.Getenv("S3_API"), storage.Endpoint)
|
||||
assert.Equal(t, "auto", storage.Region)
|
||||
assert.Equal(t, os.Getenv("S3_ACCESS_KEY"), storage.Key)
|
||||
assert.Equal(t, os.Getenv("S3_SECRET_KEY"), storage.Secret)
|
||||
assert.Equal(t, os.Getenv("S3_BUCKET"), storage.Bucket)
|
||||
assert.Equal(t, "attachment-test", storage.prefix)
|
||||
assert.Equal(t, 5*time.Minute, storage.Expiration)
|
||||
assert.True(t, storage.compression)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Upload and Download Text File", func(t *testing.T) {
|
||||
skipIfNoS3Config(t)
|
||||
|
||||
storage, err := New(getS3Config())
|
||||
assert.NoError(t, err)
|
||||
|
||||
content := []byte("test content")
|
||||
reader := bytes.NewReader(content)
|
||||
fileID := generateTestFileName("upload-test", ".txt")
|
||||
_, err = storage.Upload(context.Background(), fileID, reader, "text/plain")
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, fileID)
|
||||
|
||||
// Get presigned URL
|
||||
url := storage.URL(context.Background(), fileID)
|
||||
assert.NotEmpty(t, url)
|
||||
assert.Contains(t, url, "X-Amz-Signature")
|
||||
assert.Contains(t, url, "X-Amz-Expires")
|
||||
|
||||
// Download
|
||||
reader2, contentType, err := storage.Download(context.Background(), fileID)
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t, contentType, "text/plain")
|
||||
|
||||
downloaded, err := io.ReadAll(reader2)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, content, downloaded)
|
||||
reader2.Close()
|
||||
|
||||
// Clean up
|
||||
storage.Delete(context.Background(), fileID)
|
||||
})
|
||||
|
||||
t.Run("Chunked Upload", func(t *testing.T) {
|
||||
skipIfNoS3Config(t)
|
||||
|
||||
storage, err := New(getS3Config())
|
||||
assert.NoError(t, err)
|
||||
|
||||
fileID := generateTestFileName("test-chunked", ".txt")
|
||||
content1 := []byte("chunk1")
|
||||
content2 := []byte("chunk2")
|
||||
|
||||
// Upload chunks
|
||||
err = storage.UploadChunk(context.Background(), fileID, 0, bytes.NewReader(content1), "text/plain")
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = storage.UploadChunk(context.Background(), fileID, 1, bytes.NewReader(content2), "text/plain")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Merge chunks
|
||||
err = storage.MergeChunks(context.Background(), fileID, 2)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Download and verify
|
||||
reader, contentType, err := storage.Download(context.Background(), fileID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "text/plain", contentType)
|
||||
|
||||
downloaded, err := io.ReadAll(reader)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, append(content1, content2...), downloaded)
|
||||
reader.Close()
|
||||
|
||||
// Clean up
|
||||
storage.Delete(context.Background(), fileID)
|
||||
})
|
||||
|
||||
t.Run("File Operations", func(t *testing.T) {
|
||||
skipIfNoS3Config(t)
|
||||
|
||||
storage, err := New(getS3Config())
|
||||
assert.NoError(t, err)
|
||||
|
||||
fileID := generateTestFileName("test-ops", ".txt")
|
||||
content := []byte("test content")
|
||||
|
||||
// Upload file
|
||||
_, err = storage.Upload(context.Background(), fileID, bytes.NewReader(content), "text/plain")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Check if file exists
|
||||
exists := storage.Exists(context.Background(), fileID)
|
||||
assert.True(t, exists)
|
||||
|
||||
// Read file
|
||||
reader, err := storage.Reader(context.Background(), fileID)
|
||||
assert.NoError(t, err)
|
||||
defer reader.Close()
|
||||
|
||||
data, err := io.ReadAll(reader)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, content, data)
|
||||
|
||||
// Get file content directly
|
||||
directContent, err := storage.GetContent(context.Background(), fileID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, content, directContent)
|
||||
|
||||
// Delete file
|
||||
err = storage.Delete(context.Background(), fileID)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Check if file no longer exists
|
||||
exists = storage.Exists(context.Background(), fileID)
|
||||
assert.False(t, exists)
|
||||
})
|
||||
|
||||
t.Run("Download Non-existent File", func(t *testing.T) {
|
||||
skipIfNoS3Config(t)
|
||||
|
||||
storage, err := New(getS3Config())
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Use UUID for non-existent file to avoid any potential conflicts
|
||||
nonExistentFileID := generateTestFileName("non-existent", ".txt")
|
||||
_, _, err = storage.Download(context.Background(), nonExistentFileID)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("Invalid Configuration", func(t *testing.T) {
|
||||
// Test with missing required fields
|
||||
_, err := New(map[string]interface{}{
|
||||
"endpoint": "https://s3.amazonaws.com",
|
||||
"region": "us-east-1",
|
||||
// Missing key and secret
|
||||
})
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "key and secret are required")
|
||||
|
||||
// Test with missing bucket
|
||||
_, err = New(map[string]interface{}{
|
||||
"endpoint": "https://s3.amazonaws.com",
|
||||
"region": "us-east-1",
|
||||
"key": "test-key",
|
||||
"secret": "test-secret",
|
||||
// Missing bucket
|
||||
})
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "bucket is required")
|
||||
})
|
||||
|
||||
t.Run("LocalPath", func(t *testing.T) {
|
||||
skipIfNoS3Config(t)
|
||||
|
||||
// Create storage with custom cache directory
|
||||
tempCacheDir, err := os.MkdirTemp("", "s3_cache_test")
|
||||
assert.NoError(t, err)
|
||||
defer os.RemoveAll(tempCacheDir)
|
||||
|
||||
config := getS3Config()
|
||||
config["cache_dir"] = tempCacheDir
|
||||
|
||||
storage, err := New(config)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Test different file types
|
||||
testFiles := []struct {
|
||||
name string
|
||||
content []byte
|
||||
contentType string
|
||||
expectedCT string
|
||||
}{
|
||||
{"localpath-test.txt", []byte("Hello S3 World"), "text/plain", "text/plain"},
|
||||
{"localpath-test.json", []byte(`{"s3": "test"}`), "application/json", "application/json"},
|
||||
{"localpath-test.html", []byte("<html><body>S3 Test</body></html>"), "text/html", "text/html"},
|
||||
{"localpath-test.csv", []byte("s3,test\nval1,val2"), "text/csv", "text/csv"},
|
||||
{"localpath-test.md", []byte("# S3 Markdown"), "text/markdown", "text/markdown"},
|
||||
{"localpath-test.yao", []byte("s3 yao content"), "application/yao", "application/yao"},
|
||||
}
|
||||
|
||||
for _, tf := range testFiles {
|
||||
// Upload file to S3
|
||||
fileID := generateTestFileName("s3-localpath", "-"+tf.name)
|
||||
_, err = storage.Upload(context.Background(), fileID, bytes.NewReader(tf.content), tf.contentType)
|
||||
assert.NoError(t, err, "Failed to upload %s", tf.name)
|
||||
|
||||
// Get local path - first call should download to cache
|
||||
localPath1, detectedCT1, err := storage.LocalPath(context.Background(), fileID)
|
||||
assert.NoError(t, err, "Failed to get local path for %s", tf.name)
|
||||
assert.NotEmpty(t, localPath1, "Local path should not be empty for %s", tf.name)
|
||||
assert.Equal(t, tf.expectedCT, detectedCT1, "Content type mismatch for %s", tf.name)
|
||||
|
||||
// Verify the path is absolute
|
||||
assert.True(t, filepath.IsAbs(localPath1), "Path should be absolute for %s", tf.name)
|
||||
|
||||
// Verify the file exists at the returned path
|
||||
_, err = os.Stat(localPath1)
|
||||
assert.NoError(t, err, "File should exist at local path for %s", tf.name)
|
||||
|
||||
// Verify file content
|
||||
fileContent, err := os.ReadFile(localPath1)
|
||||
assert.NoError(t, err, "Failed to read file at local path for %s", tf.name)
|
||||
assert.Equal(t, tf.content, fileContent, "File content mismatch for %s", tf.name)
|
||||
|
||||
// Get local path again - should use cached version
|
||||
localPath2, detectedCT2, err := storage.LocalPath(context.Background(), fileID)
|
||||
assert.NoError(t, err, "Failed to get cached local path for %s", tf.name)
|
||||
assert.Equal(t, localPath1, localPath2, "Cached path should be same as first call for %s", tf.name)
|
||||
assert.Equal(t, detectedCT1, detectedCT2, "Cached content type should be same as first call for %s", tf.name)
|
||||
|
||||
// Clean up from S3
|
||||
storage.Delete(context.Background(), fileID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("LocalPath_GzippedFile", func(t *testing.T) {
|
||||
skipIfNoS3Config(t)
|
||||
|
||||
// Create storage with custom cache directory
|
||||
tempCacheDir, err := os.MkdirTemp("", "s3_cache_gzip_test")
|
||||
assert.NoError(t, err)
|
||||
defer os.RemoveAll(tempCacheDir)
|
||||
|
||||
config := getS3Config()
|
||||
config["cache_dir"] = tempCacheDir
|
||||
|
||||
storage, err := New(config)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Create gzipped content
|
||||
originalContent := []byte("This content will be gzipped and stored in S3")
|
||||
var gzipBuf bytes.Buffer
|
||||
gzipWriter := gzip.NewWriter(&gzipBuf)
|
||||
_, err = gzipWriter.Write(originalContent)
|
||||
assert.NoError(t, err)
|
||||
gzipWriter.Close()
|
||||
|
||||
// Upload gzipped file
|
||||
fileID := generateTestFileName("gzipped", ".txt.gz")
|
||||
_, err = storage.Upload(context.Background(), fileID, bytes.NewReader(gzipBuf.Bytes()), "text/plain")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Get local path - should decompress during download
|
||||
localPath, contentType, err := storage.LocalPath(context.Background(), fileID)
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, localPath)
|
||||
|
||||
// Verify the file is decompressed in cache (path should not end with .gz)
|
||||
assert.False(t, strings.HasSuffix(localPath, ".gz"), "Cached file should be decompressed")
|
||||
|
||||
// Verify content is decompressed
|
||||
cachedContent, err := os.ReadFile(localPath)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, originalContent, cachedContent, "Cached file should contain decompressed content")
|
||||
|
||||
// Verify content type
|
||||
assert.Equal(t, "text/plain", contentType)
|
||||
|
||||
// Clean up
|
||||
storage.Delete(context.Background(), fileID)
|
||||
})
|
||||
|
||||
t.Run("LocalPath_NonExistentFile", func(t *testing.T) {
|
||||
skipIfNoS3Config(t)
|
||||
|
||||
storage, err := New(getS3Config())
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Test with non-existent file
|
||||
nonExistentFileID := generateTestFileName("non-existent-localpath", ".txt")
|
||||
_, _, err = storage.LocalPath(context.Background(), nonExistentFileID)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "failed to download file")
|
||||
})
|
||||
|
||||
t.Run("LocalPath_CustomCacheDir", func(t *testing.T) {
|
||||
skipIfNoS3Config(t)
|
||||
|
||||
// Create custom cache directory
|
||||
customCacheDir, err := os.MkdirTemp("", "custom_s3_cache")
|
||||
assert.NoError(t, err)
|
||||
defer os.RemoveAll(customCacheDir)
|
||||
|
||||
config := getS3Config()
|
||||
config["cache_dir"] = customCacheDir
|
||||
|
||||
storage, err := New(config)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify cache directory is set correctly
|
||||
assert.Equal(t, customCacheDir, storage.CacheDir)
|
||||
|
||||
// Upload a test file
|
||||
content := []byte("Custom cache directory test")
|
||||
fileID := generateTestFileName("custom-cache", ".txt")
|
||||
_, err = storage.Upload(context.Background(), fileID, bytes.NewReader(content), "text/plain")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Get local path
|
||||
localPath, contentType, err := storage.LocalPath(context.Background(), fileID)
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, localPath)
|
||||
assert.Equal(t, "text/plain", contentType)
|
||||
|
||||
// Verify the file is cached in the custom directory
|
||||
assert.True(t, strings.HasPrefix(localPath, customCacheDir), "File should be cached in custom directory")
|
||||
|
||||
// Clean up
|
||||
storage.Delete(context.Background(), fileID)
|
||||
})
|
||||
}
|
||||
160
attachment/types.go
Normal file
160
attachment/types.go
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
package attachment
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
|
||||
"github.com/yaoapp/gou/types"
|
||||
)
|
||||
|
||||
// FileManager defines the interface for file management operations.
|
||||
// This interface provides abstraction for file operations, making it easier to:
|
||||
// - Write unit tests with mock implementations
|
||||
// - Switch between different storage backends
|
||||
// - Maintain consistent API across different implementations
|
||||
//
|
||||
// Example usage:
|
||||
//
|
||||
// var fileManager FileManager = manager // Manager implements FileManager
|
||||
// file, err := fileManager.Upload(ctx, header, reader, options)
|
||||
// data, err := fileManager.Read(ctx, file.ID)
|
||||
type FileManager interface {
|
||||
// Upload uploads a file with optional chunked upload support
|
||||
Upload(ctx context.Context, fileheader *FileHeader, reader io.Reader, option UploadOption) (*File, error)
|
||||
|
||||
// Download downloads a file by its ID
|
||||
Download(ctx context.Context, fileID string) (*FileResponse, error)
|
||||
|
||||
// Read reads a file content as bytes
|
||||
Read(ctx context.Context, fileID string) ([]byte, error)
|
||||
|
||||
// ReadBase64 reads a file content as base64 encoded string
|
||||
ReadBase64(ctx context.Context, fileID string) (string, error)
|
||||
|
||||
// Info retrieves complete file information from database by file ID
|
||||
Info(ctx context.Context, fileID string) (*File, error)
|
||||
|
||||
// List retrieves files from database with pagination and filtering
|
||||
List(ctx context.Context, option ListOption) (*ListResult, error)
|
||||
|
||||
// Exists checks if a file exists
|
||||
Exists(ctx context.Context, fileID string) bool
|
||||
|
||||
// Delete deletes a file
|
||||
Delete(ctx context.Context, fileID string) error
|
||||
|
||||
// LocalPath gets the local path of the file
|
||||
LocalPath(ctx context.Context, fileID string) (string, string, error)
|
||||
}
|
||||
|
||||
// File the file
|
||||
type File struct {
|
||||
ID string `json:"file_id"`
|
||||
UserPath string `json:"user_path"` // User-specified complete file path
|
||||
Path string `json:"path"` // Actual storage path
|
||||
Bytes int `json:"bytes"`
|
||||
CreatedAt int `json:"created_at"`
|
||||
Filename string `json:"filename"`
|
||||
ContentType string `json:"content_type"`
|
||||
Status string `json:"status"` // uploading, uploaded, indexing, indexed, upload_failed, index_failed
|
||||
}
|
||||
|
||||
// FileResponse represents a file download response
|
||||
type FileResponse struct {
|
||||
Reader io.ReadCloser
|
||||
ContentType string
|
||||
Extension string
|
||||
}
|
||||
|
||||
// Attachment represents a file attachment
|
||||
type Attachment struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
ContentType string `json:"content_type,omitempty"`
|
||||
Bytes int64 `json:"bytes,omitempty"`
|
||||
CreatedAt int64 `json:"created_at,omitempty"`
|
||||
FileID string `json:"file_id,omitempty"`
|
||||
UserPath string `json:"user_path,omitempty"` // User-specified complete file path
|
||||
Path string `json:"path,omitempty"` // Actual storage path
|
||||
Groups []string `json:"groups,omitempty"`
|
||||
Gzip bool `json:"gzip,omitempty"` // Gzip the file, Optional, default is false
|
||||
ClientID string `json:"client_id,omitempty"` // Client identifier
|
||||
OpenID string `json:"openid,omitempty"` // OpenID identifier
|
||||
}
|
||||
|
||||
// Manager the manager struct
|
||||
type Manager struct {
|
||||
ManagerOption
|
||||
Name string // Manager name for identification
|
||||
storage Storage
|
||||
maxsize int64
|
||||
chunsize int64
|
||||
allowedTypes allowedType
|
||||
}
|
||||
|
||||
// Storage the storage interface
|
||||
type Storage interface {
|
||||
Upload(ctx context.Context, path string, reader io.Reader, contentType string) (string, error)
|
||||
UploadChunk(ctx context.Context, path string, chunkIndex int, reader io.Reader, contentType string) error
|
||||
MergeChunks(ctx context.Context, path string, totalChunks int) error
|
||||
Download(ctx context.Context, path string) (io.ReadCloser, string, error)
|
||||
Reader(ctx context.Context, path string) (io.ReadCloser, error)
|
||||
GetContent(ctx context.Context, path string) ([]byte, error)
|
||||
URL(ctx context.Context, path string) string
|
||||
Exists(ctx context.Context, path string) bool
|
||||
Delete(ctx context.Context, path string) error
|
||||
LocalPath(ctx context.Context, path string) (string, string, error) // Returns absolute path and content type
|
||||
}
|
||||
|
||||
// ManagerOption the manager option
|
||||
type ManagerOption struct {
|
||||
types.MetaInfo
|
||||
MaxSize string `json:"max_size,omitempty" yaml:"max_size,omitempty"` // Max size of the file, Optional, default is 20M
|
||||
ChunkSize string `json:"chunk_size,omitempty" yaml:"chunk_size,omitempty"` // Chunk size of the file, Optional, default is 2M
|
||||
AllowedTypes []string `json:"allowed_types,omitempty" yaml:"allowed_types,omitempty"` // Allowed types of the file, Optional, default is all
|
||||
Gzip bool `json:"gzip,omitempty" yaml:"gzip,omitempty"` // Gzip the file, Optional, default is false
|
||||
Driver string `json:"driver,omitempty" yaml:"driver,omitempty"` // Driver, Optional, default is local
|
||||
Options map[string]interface{} `json:"options,omitempty" yaml:"options,omitempty"` // Options, Optional
|
||||
}
|
||||
|
||||
type allowedType struct {
|
||||
mapping map[string]bool
|
||||
wildcards []string // Wildcard patterns for file types (e.g., "image/*", "text/*")
|
||||
}
|
||||
|
||||
// UploadOption the upload option
|
||||
type UploadOption struct {
|
||||
CompressImage bool `json:"compress_image,omitempty" form:"compress_image"` // Compress the file, Optional, default is true
|
||||
CompressSize int `json:"compress_size,omitempty" form:"compress_size"` // Compress the file size, Optional, default is 1920, if compress_image is true, the file size will be compressed to the compress_size
|
||||
Gzip bool `json:"gzip,omitempty" form:"gzip"` // Gzip the file, Optional, default is false
|
||||
OriginalFilename string `json:"original_filename,omitempty" form:"original_filename"` // Original filename sent separately to avoid encoding issues
|
||||
Groups []string `json:"groups,omitempty" form:"groups"` // Groups, Optional, default is empty, Multi-level groups like ["user", "user123", "chat", "chat456"]
|
||||
ClientID string `json:"client_id,omitempty" form:"client_id"` // Client identifier
|
||||
OpenID string `json:"openid,omitempty" form:"openid"` // OpenID identifier
|
||||
}
|
||||
|
||||
// ListOption defines options for listing files
|
||||
type ListOption struct {
|
||||
Page int `json:"page,omitempty"` // Page number (1-based), default is 1
|
||||
PageSize int `json:"page_size,omitempty"` // Page size, default is 20
|
||||
Filters map[string]interface{} `json:"filters,omitempty"` // Filter conditions, e.g., {"status": "uploaded", "content_type": "image/*"}
|
||||
OrderBy string `json:"order_by,omitempty"` // Order by field, e.g., "created_at desc", "name asc"
|
||||
Select []string `json:"select,omitempty"` // Fields to select, empty means select all
|
||||
}
|
||||
|
||||
// ListResult contains the paginated list result
|
||||
type ListResult struct {
|
||||
Files []*File `json:"files"` // List of files
|
||||
Total int64 `json:"total"` // Total count
|
||||
Page int `json:"page"` // Current page
|
||||
PageSize int `json:"page_size"` // Page size
|
||||
TotalPages int `json:"total_pages"` // Total pages
|
||||
}
|
||||
|
||||
// FileHeader the file header
|
||||
type FileHeader struct {
|
||||
*multipart.FileHeader
|
||||
}
|
||||
1
audit/README.md
Normal file
1
audit/README.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
# Audit Log
|
||||
11
cert/cert.go
11
cert/cert.go
|
|
@ -11,6 +11,17 @@ import (
|
|||
|
||||
// Load 加载API
|
||||
func Load(cfg config.Config) error {
|
||||
|
||||
// Ignore if the certs directory does not exist
|
||||
exists, err := application.App.Exists("certs")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !exists {
|
||||
return nil
|
||||
}
|
||||
|
||||
exts := []string{"*.pem", "*.key", "*.pub"}
|
||||
return application.App.Walk("certs", func(root, file string, isdir bool) error {
|
||||
if isdir {
|
||||
|
|
|
|||
|
|
@ -57,12 +57,18 @@ var dumpCmd = &cobra.Command{
|
|||
}
|
||||
|
||||
// Load model
|
||||
err = engine.Load(config.Conf, engine.LoadOption{Action: "dump"})
|
||||
loadWarnings, err := engine.Load(config.Conf, engine.LoadOption{Action: "dump"})
|
||||
if err != nil {
|
||||
fmt.Println(color.RedString(L("Fatal: %s"), err.Error()))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if len(loadWarnings) > 0 {
|
||||
for _, warning := range loadWarnings {
|
||||
fmt.Println(color.YellowString("[%s] %s", warning.Widget, warning.Error))
|
||||
}
|
||||
}
|
||||
|
||||
if dumpModel != "" {
|
||||
fmt.Println(color.YellowString(L("Not supported yet")))
|
||||
os.Exit(1)
|
||||
|
|
|
|||
|
|
@ -37,12 +37,18 @@ var migrateCmd = &cobra.Command{
|
|||
}
|
||||
|
||||
// 加载数据模型
|
||||
err := engine.Load(config.Conf, engine.LoadOption{Action: "migrate"})
|
||||
loadWarnings, err := engine.Load(config.Conf, engine.LoadOption{Action: "migrate"})
|
||||
if err != nil {
|
||||
fmt.Println(color.RedString(L("Fatal: %s"), err.Error()))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if len(loadWarnings) > 0 {
|
||||
for _, warning := range loadWarnings {
|
||||
fmt.Println(color.YellowString("[%s] %s", warning.Widget, warning.Error))
|
||||
}
|
||||
}
|
||||
|
||||
if name != "" {
|
||||
mod, has := model.Models[name]
|
||||
if !has {
|
||||
|
|
|
|||
|
|
@ -60,12 +60,18 @@ var restoreCmd = &cobra.Command{
|
|||
})
|
||||
|
||||
// 加载数据模型
|
||||
err = engine.Load(config.Conf, engine.LoadOption{Action: "restore"})
|
||||
loadWarnings, err := engine.Load(config.Conf, engine.LoadOption{Action: "restore"})
|
||||
if err != nil {
|
||||
fmt.Println(color.RedString(L("Fatal: %s"), err.Error()))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if len(loadWarnings) > 0 {
|
||||
for _, warning := range loadWarnings {
|
||||
fmt.Println(color.YellowString("[%s] %s", warning.Widget, warning.Error))
|
||||
}
|
||||
}
|
||||
|
||||
// Restore models
|
||||
restoreModels(filepath.Join(dst, "model"), []model.MigrateOption{
|
||||
model.WithDonotInsertValues(migrateNoInsert),
|
||||
|
|
|
|||
13
cmd/run.go
13
cmd/run.go
|
|
@ -57,7 +57,7 @@ var runCmd = &cobra.Command{
|
|||
return
|
||||
}
|
||||
|
||||
err := engine.Load(cfg, engine.LoadOption{Action: "run"})
|
||||
loadWarnings, err := engine.Load(cfg, engine.LoadOption{Action: "run"})
|
||||
if err != nil {
|
||||
if !runSilent {
|
||||
color.Red(L("Engine: %s\n"), err.Error())
|
||||
|
|
@ -130,6 +130,17 @@ var runCmd = &cobra.Command{
|
|||
}
|
||||
|
||||
if !runSilent {
|
||||
|
||||
if len(loadWarnings) > 0 {
|
||||
fmt.Println(color.YellowString("---------------------------------"))
|
||||
fmt.Println(color.YellowString(L("Warnings")))
|
||||
fmt.Println(color.YellowString("---------------------------------"))
|
||||
for _, warning := range loadWarnings {
|
||||
fmt.Println(color.YellowString("[%s] %s", warning.Widget, warning.Error))
|
||||
}
|
||||
fmt.Printf("\n")
|
||||
}
|
||||
|
||||
color.White("--------------------------------------\n")
|
||||
color.White(L("%s Response\n"), name)
|
||||
color.White("--------------------------------------\n")
|
||||
|
|
|
|||
23
cmd/start.go
23
cmd/start.go
|
|
@ -77,7 +77,7 @@ var startCmd = &cobra.Command{
|
|||
}
|
||||
|
||||
// load the application engine
|
||||
err := engine.Load(config.Conf, engine.LoadOption{Action: "start"})
|
||||
loadWarnings, err := engine.Load(config.Conf, engine.LoadOption{Action: "start"})
|
||||
if err != nil {
|
||||
fmt.Println(color.RedString(L("Load: %s"), err.Error()))
|
||||
os.Exit(1)
|
||||
|
|
@ -232,6 +232,17 @@ var startCmd = &cobra.Command{
|
|||
printStores(true)
|
||||
}
|
||||
|
||||
// Print the warnings
|
||||
if len(loadWarnings) > 0 {
|
||||
fmt.Println(color.YellowString("---------------------------------"))
|
||||
fmt.Println(color.YellowString(L("Warnings")))
|
||||
fmt.Println(color.YellowString("---------------------------------"))
|
||||
for _, warning := range loadWarnings {
|
||||
fmt.Println(color.YellowString("[%s] %s", warning.Widget, warning.Error))
|
||||
}
|
||||
fmt.Printf("\n")
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case v := <-srv.Event():
|
||||
|
|
@ -275,11 +286,19 @@ func install() error {
|
|||
Boot()
|
||||
|
||||
// load the application engine
|
||||
err = engine.Load(config.Conf, engine.LoadOption{Action: "start"})
|
||||
loadWarnings, err := engine.Load(config.Conf, engine.LoadOption{Action: "start"})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Print the warnings
|
||||
if len(loadWarnings) > 0 {
|
||||
for _, warning := range loadWarnings {
|
||||
fmt.Println(color.YellowString("[%s] %s", warning.Widget, warning.Error))
|
||||
}
|
||||
fmt.Printf("\n\n")
|
||||
}
|
||||
|
||||
err = setup.Initialize(config.Conf.Root, config.Conf)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
|
|||
|
|
@ -18,6 +18,11 @@ import (
|
|||
"github.com/yaoapp/yao/studio"
|
||||
)
|
||||
|
||||
// *********************************************************************************
|
||||
// !! Yao Studio Command has been deprecated.
|
||||
// !! Do not use this command in your project.
|
||||
// *********************************************************************************
|
||||
|
||||
// RunCmd command
|
||||
var RunCmd = &cobra.Command{
|
||||
Use: "run",
|
||||
|
|
@ -43,7 +48,7 @@ var RunCmd = &cobra.Command{
|
|||
return
|
||||
}
|
||||
|
||||
err := engine.Load(cfg, engine.LoadOption{Action: "studio.run"})
|
||||
_, err := engine.Load(cfg, engine.LoadOption{Action: "studio.run"})
|
||||
if err != nil {
|
||||
fmt.Println(color.RedString(L("Engine: %s"), err.Error()))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ var BuildCmd = &cobra.Command{
|
|||
Boot()
|
||||
|
||||
cfg := config.Conf
|
||||
err := engine.Load(cfg, engine.LoadOption{Action: "sui.build"})
|
||||
loadWarnings, err := engine.Load(cfg, engine.LoadOption{Action: "sui.build"})
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, color.RedString(err.Error()))
|
||||
return
|
||||
|
|
@ -99,6 +99,13 @@ var BuildCmd = &cobra.Command{
|
|||
fmt.Println(color.YellowString("Build succeeded for %s in %s", mode, timecost))
|
||||
return
|
||||
}
|
||||
|
||||
if len(loadWarnings) > 0 {
|
||||
for _, warning := range loadWarnings {
|
||||
fmt.Println(color.YellowString("[%s] %s", warning.Widget, warning.Error))
|
||||
}
|
||||
}
|
||||
|
||||
if len(warnings) > 0 {
|
||||
for _, warning := range warnings {
|
||||
fmt.Println(color.YellowString("Warning: %s", warning))
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ var TransCmd = &cobra.Command{
|
|||
Boot()
|
||||
|
||||
cfg := config.Conf
|
||||
err := engine.Load(cfg, engine.LoadOption{Action: "sui.trans"})
|
||||
loadWarnings, err := engine.Load(cfg, engine.LoadOption{Action: "sui.trans"})
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, color.RedString(err.Error()))
|
||||
return
|
||||
|
|
@ -135,6 +135,13 @@ var TransCmd = &cobra.Command{
|
|||
fmt.Println(color.YellowString("Translate succeeded for %s in %s", mode, timecost))
|
||||
return
|
||||
}
|
||||
|
||||
if len(loadWarnings) > 0 {
|
||||
for _, warning := range loadWarnings {
|
||||
fmt.Println(color.YellowString("[%s] %s", warning.Widget, warning.Error))
|
||||
}
|
||||
}
|
||||
|
||||
if len(warnings) > 0 {
|
||||
for _, warning := range warnings {
|
||||
fmt.Println(color.YellowString("Warning: %s", warning))
|
||||
|
|
|
|||
|
|
@ -39,12 +39,18 @@ var WatchCmd = &cobra.Command{
|
|||
Boot()
|
||||
|
||||
cfg := config.Conf
|
||||
err := engine.Load(cfg, engine.LoadOption{Action: "sui.watch"})
|
||||
loadWarnings, err := engine.Load(cfg, engine.LoadOption{Action: "sui.watch"})
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, color.RedString(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
if len(loadWarnings) > 0 {
|
||||
for _, warning := range loadWarnings {
|
||||
fmt.Println(color.YellowString("[%s] %s", warning.Widget, warning.Error))
|
||||
}
|
||||
}
|
||||
|
||||
id := args[0]
|
||||
template := args[1]
|
||||
|
||||
|
|
|
|||
|
|
@ -2,18 +2,22 @@ package cmd
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/yaoapp/yao/share"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/yaoapp/yao/share"
|
||||
)
|
||||
|
||||
var printAllVersion bool
|
||||
var versionTemplate = `Version: %s
|
||||
Go version: %s
|
||||
Git commit: %s
|
||||
Built: %s
|
||||
OS/Arch: %s/%s
|
||||
var versionTemplate = `Version: %s
|
||||
Go version: %s
|
||||
Yao commit: %s
|
||||
Cui version: %s
|
||||
Cui commit: %s
|
||||
Built: %s
|
||||
OS/Arch: %s/%s
|
||||
`
|
||||
var versionCmd = &cobra.Command{
|
||||
Use: "version",
|
||||
|
|
@ -23,16 +27,29 @@ var versionCmd = &cobra.Command{
|
|||
if printAllVersion {
|
||||
commit := strings.Split(share.PRVERSION, "-")[0]
|
||||
buildTime := strings.TrimPrefix(share.PRVERSION, commit+"-")
|
||||
fmt.Printf(versionTemplate,
|
||||
share.VERSION,
|
||||
runtime.Version(),
|
||||
commit, buildTime,
|
||||
runtime.GOOS,
|
||||
runtime.GOARCH)
|
||||
cuiCommit := strings.Split(share.PRCUI, "-")[0]
|
||||
|
||||
fmt.Printf("%s", color.WhiteString("Yao version: "))
|
||||
fmt.Printf("%s\n", color.GreenString(share.VERSION))
|
||||
|
||||
fmt.Printf("%s", color.WhiteString("Yao commit: "))
|
||||
fmt.Printf("%s\n", color.YellowString(commit))
|
||||
|
||||
fmt.Printf("%s", color.WhiteString("Cui commit: "))
|
||||
fmt.Printf("%s\n", color.YellowString(cuiCommit))
|
||||
|
||||
fmt.Printf("%s", color.WhiteString("Built: "))
|
||||
fmt.Printf("%s\n", color.BlueString(buildTime))
|
||||
|
||||
fmt.Printf("%s", color.WhiteString("OS/Arch: "))
|
||||
fmt.Printf("%s\n", color.MagentaString("%s/%s", runtime.GOOS, runtime.GOARCH))
|
||||
|
||||
fmt.Printf("%s", color.WhiteString("Go version: "))
|
||||
fmt.Printf("%s\n", color.CyanString(runtime.Version()))
|
||||
return
|
||||
}
|
||||
// Do Stuff Here
|
||||
fmt.Println(share.VERSION)
|
||||
fmt.Printf("%s", color.WhiteString("Yao version: "))
|
||||
fmt.Printf("%s\n", color.GreenString(share.VERSION))
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
1
cui/setup/index.html
Normal file
1
cui/setup/index.html
Normal file
|
|
@ -0,0 +1 @@
|
|||
CUI SETUP
|
||||
1
cui/v0.9/index.html
Normal file
1
cui/v0.9/index.html
Normal file
|
|
@ -0,0 +1 @@
|
|||
# CUI v0.9.2
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
# XGEN v1.0.1
|
||||
# CUI v1.0.0
|
||||
|
||||
<b>## ROOT /__yao_admin_root/ </b>
|
||||
2825
data/bindata.go
2825
data/bindata.go
File diff suppressed because one or more lines are too long
48
data/data.go
48
data/data.go
|
|
@ -10,25 +10,25 @@ import (
|
|||
assetfs "github.com/elazarl/go-bindata-assetfs"
|
||||
)
|
||||
|
||||
// XgenV0 XGen 0.9
|
||||
func XgenV0() *assetfs.AssetFS {
|
||||
// CuiV0 CUI 0.9
|
||||
func CuiV0() *assetfs.AssetFS {
|
||||
assetInfo := func(path string) (os.FileInfo, error) {
|
||||
return os.Stat(path)
|
||||
}
|
||||
for k := range _bintree.Children {
|
||||
k = "xgen/v0.9"
|
||||
k = "cui/v0.9"
|
||||
return &assetfs.AssetFS{Asset: Asset, AssetDir: AssetDir, AssetInfo: assetInfo, Prefix: k, Fallback: "index.html"}
|
||||
}
|
||||
panic("unreachable")
|
||||
}
|
||||
|
||||
// XgenV1 XGen 1.0
|
||||
func XgenV1() *assetfs.AssetFS {
|
||||
// CuiV1 CUI 1.0
|
||||
func CuiV1() *assetfs.AssetFS {
|
||||
assetInfo := func(path string) (os.FileInfo, error) {
|
||||
return os.Stat(path)
|
||||
}
|
||||
for k := range _bintree.Children {
|
||||
k = "xgen/v1.0"
|
||||
k = "cui/v1.0"
|
||||
return &assetfs.AssetFS{Asset: Asset, AssetDir: AssetDir, AssetInfo: assetInfo, Prefix: k, Fallback: "index.html"}
|
||||
}
|
||||
panic("unreachable")
|
||||
|
|
@ -40,25 +40,25 @@ func Setup() *assetfs.AssetFS {
|
|||
return os.Stat(path)
|
||||
}
|
||||
for k := range _bintree.Children {
|
||||
k = "xgen/setup"
|
||||
k = "cui/setup"
|
||||
return &assetfs.AssetFS{Asset: Asset, AssetDir: AssetDir, AssetInfo: assetInfo, Prefix: k, Fallback: "index.html"}
|
||||
}
|
||||
panic("unreachable")
|
||||
}
|
||||
|
||||
// ReplaceXGen bindata file
|
||||
func ReplaceXGen(search, replace string) error {
|
||||
err := replaceXGenIndex(search, replace)
|
||||
// ReplaceCUI bindata file
|
||||
func ReplaceCUI(search, replace string) error {
|
||||
err := replaceCUIIndex(search, replace)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = replaceXGenUmi(search, replace)
|
||||
err = replaceCUIUmi(search, replace)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return replaceXGenLayouts(search, replace)
|
||||
return replaceCUILayouts(search, replace)
|
||||
}
|
||||
|
||||
// Read file from bin
|
||||
|
|
@ -81,10 +81,10 @@ func RemoveApp() {
|
|||
delete(_bindata, "yao/release/app.yaz")
|
||||
}
|
||||
|
||||
// ReplaceXGenIndex bindata file
|
||||
func replaceXGenIndex(search, replace string) error {
|
||||
// ReplaceCUIIndex bindata file
|
||||
func replaceCUIIndex(search, replace string) error {
|
||||
|
||||
content, err := bindataRead(_xgenV10IndexHtml, "xgen/v1.0/index.html")
|
||||
content, err := bindataRead(_cuiV10IndexHtml, "cui/v1.0/index.html")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -100,14 +100,14 @@ func replaceXGenIndex(search, replace string) error {
|
|||
return err
|
||||
}
|
||||
|
||||
_xgenV10IndexHtml = b.Bytes()
|
||||
_cuiV10IndexHtml = b.Bytes()
|
||||
return nil
|
||||
}
|
||||
|
||||
// replaceXGenUmi bindata file
|
||||
func replaceXGenUmi(search, replace string) error {
|
||||
// replaceCUIUmi bindata file
|
||||
func replaceCUIUmi(search, replace string) error {
|
||||
|
||||
content, err := bindataRead(_xgenV10UmiJs, "xgen/v1.0/umi.js")
|
||||
content, err := bindataRead(_cuiV10UmiJs, "cui/v1.0/umi.js")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -123,14 +123,14 @@ func replaceXGenUmi(search, replace string) error {
|
|||
return err
|
||||
}
|
||||
|
||||
_xgenV10UmiJs = b.Bytes()
|
||||
_cuiV10UmiJs = b.Bytes()
|
||||
return nil
|
||||
}
|
||||
|
||||
// replaceXGenLayouts bindata file
|
||||
func replaceXGenLayouts(search, replace string) error {
|
||||
// replaceCUILayouts bindata file
|
||||
func replaceCUILayouts(search, replace string) error {
|
||||
|
||||
content, err := bindataRead(_xgenV10Layouts__indexAsyncJs, "xgen/v1.0/layouts__index.async.js")
|
||||
content, err := bindataRead(_cuiV10Layouts__indexAsyncJs, "cui/v1.0/layouts__index.async.js")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -146,6 +146,6 @@ func replaceXGenLayouts(search, replace string) error {
|
|||
return err
|
||||
}
|
||||
|
||||
_xgenV10Layouts__indexAsyncJs = b.Bytes()
|
||||
_cuiV10Layouts__indexAsyncJs = b.Bytes()
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,18 +6,18 @@ import (
|
|||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestReplaceXGenIndex(t *testing.T) {
|
||||
err := ReplaceXGen("__yao_admin_root", "Admin-Replaced")
|
||||
func TestReplaceCUIIndex(t *testing.T) {
|
||||
err := ReplaceCUI("__yao_admin_root", "Admin-Replaced")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
content, err := bindataRead(_xgenV10IndexHtml, "index.html")
|
||||
content, err := bindataRead(_cuiV10IndexHtml, "index.html")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
content, err = bindataRead(_xgenV10UmiJs, "umi.js")
|
||||
content, err = bindataRead(_cuiV10UmiJs, "umi.js")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,14 +2,14 @@
|
|||
# Yao Build Environment (Ubuntu 24.04 AMD64)
|
||||
#
|
||||
# Build:
|
||||
# docker build --platform linux/amd64 -t yaoapp/yao-build:0.10.4 .
|
||||
# docker build --platform linux/amd64 -t yaoapp/yao-build:0.10.5 .
|
||||
#
|
||||
# Usage:
|
||||
# docker run --rm -it -v /local/path/dist:/data yaoapp/yao-build:0.10.4
|
||||
# docker run --rm -it -v /local/path/dist:/data yaoapp/yao-build:0.10.5
|
||||
#
|
||||
# Tests:
|
||||
# docker run --rm -it yaoapp/yao-build:0.10.4 /bin/bash
|
||||
# docker run --rm -it -v ./test:/data yaoapp/yao-build:0.10.4 /bin/bash
|
||||
# docker run --rm -it yaoapp/yao-build:0.10.5 /bin/bash
|
||||
# docker run --rm -it -v ./test:/data yaoapp/yao-build:0.10.5 /bin/bash
|
||||
#
|
||||
# ===========================================
|
||||
FROM ubuntu:24.04
|
||||
|
|
@ -29,10 +29,10 @@ RUN apt-get update && \
|
|||
apt-get install -y git && \
|
||||
apt-get install -y unzip
|
||||
|
||||
# Install Go 1.23.3
|
||||
RUN wget https://golang.org/dl/go1.23.3.linux-amd64.tar.gz && \
|
||||
tar -C /usr/local -xzf go1.23.3.linux-amd64.tar.gz && \
|
||||
rm go1.23.3.linux-amd64.tar.gz
|
||||
# Install Go 1.24.3
|
||||
RUN wget https://golang.org/dl/go1.24.3.linux-amd64.tar.gz && \
|
||||
tar -C /usr/local -xzf go1.24.3.linux-amd64.tar.gz && \
|
||||
rm go1.24.3.linux-amd64.tar.gz
|
||||
|
||||
# Install Node.js 18.x
|
||||
RUN curl -fsSL https://deb.nodesource.com/setup_18.x | bash - && \
|
||||
|
|
@ -40,6 +40,15 @@ RUN curl -fsSL https://deb.nodesource.com/setup_18.x | bash - && \
|
|||
|
||||
RUN npm install -g pnpm
|
||||
|
||||
# Install AWS CLI
|
||||
RUN curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64-2.22.7.zip" -o "awscliv2.zip" && \
|
||||
unzip awscliv2.zip && \
|
||||
./aws/install && \
|
||||
rm -rf awscliv2.zip && \
|
||||
rm -rf aws && \
|
||||
aws --version
|
||||
|
||||
# RUN npm install -g pnpm
|
||||
RUN chmod +x /app/build.sh
|
||||
|
||||
VOLUME [ "/data" ]
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ git clone https://github.com/yaoapp/kun.git /app/kun && \
|
|||
git clone https://github.com/yaoapp/xun.git /app/xun && \
|
||||
git clone https://github.com/sjzsdu/gou.git /app/gou && \
|
||||
git clone https://github.com/yaoapp/v8go.git /app/v8go && \
|
||||
git clone https://github.com/sjzsdu/xgen.git /app/xgen-v1.0 && \
|
||||
git clone https://github.com/yaoapp/cui.git /app/cui-v1.0 && \
|
||||
git clone https://github.com/yaoapp/yao-init.git /app/yao-init && \
|
||||
git clone https://github.com/sjzsdu/yao.git /app/yao
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ ARG ARCH
|
|||
RUN groupadd -r yao && useradd -r -g yao yao && \
|
||||
apt-get update && \
|
||||
apt-get install -y curl sudo procps net-tools
|
||||
RUN curl -fsSL "https://yao.moapi.ai/archives/yao-${VERSION}-linux-${ARCH}" > /usr/local/bin/yao && \
|
||||
RUN curl -fsSL "https://pub-80136338e60643edbb55c6ca8a689cf8.r2.dev/archives/yao-${VERSION}-linux-${ARCH}" > /usr/local/bin/yao && \
|
||||
chmod +x /usr/local/bin/yao && \
|
||||
mkdir -p /data/app
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ FROM alpine:latest
|
|||
ARG VERSION
|
||||
ARG ARCH
|
||||
RUN apk --no-cache add curl
|
||||
RUN curl -fsSL "https://yao.moapi.ai/archives/yao-${VERSION}-linux-${ARCH}" > /usr/local/bin/yao && \
|
||||
RUN curl -fsSL "https://pub-80136338e60643edbb55c6ca8a689cf8.r2.dev/archives/yao-${VERSION}-linux-${ARCH}" > /usr/local/bin/yao && \
|
||||
chmod +x /usr/local/bin/yao && \
|
||||
addgroup -S yao && adduser -S -G yao yao && \
|
||||
mkdir -p /data/app && \
|
||||
|
|
|
|||
49
dsl/api/api.go
Normal file
49
dsl/api/api.go
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/yaoapp/yao/dsl/types"
|
||||
)
|
||||
|
||||
// YaoAPI is the MCP client DSL manager
|
||||
type YaoAPI struct {
|
||||
root string // The relative path of the MCP client DSL
|
||||
fs types.IO // The file system IO interface
|
||||
db types.IO // The database IO interface
|
||||
}
|
||||
|
||||
// New returns a new connector DSL manager
|
||||
func New(root string, fs types.IO, db types.IO) types.Manager {
|
||||
return &YaoAPI{root: root, fs: fs, db: db}
|
||||
}
|
||||
|
||||
// Loaded return all loaded DSLs
|
||||
func (api *YaoAPI) Loaded(ctx context.Context) (map[string]*types.Info, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Load will unload the DSL first, then load the DSL from DB or file system
|
||||
func (api *YaoAPI) Load(ctx context.Context, options *types.LoadOptions) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reload will unload the DSL first, then reload the DSL from DB or file system
|
||||
func (api *YaoAPI) Reload(ctx context.Context, options *types.ReloadOptions) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Unload will unload the DSL from memory
|
||||
func (api *YaoAPI) Unload(ctx context.Context, options *types.UnloadOptions) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate will validate the DSL from source
|
||||
func (api *YaoAPI) Validate(ctx context.Context, source string) (bool, []types.LintMessage) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Execute will execute the DSL
|
||||
func (api *YaoAPI) Execute(ctx context.Context, id string, method string, args ...any) (any, error) {
|
||||
return nil, nil
|
||||
}
|
||||
213
dsl/connector/cases_test.go
Normal file
213
dsl/connector/cases_test.go
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
package connector
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
jsoniter "github.com/json-iterator/go"
|
||||
"github.com/yaoapp/gou/application"
|
||||
"github.com/yaoapp/gou/model"
|
||||
"github.com/yaoapp/kun/log"
|
||||
"github.com/yaoapp/yao/config"
|
||||
"github.com/yaoapp/yao/data"
|
||||
"github.com/yaoapp/yao/dsl/types"
|
||||
"github.com/yaoapp/yao/test"
|
||||
)
|
||||
|
||||
// systemModels system models
|
||||
var systemModels = map[string]string{
|
||||
"__yao.dsl": "yao/models/dsl.mod.yao",
|
||||
}
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
// Setup
|
||||
test.Prepare(&testing.T{}, config.Conf)
|
||||
defer test.Clean()
|
||||
|
||||
// Load system models
|
||||
model.WithCrypt([]byte(fmt.Sprintf(`{"key":"%s"}`, config.Conf.DB.AESKey)), "AES")
|
||||
model.WithCrypt([]byte(`{}`), "PASSWORD")
|
||||
err := loadSystemModels()
|
||||
if err != nil {
|
||||
log.Error("Load system models error: %s", err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Load application
|
||||
root := os.Getenv("GOU_TEST_APPLICATION")
|
||||
app, err := application.OpenFromDisk(root) // Load app
|
||||
if err != nil {
|
||||
log.Error("Load application error: %s", err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
application.Load(app)
|
||||
|
||||
// Run tests
|
||||
code := m.Run()
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
// loadSystemModels load system models
|
||||
func loadSystemModels() error {
|
||||
for id, path := range systemModels {
|
||||
content, err := data.Read(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Parse model
|
||||
var data map[string]interface{}
|
||||
err = application.Parse(path, content, &data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Set prefix
|
||||
if table, ok := data["table"].(map[string]interface{}); ok {
|
||||
if name, ok := table["name"].(string); ok {
|
||||
table["name"] = "__yao_" + name
|
||||
content, err = jsoniter.Marshal(data)
|
||||
if err != nil {
|
||||
log.Error("failed to marshal model data: %v", err)
|
||||
return fmt.Errorf("failed to marshal model data: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load Model
|
||||
mod, err := model.LoadSource(content, id, path)
|
||||
if err != nil {
|
||||
log.Error("load system model %s error: %s", id, err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
// Drop table first
|
||||
err = mod.DropTable()
|
||||
if err != nil {
|
||||
log.Error("drop table error: %s", err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
// Auto migrate
|
||||
err = mod.Migrate(false, model.WithDonotInsertValues(true))
|
||||
if err != nil {
|
||||
log.Error("migrate system model %s error: %s", id, err.Error())
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestCase defines a single test case
|
||||
type TestCase struct {
|
||||
ID string
|
||||
Source string
|
||||
UpdatedSource string
|
||||
Tags []string
|
||||
Label string
|
||||
Description string
|
||||
}
|
||||
|
||||
// NewTestCase creates a new test case
|
||||
func NewTestCase() *TestCase {
|
||||
id := getTestID()
|
||||
return &TestCase{
|
||||
ID: id,
|
||||
Source: fmt.Sprintf(`{
|
||||
"label": "Test OpenAI",
|
||||
"description": "Test Description",
|
||||
"tags": ["test_%s"],
|
||||
"type": "openai",
|
||||
"options": {
|
||||
"proxy": "https://api.openai.com/v1",
|
||||
"model": "gpt-4o-mini",
|
||||
"key": "sk-test-key"
|
||||
}
|
||||
}`, id),
|
||||
UpdatedSource: fmt.Sprintf(`{
|
||||
"label": "Updated OpenAI",
|
||||
"description": "Updated Description",
|
||||
"tags": ["test_%s", "updated"],
|
||||
"type": "openai",
|
||||
"options": {
|
||||
"proxy": "https://api.openai.com/v1",
|
||||
"model": "gpt-4o-mini",
|
||||
"key": "sk-test-key"
|
||||
}
|
||||
}`, id),
|
||||
Tags: []string{fmt.Sprintf("test_%s", id)},
|
||||
Label: "Test OpenAI",
|
||||
Description: "Test Description",
|
||||
}
|
||||
}
|
||||
|
||||
// getTestID generates a unique test ID
|
||||
func getTestID() string {
|
||||
return fmt.Sprintf("test_%d", time.Now().UnixNano())
|
||||
}
|
||||
|
||||
// CreateOptions returns creation options
|
||||
func (tc *TestCase) CreateOptions() *types.CreateOptions {
|
||||
return &types.CreateOptions{
|
||||
ID: tc.ID,
|
||||
Source: tc.Source,
|
||||
}
|
||||
}
|
||||
|
||||
// LoadOptions returns load options
|
||||
func (tc *TestCase) LoadOptions() *types.LoadOptions {
|
||||
return &types.LoadOptions{
|
||||
ID: tc.ID,
|
||||
Source: tc.Source,
|
||||
}
|
||||
}
|
||||
|
||||
// UnloadOptions returns unload options
|
||||
func (tc *TestCase) UnloadOptions() *types.UnloadOptions {
|
||||
return &types.UnloadOptions{
|
||||
ID: tc.ID,
|
||||
}
|
||||
}
|
||||
|
||||
// ReloadOptions returns reload options
|
||||
func (tc *TestCase) ReloadOptions() *types.ReloadOptions {
|
||||
return &types.ReloadOptions{
|
||||
ID: tc.ID,
|
||||
Source: tc.UpdatedSource,
|
||||
}
|
||||
}
|
||||
|
||||
// AssertInfo verifies if the information is correct
|
||||
func (tc *TestCase) AssertInfo(info *types.Info) bool {
|
||||
if info == nil {
|
||||
return false
|
||||
}
|
||||
return info.ID == tc.ID &&
|
||||
info.Type == types.TypeConnector &&
|
||||
info.Label == tc.Label &&
|
||||
len(info.Tags) == len(tc.Tags) &&
|
||||
info.Description == tc.Description &&
|
||||
!info.Readonly &&
|
||||
!info.Builtin &&
|
||||
!info.Mtime.IsZero() &&
|
||||
!info.Ctime.IsZero()
|
||||
}
|
||||
|
||||
// AssertUpdatedInfo verifies if the updated information is correct
|
||||
func (tc *TestCase) AssertUpdatedInfo(info *types.Info) bool {
|
||||
if info == nil {
|
||||
return false
|
||||
}
|
||||
return info.ID == tc.ID &&
|
||||
info.Type == types.TypeConnector &&
|
||||
info.Label == "Updated OpenAI" &&
|
||||
len(info.Tags) == 2 &&
|
||||
info.Description == "Updated Description" &&
|
||||
!info.Readonly &&
|
||||
!info.Builtin &&
|
||||
!info.Mtime.IsZero() &&
|
||||
!info.Ctime.IsZero()
|
||||
}
|
||||
175
dsl/connector/connector.go
Normal file
175
dsl/connector/connector.go
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
package connector
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/yaoapp/gou/connector"
|
||||
"github.com/yaoapp/yao/dsl/types"
|
||||
)
|
||||
|
||||
// YaoConnector is the connector DSL manager
|
||||
type YaoConnector struct {
|
||||
root string // The relative path of the connector DSL
|
||||
fs types.IO // The file system IO interface
|
||||
db types.IO // The database IO interface
|
||||
}
|
||||
|
||||
// New returns a new connector DSL manager
|
||||
func New(root string, fs types.IO, db types.IO) types.Manager {
|
||||
return &YaoConnector{root: root, fs: fs, db: db}
|
||||
}
|
||||
|
||||
// Loaded return all loaded DSLs
|
||||
func (c *YaoConnector) Loaded(ctx context.Context) (map[string]*types.Info, error) {
|
||||
infos := map[string]*types.Info{}
|
||||
for id, conn := range connector.Connectors {
|
||||
meta := conn.GetMetaInfo()
|
||||
infos[id] = &types.Info{
|
||||
ID: id,
|
||||
Path: conn.ID(),
|
||||
Type: types.TypeConnector,
|
||||
Label: meta.Label,
|
||||
Sort: meta.Sort,
|
||||
Description: meta.Description,
|
||||
Tags: meta.Tags,
|
||||
Readonly: meta.Readonly,
|
||||
Builtin: meta.Builtin,
|
||||
Mtime: meta.Mtime,
|
||||
Ctime: meta.Ctime,
|
||||
}
|
||||
}
|
||||
return infos, nil
|
||||
}
|
||||
|
||||
// Load will unload the DSL first, then load the DSL from DB or file system
|
||||
func (c *YaoConnector) Load(ctx context.Context, options *types.LoadOptions) error {
|
||||
if options == nil {
|
||||
return fmt.Errorf("load options is required")
|
||||
}
|
||||
|
||||
if options.ID == "" {
|
||||
return fmt.Errorf("load options id is required")
|
||||
}
|
||||
|
||||
var err error
|
||||
|
||||
// Case 1: If Source is provided, use LoadSourceSync
|
||||
if options.Source != "" {
|
||||
connectorPath := types.ToPath(types.TypeConnector, options.ID)
|
||||
_, err = connector.LoadSourceSync([]byte(options.Source), options.ID, connectorPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else if options.Path != "" && options.Store == "fs" {
|
||||
// Case 2: If Path is provided and Store is fs, use LoadSync with Path
|
||||
_, err = connector.LoadSync(options.Path, options.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else if options.Store == "db" {
|
||||
// Case 3: If Store is db, get Source from DB first
|
||||
if c.db == nil {
|
||||
return fmt.Errorf("db io is required for store type db")
|
||||
}
|
||||
source, exists, err := c.db.Source(options.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
return fmt.Errorf("connector %s not found in database", options.ID)
|
||||
}
|
||||
connectorPath := types.ToPath(types.TypeConnector, options.ID)
|
||||
_, err = connector.LoadSourceSync([]byte(source), options.ID, connectorPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
// Case 4: Default case, use LoadSync with ID
|
||||
path := types.ToPath(types.TypeConnector, options.ID)
|
||||
_, err = connector.LoadSync(path, options.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Unload will unload the DSL from memory
|
||||
func (c *YaoConnector) Unload(ctx context.Context, options *types.UnloadOptions) error {
|
||||
if options == nil {
|
||||
return fmt.Errorf("unload options is required")
|
||||
}
|
||||
|
||||
if options.ID == "" {
|
||||
return fmt.Errorf("unload options id is required")
|
||||
}
|
||||
|
||||
return connector.Remove(options.ID)
|
||||
}
|
||||
|
||||
// Reload will unload the DSL first, then reload the DSL from DB or file system
|
||||
func (c *YaoConnector) Reload(ctx context.Context, options *types.ReloadOptions) error {
|
||||
if options == nil {
|
||||
return fmt.Errorf("reload options is required")
|
||||
}
|
||||
|
||||
if options.ID == "" {
|
||||
return fmt.Errorf("reload options id is required")
|
||||
}
|
||||
|
||||
// First unload
|
||||
err := connector.Remove(options.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Then load
|
||||
if options.Source != "" {
|
||||
connectorPath := types.ToPath(types.TypeConnector, options.ID)
|
||||
_, err = connector.LoadSourceSync([]byte(options.Source), options.ID, connectorPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else if options.Path != "" && options.Store == "fs" {
|
||||
_, err = connector.LoadSync(options.Path, options.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else if options.Store == "db" {
|
||||
if c.db == nil {
|
||||
return fmt.Errorf("db io is required for store type db")
|
||||
}
|
||||
source, exists, err := c.db.Source(options.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
return fmt.Errorf("connector %s not found in database", options.ID)
|
||||
}
|
||||
connectorPath := types.ToPath(types.TypeConnector, options.ID)
|
||||
_, err = connector.LoadSourceSync([]byte(source), options.ID, connectorPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
path := types.ToPath(types.TypeConnector, options.ID)
|
||||
_, err = connector.LoadSync(path, options.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate will validate the DSL from source
|
||||
func (c *YaoConnector) Validate(ctx context.Context, source string) (bool, []types.LintMessage) {
|
||||
return true, []types.LintMessage{}
|
||||
}
|
||||
|
||||
// Execute will execute the DSL
|
||||
func (c *YaoConnector) Execute(ctx context.Context, id string, method string, args ...any) (any, error) {
|
||||
return nil, fmt.Errorf("Not implemented")
|
||||
}
|
||||
183
dsl/connector/connector_test.go
Normal file
183
dsl/connector/connector_test.go
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
package connector
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/yaoapp/yao/dsl/io"
|
||||
"github.com/yaoapp/yao/dsl/types"
|
||||
)
|
||||
|
||||
func TestConnectorLoad(t *testing.T) {
|
||||
testCase := NewTestCase()
|
||||
fsio := io.NewFS(types.TypeConnector)
|
||||
dbio := io.NewDB(types.TypeConnector)
|
||||
manager := New("", fsio, dbio)
|
||||
|
||||
// Test Load with nil options
|
||||
err := manager.Load(context.Background(), nil)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "load options is required")
|
||||
|
||||
// Test Load with empty ID
|
||||
err = manager.Load(context.Background(), &types.LoadOptions{})
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "load options id is required")
|
||||
|
||||
// Test Load with Source
|
||||
err = manager.Load(context.Background(), testCase.LoadOptions())
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Test Load from filesystem
|
||||
err = fsio.Create(testCase.CreateOptions())
|
||||
assert.NoError(t, err)
|
||||
|
||||
path := types.ToPath(types.TypeConnector, testCase.ID)
|
||||
err = manager.Load(context.Background(), &types.LoadOptions{
|
||||
ID: testCase.ID,
|
||||
Path: path,
|
||||
Store: "fs",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Test Load from database
|
||||
err = dbio.Create(testCase.CreateOptions())
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = manager.Load(context.Background(), &types.LoadOptions{
|
||||
ID: testCase.ID,
|
||||
Store: "db",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Clean up
|
||||
err = fsio.Delete(testCase.ID)
|
||||
assert.NoError(t, err)
|
||||
err = dbio.Delete(testCase.ID)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestConnectorUnload(t *testing.T) {
|
||||
testCase := NewTestCase()
|
||||
fsio := io.NewFS(types.TypeConnector)
|
||||
dbio := io.NewDB(types.TypeConnector)
|
||||
manager := New("", fsio, dbio)
|
||||
|
||||
// Test Unload with nil options
|
||||
err := manager.Unload(context.Background(), nil)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "unload options is required")
|
||||
|
||||
// Test Unload with empty ID
|
||||
err = manager.Unload(context.Background(), &types.UnloadOptions{})
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "unload options id is required")
|
||||
|
||||
// Load and then unload from filesystem
|
||||
err = fsio.Create(testCase.CreateOptions())
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = manager.Load(context.Background(), &types.LoadOptions{
|
||||
ID: testCase.ID,
|
||||
Store: "fs",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = manager.Unload(context.Background(), testCase.UnloadOptions())
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Clean up
|
||||
err = fsio.Delete(testCase.ID)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestConnectorReload(t *testing.T) {
|
||||
testCase := NewTestCase()
|
||||
fsio := io.NewFS(types.TypeConnector)
|
||||
dbio := io.NewDB(types.TypeConnector)
|
||||
manager := New("", fsio, dbio)
|
||||
|
||||
// Test Reload with nil options
|
||||
err := manager.Reload(context.Background(), nil)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "reload options is required")
|
||||
|
||||
// Test Reload with empty ID
|
||||
err = manager.Reload(context.Background(), &types.ReloadOptions{})
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "reload options id is required")
|
||||
|
||||
// Load and then reload from filesystem
|
||||
err = fsio.Create(testCase.CreateOptions())
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = manager.Load(context.Background(), &types.LoadOptions{
|
||||
ID: testCase.ID,
|
||||
Store: "fs",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = manager.Reload(context.Background(), testCase.ReloadOptions())
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Clean up
|
||||
err = fsio.Delete(testCase.ID)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestConnectorLoaded(t *testing.T) {
|
||||
testCase := NewTestCase()
|
||||
fsio := io.NewFS(types.TypeConnector)
|
||||
dbio := io.NewDB(types.TypeConnector)
|
||||
manager := New("", fsio, dbio)
|
||||
|
||||
// Load from filesystem
|
||||
err := fsio.Create(testCase.CreateOptions())
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = manager.Load(context.Background(), &types.LoadOptions{
|
||||
ID: testCase.ID,
|
||||
Store: "fs",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Test Loaded
|
||||
infos, err := manager.Loaded(context.Background())
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, infos)
|
||||
assert.Contains(t, infos, testCase.ID)
|
||||
|
||||
// Verify metadata fields
|
||||
fsInfo := infos[testCase.ID]
|
||||
assert.Equal(t, testCase.ID, fsInfo.ID)
|
||||
assert.Equal(t, types.TypeConnector, fsInfo.Type)
|
||||
assert.Equal(t, testCase.Label, fsInfo.Label)
|
||||
assert.Equal(t, testCase.Description, fsInfo.Description)
|
||||
assert.ElementsMatch(t, testCase.Tags, fsInfo.Tags)
|
||||
assert.False(t, fsInfo.Readonly)
|
||||
assert.False(t, fsInfo.Builtin)
|
||||
|
||||
// Clean up
|
||||
err = fsio.Delete(testCase.ID)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestConnectorValidate(t *testing.T) {
|
||||
manager := New("", nil, nil)
|
||||
|
||||
// Test Validate
|
||||
valid, messages := manager.Validate(context.Background(), "test source")
|
||||
assert.True(t, valid)
|
||||
assert.Empty(t, messages)
|
||||
}
|
||||
|
||||
func TestConnectorExecute(t *testing.T) {
|
||||
manager := New("", nil, nil)
|
||||
|
||||
// Test Execute
|
||||
result, err := manager.Execute(context.Background(), "test_id", "test_method")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "Not implemented")
|
||||
assert.Nil(t, result)
|
||||
}
|
||||
399
dsl/dsl.go
Normal file
399
dsl/dsl.go
Normal file
|
|
@ -0,0 +1,399 @@
|
|||
package dsl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/yaoapp/yao/dsl/api"
|
||||
"github.com/yaoapp/yao/dsl/connector"
|
||||
"github.com/yaoapp/yao/dsl/io"
|
||||
"github.com/yaoapp/yao/dsl/mcp"
|
||||
"github.com/yaoapp/yao/dsl/model"
|
||||
"github.com/yaoapp/yao/dsl/types"
|
||||
)
|
||||
|
||||
// DSL is the base DSL struct
|
||||
type DSL struct {
|
||||
Type types.Type
|
||||
exts []string
|
||||
root string
|
||||
manager types.Manager
|
||||
db types.IO
|
||||
fs types.IO
|
||||
}
|
||||
|
||||
// New returns a new DSL manager
|
||||
func New(typ types.Type) (types.DSL, error) {
|
||||
var manager types.Manager
|
||||
var db types.IO = io.NewDB(typ)
|
||||
var fs types.IO = io.NewFS(typ)
|
||||
|
||||
// Get the root path and the extensions of the type
|
||||
root, exts := types.TypeRootAndExts(typ)
|
||||
|
||||
// Create the manager
|
||||
switch typ {
|
||||
case types.TypeConnector:
|
||||
exts = []string{".conn.yao", ".conn.jsonc", ".conn.json"}
|
||||
manager = connector.New(root, fs, db)
|
||||
|
||||
case types.TypeModel:
|
||||
exts = []string{".mod.yao", ".mod.jsonc", ".mod.json"}
|
||||
manager = model.New(root, fs, db)
|
||||
|
||||
case types.TypeMCPClient:
|
||||
exts = []string{".mcp.yao", ".mcp.jsonc", ".mcp.json"}
|
||||
manager = mcp.NewClient(root, fs, db)
|
||||
|
||||
// case types.TypeMCPServer:
|
||||
// exts = []string{".mcp.yao", ".mcp.jsonc", ".mcp.json"}
|
||||
// manager = mcp.NewServer(root)
|
||||
|
||||
case types.TypeAPI:
|
||||
exts = []string{".http.yao", ".http.jsonc", ".http.json"}
|
||||
manager = api.New(root, fs, db)
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("dsl manager is not initialized, %s not supported", typ)
|
||||
}
|
||||
|
||||
return &DSL{Type: typ, manager: manager, root: root, exts: exts, db: db, fs: fs}, nil
|
||||
}
|
||||
|
||||
// Inspect DSL
|
||||
func (dsl *DSL) Inspect(ctx context.Context, id string) (*types.Info, error) {
|
||||
|
||||
// Get the info from the db
|
||||
info, exists, err := dsl.db.Inspect(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !exists {
|
||||
// Get the info from the file
|
||||
info, exists, err = dsl.fs.Inspect(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("%s not found, %s", dsl.Type, id)
|
||||
}
|
||||
}
|
||||
|
||||
// Merge the status from the manager
|
||||
loaded, err := dsl.manager.Loaded(ctx)
|
||||
if err != nil {
|
||||
fmt.Printf("DEBUG: manager.Loaded failed: %v\n", err)
|
||||
return info, err
|
||||
}
|
||||
|
||||
// Check if the DSL is loaded
|
||||
if _, ok := loaded[id]; ok {
|
||||
info.Status = types.StatusLoaded
|
||||
}
|
||||
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// Path Get Path by id, ( If the DSL is saved as file, return the file path )
|
||||
func (dsl *DSL) Path(ctx context.Context, id string) (string, error) {
|
||||
return types.ToPath(dsl.Type, id), nil
|
||||
}
|
||||
|
||||
// Source Get Source by id
|
||||
func (dsl *DSL) Source(ctx context.Context, id string) (string, error) {
|
||||
|
||||
// Get the source from the db
|
||||
source, exists, err := dsl.db.Source(id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if !exists {
|
||||
// Get the source from the file
|
||||
source, exists, err = dsl.fs.Source(id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if !exists {
|
||||
return "", fmt.Errorf("%s DSL not found, %s", dsl.Type, id)
|
||||
}
|
||||
}
|
||||
|
||||
return source, nil
|
||||
}
|
||||
|
||||
// List DSLs
|
||||
func (dsl *DSL) List(ctx context.Context, opts *types.ListOptions) ([]*types.Info, error) {
|
||||
// Get the list from the db
|
||||
var dbList []*types.Info
|
||||
var fileList []*types.Info
|
||||
var err error
|
||||
|
||||
// If StoreType is not specified or is DB, get from db
|
||||
if opts.Store == "" || opts.Store == types.StoreTypeDB {
|
||||
dbList, err = dsl.db.List(opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// If StoreType is not specified or is File, get from file
|
||||
if opts.Store == "" || opts.Store == types.StoreTypeFile {
|
||||
fileList, err = dsl.fs.List(opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Merge the list and unique
|
||||
list := []*types.Info{}
|
||||
unique := make(map[string]bool)
|
||||
for _, info := range dbList {
|
||||
if _, ok := unique[info.ID]; !ok {
|
||||
list = append(list, info)
|
||||
unique[info.ID] = true
|
||||
}
|
||||
}
|
||||
for _, info := range fileList {
|
||||
if _, ok := unique[info.ID]; !ok {
|
||||
list = append(list, info)
|
||||
unique[info.ID] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Merge the status from the manager
|
||||
loaded, err := dsl.manager.Loaded(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Merge the status from the manager
|
||||
for _, info := range list {
|
||||
if _, ok := loaded[info.ID]; ok {
|
||||
info.Status = types.StatusLoaded
|
||||
}
|
||||
}
|
||||
|
||||
return list, nil
|
||||
}
|
||||
|
||||
// Create DSL
|
||||
func (dsl *DSL) Create(ctx context.Context, options *types.CreateOptions) error {
|
||||
|
||||
if options == nil {
|
||||
return fmt.Errorf("create options is required")
|
||||
}
|
||||
|
||||
// Set default store type if not specified
|
||||
if options.Store == "" {
|
||||
options.Store = types.StoreTypeFile
|
||||
}
|
||||
|
||||
// Validate store type
|
||||
if options.Store != types.StoreTypeDB && options.Store != types.StoreTypeFile {
|
||||
return fmt.Errorf("invalid store type: %s", options.Store)
|
||||
}
|
||||
|
||||
if options.Store == types.StoreTypeDB {
|
||||
err := dsl.db.Create(options)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else if options.Store == types.StoreTypeFile {
|
||||
err := dsl.fs.Create(options)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
var loadOptions *types.LoadOptions = &types.LoadOptions{
|
||||
ID: options.ID,
|
||||
Path: types.ToPath(dsl.Type, options.ID),
|
||||
Source: options.Source,
|
||||
Store: options.Store,
|
||||
Options: options.Load,
|
||||
}
|
||||
|
||||
// Load the DSL
|
||||
err := dsl.Load(ctx, loadOptions)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Exists Check if the DSL exists
|
||||
func (dsl *DSL) Exists(ctx context.Context, id string) (bool, error) {
|
||||
// Check if the DSL exists in the db
|
||||
exists, err := dsl.db.Exists(id)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if exists {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Check if the DSL exists in the file
|
||||
return dsl.fs.Exists(id)
|
||||
}
|
||||
|
||||
// Update DSL
|
||||
func (dsl *DSL) Update(ctx context.Context, options *types.UpdateOptions) error {
|
||||
|
||||
if options == nil {
|
||||
return fmt.Errorf("update options is required")
|
||||
}
|
||||
|
||||
// Exists
|
||||
info, exists, err := dsl.db.Inspect(options.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !exists {
|
||||
info, exists, err = dsl.fs.Inspect(options.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
return fmt.Errorf("%s not found, %s", dsl.Type, options.ID)
|
||||
}
|
||||
// Fix: If store is empty but found in fs, it should be File store
|
||||
if info.Store == "" {
|
||||
info.Store = types.StoreTypeFile
|
||||
}
|
||||
} else {
|
||||
// Fix: If store is empty but found in db, it should be DB store
|
||||
if info.Store == "" {
|
||||
info.Store = types.StoreTypeDB
|
||||
}
|
||||
}
|
||||
|
||||
// Create the reload options
|
||||
var reloadOptions *types.ReloadOptions = &types.ReloadOptions{
|
||||
ID: options.ID,
|
||||
Path: info.Path,
|
||||
Source: options.Source,
|
||||
Store: info.Store,
|
||||
Options: options.Reload,
|
||||
}
|
||||
|
||||
// Update the DSL in the db
|
||||
if info.Store == types.StoreTypeDB {
|
||||
err := dsl.db.Update(options)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Reload the DSL
|
||||
return dsl.manager.Reload(ctx, reloadOptions)
|
||||
}
|
||||
|
||||
// Update the DSL in the file
|
||||
err = dsl.fs.Update(options)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Reload the DSL
|
||||
return dsl.manager.Reload(ctx, reloadOptions)
|
||||
}
|
||||
|
||||
// Delete DSL
|
||||
func (dsl *DSL) Delete(ctx context.Context, options *types.DeleteOptions) error {
|
||||
|
||||
if options == nil {
|
||||
return fmt.Errorf("delete options is required")
|
||||
}
|
||||
|
||||
if options.ID == "" {
|
||||
return fmt.Errorf("delete options id is required")
|
||||
}
|
||||
|
||||
// Exists
|
||||
info, exists, err := dsl.db.Inspect(options.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !exists {
|
||||
info, exists, err = dsl.fs.Inspect(options.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
return fmt.Errorf("%s not found, %s", dsl.Type, options.ID)
|
||||
} else {
|
||||
// Fix: If store is empty but found in fs, it should be File store
|
||||
if info.Store == "" {
|
||||
info.Store = types.StoreTypeFile
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fix: If store is empty but found in db, it should be DB store
|
||||
if info.Store == "" {
|
||||
info.Store = types.StoreTypeDB
|
||||
}
|
||||
}
|
||||
|
||||
var opts map[string]interface{}
|
||||
if options.Options != nil {
|
||||
opts = options.Options
|
||||
}
|
||||
|
||||
var unloadOptions *types.UnloadOptions = &types.UnloadOptions{
|
||||
ID: options.ID,
|
||||
Path: info.Path,
|
||||
Store: info.Store,
|
||||
Options: opts,
|
||||
}
|
||||
|
||||
if info.Store == types.StoreTypeDB {
|
||||
err = dsl.db.Delete(options.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Unload the DSL
|
||||
return dsl.manager.Unload(ctx, unloadOptions)
|
||||
}
|
||||
|
||||
err = dsl.fs.Delete(options.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Unload the DSL
|
||||
return dsl.manager.Unload(ctx, unloadOptions)
|
||||
|
||||
}
|
||||
|
||||
// Load DSL
|
||||
func (dsl *DSL) Load(ctx context.Context, options *types.LoadOptions) error {
|
||||
return dsl.manager.Load(ctx, options)
|
||||
}
|
||||
|
||||
// Unload DSL
|
||||
func (dsl *DSL) Unload(ctx context.Context, options *types.UnloadOptions) error {
|
||||
return dsl.manager.Unload(ctx, options)
|
||||
}
|
||||
|
||||
// Reload DSL
|
||||
func (dsl *DSL) Reload(ctx context.Context, options *types.ReloadOptions) error {
|
||||
return dsl.manager.Reload(ctx, options)
|
||||
}
|
||||
|
||||
// Execute DSL (Some DSLs can be executed)
|
||||
func (dsl *DSL) Execute(ctx context.Context, id string, method string, args ...any) (any, error) {
|
||||
return dsl.manager.Execute(ctx, id, method, args...)
|
||||
}
|
||||
|
||||
// Validate DSL
|
||||
func (dsl *DSL) Validate(ctx context.Context, source string) (bool, []types.LintMessage) {
|
||||
return dsl.manager.Validate(ctx, source)
|
||||
}
|
||||
784
dsl/dsl_test.go
Normal file
784
dsl/dsl_test.go
Normal file
|
|
@ -0,0 +1,784 @@
|
|||
package dsl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
jsoniter "github.com/json-iterator/go"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/yaoapp/gou/application"
|
||||
"github.com/yaoapp/gou/model"
|
||||
"github.com/yaoapp/kun/log"
|
||||
"github.com/yaoapp/yao/config"
|
||||
"github.com/yaoapp/yao/data"
|
||||
"github.com/yaoapp/yao/dsl/types"
|
||||
"github.com/yaoapp/yao/test"
|
||||
)
|
||||
|
||||
// systemModels system models
|
||||
var systemModels = map[string]string{
|
||||
"__yao.dsl": "yao/models/dsl.mod.yao",
|
||||
}
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
// Setup
|
||||
test.Prepare(&testing.T{}, config.Conf)
|
||||
defer test.Clean()
|
||||
|
||||
// Load system models
|
||||
model.WithCrypt([]byte(fmt.Sprintf(`{"key":"%s"}`, config.Conf.DB.AESKey)), "AES")
|
||||
model.WithCrypt([]byte(`{}`), "PASSWORD")
|
||||
err := loadSystemModels()
|
||||
if err != nil {
|
||||
log.Error("Load system models error: %s", err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Load application
|
||||
root := os.Getenv("YAO_TEST_APPLICATION")
|
||||
if root == "" {
|
||||
log.Error("YAO_TEST_APPLICATION environment variable is not set")
|
||||
os.Exit(1)
|
||||
}
|
||||
app, err := application.OpenFromDisk(root) // Load app
|
||||
if err != nil {
|
||||
log.Error("Load application error: %s", err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
application.Load(app)
|
||||
|
||||
// Run tests
|
||||
code := m.Run()
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
// loadSystemModels load system models
|
||||
func loadSystemModels() error {
|
||||
for id, path := range systemModels {
|
||||
content, err := data.Read(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Parse model
|
||||
var data map[string]interface{}
|
||||
err = application.Parse(path, content, &data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Set prefix
|
||||
if table, ok := data["table"].(map[string]interface{}); ok {
|
||||
if name, ok := table["name"].(string); ok {
|
||||
table["name"] = "__yao_" + name
|
||||
content, err = jsoniter.Marshal(data)
|
||||
if err != nil {
|
||||
log.Error("failed to marshal model data: %v", err)
|
||||
return fmt.Errorf("failed to marshal model data: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load Model
|
||||
mod, err := model.LoadSource(content, id, path)
|
||||
if err != nil {
|
||||
log.Error("load system model %s error: %s", id, err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
// Drop table first
|
||||
err = mod.DropTable()
|
||||
if err != nil {
|
||||
log.Error("drop table error: %s", err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
// Auto migrate
|
||||
err = mod.Migrate(false, model.WithDonotInsertValues(true))
|
||||
if err != nil {
|
||||
log.Error("migrate system model %s error: %s", id, err.Error())
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// cleanTestData cleans test data from database
|
||||
func cleanTestData() error {
|
||||
m := model.Select("__yao.dsl")
|
||||
err := m.DropTable()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = m.Migrate(false, model.WithDonotInsertValues(true))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// getTestID generates a unique test ID
|
||||
func getTestID() string {
|
||||
return fmt.Sprintf("test_%d", time.Now().UnixNano())
|
||||
}
|
||||
|
||||
// TestCase defines a unified test case for all DSL types
|
||||
type TestCase struct {
|
||||
ID string
|
||||
Source string
|
||||
UpdatedSource string
|
||||
Tags []string
|
||||
Label string
|
||||
Description string
|
||||
DSLType types.Type
|
||||
}
|
||||
|
||||
// NewModelTestCase creates a new model test case
|
||||
func NewModelTestCase() *TestCase {
|
||||
id := getTestID()
|
||||
return &TestCase{
|
||||
ID: id,
|
||||
DSLType: types.TypeModel,
|
||||
Source: fmt.Sprintf(`{
|
||||
"name": "%s",
|
||||
"table": { "name": "%s", "comment": "Test User" },
|
||||
"columns": [
|
||||
{ "name": "id", "type": "ID" },
|
||||
{ "name": "name", "type": "string", "length": 80, "comment": "User Name", "index": true },
|
||||
{ "name": "status", "type": "enum", "option": ["active", "disabled"], "default": "active", "comment": "Status", "index": true }
|
||||
],
|
||||
"tags": ["test_%s"],
|
||||
"label": "Test Model",
|
||||
"description": "Test Model Description",
|
||||
"option": { "timestamps": true, "soft_deletes": true }
|
||||
}`, id, id, id),
|
||||
UpdatedSource: fmt.Sprintf(`{
|
||||
"name": "%s",
|
||||
"table": { "name": "%s", "comment": "Updated Test User" },
|
||||
"columns": [
|
||||
{ "name": "id", "type": "ID" },
|
||||
{ "name": "name", "type": "string", "length": 80, "comment": "User Name", "index": true },
|
||||
{ "name": "status", "type": "enum", "option": ["active", "disabled", "pending"], "default": "active", "comment": "Status", "index": true }
|
||||
],
|
||||
"tags": ["test_%s", "updated"],
|
||||
"label": "Updated Model",
|
||||
"description": "Updated Model Description",
|
||||
"option": { "timestamps": true, "soft_deletes": true }
|
||||
}`, id, id, id),
|
||||
Tags: []string{fmt.Sprintf("test_%s", id)},
|
||||
Label: "Test Model",
|
||||
Description: "Test Model Description",
|
||||
}
|
||||
}
|
||||
|
||||
// NewConnectorTestCase creates a new connector test case
|
||||
func NewConnectorTestCase() *TestCase {
|
||||
id := getTestID()
|
||||
return &TestCase{
|
||||
ID: id,
|
||||
DSLType: types.TypeConnector,
|
||||
Source: fmt.Sprintf(`{
|
||||
"label": "Test Connector",
|
||||
"description": "Test Connector Description",
|
||||
"tags": ["test_%s"],
|
||||
"type": "openai",
|
||||
"options": {
|
||||
"proxy": "https://api.openai.com/v1",
|
||||
"model": "gpt-4o-mini",
|
||||
"key": "sk-test-key"
|
||||
}
|
||||
}`, id),
|
||||
UpdatedSource: fmt.Sprintf(`{
|
||||
"label": "Updated Connector",
|
||||
"description": "Updated Connector Description",
|
||||
"tags": ["test_%s", "updated"],
|
||||
"type": "openai",
|
||||
"options": {
|
||||
"proxy": "https://api.openai.com/v1",
|
||||
"model": "gpt-4o-mini",
|
||||
"key": "sk-test-key"
|
||||
}
|
||||
}`, id),
|
||||
Tags: []string{fmt.Sprintf("test_%s", id)},
|
||||
Label: "Test Connector",
|
||||
Description: "Test Connector Description",
|
||||
}
|
||||
}
|
||||
|
||||
// NewMCPTestCase creates a new MCP test case
|
||||
func NewMCPTestCase() *TestCase {
|
||||
id := getTestID()
|
||||
return &TestCase{
|
||||
ID: id,
|
||||
DSLType: types.TypeMCPClient,
|
||||
Source: fmt.Sprintf(`{
|
||||
"name": "Test MCP Client %s",
|
||||
"label": "Test MCP Client",
|
||||
"description": "Test MCP Client Description",
|
||||
"tags": ["test_%s"],
|
||||
"transport": "stdio",
|
||||
"command": "echo",
|
||||
"arguments": ["hello", "world"],
|
||||
"env": {
|
||||
"MCP_TEST": "true"
|
||||
},
|
||||
"enable_sampling": true,
|
||||
"enable_roots": false,
|
||||
"timeout": "30s"
|
||||
}`, id, id),
|
||||
UpdatedSource: fmt.Sprintf(`{
|
||||
"name": "Updated MCP Client %s",
|
||||
"label": "Updated MCP Client",
|
||||
"description": "Updated MCP Client Description",
|
||||
"tags": ["test_%s", "updated"],
|
||||
"transport": "stdio",
|
||||
"command": "echo",
|
||||
"arguments": ["hello", "updated"],
|
||||
"env": {
|
||||
"MCP_TEST": "true",
|
||||
"MCP_UPDATED": "true"
|
||||
},
|
||||
"enable_sampling": false,
|
||||
"enable_roots": true,
|
||||
"timeout": "60s"
|
||||
}`, id, id),
|
||||
Tags: []string{fmt.Sprintf("test_%s", id)},
|
||||
Label: "Test MCP Client",
|
||||
Description: "Test MCP Client Description",
|
||||
}
|
||||
}
|
||||
|
||||
// CreateOptions returns creation options
|
||||
func (tc *TestCase) CreateOptions(store types.StoreType) *types.CreateOptions {
|
||||
return &types.CreateOptions{
|
||||
ID: tc.ID,
|
||||
Source: tc.Source,
|
||||
Store: store,
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateOptions returns update options
|
||||
func (tc *TestCase) UpdateOptions() *types.UpdateOptions {
|
||||
return &types.UpdateOptions{
|
||||
ID: tc.ID,
|
||||
Source: tc.UpdatedSource,
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteOptions returns delete options
|
||||
func (tc *TestCase) DeleteOptions() *types.DeleteOptions {
|
||||
return &types.DeleteOptions{
|
||||
ID: tc.ID,
|
||||
}
|
||||
}
|
||||
|
||||
// LoadOptions returns load options
|
||||
func (tc *TestCase) LoadOptions(store types.StoreType) *types.LoadOptions {
|
||||
return &types.LoadOptions{
|
||||
ID: tc.ID,
|
||||
Source: tc.Source,
|
||||
Store: store,
|
||||
}
|
||||
}
|
||||
|
||||
// UnloadOptions returns unload options
|
||||
func (tc *TestCase) UnloadOptions(store types.StoreType) *types.UnloadOptions {
|
||||
return &types.UnloadOptions{
|
||||
ID: tc.ID,
|
||||
Store: store,
|
||||
}
|
||||
}
|
||||
|
||||
// ReloadOptions returns reload options
|
||||
func (tc *TestCase) ReloadOptions(store types.StoreType) *types.ReloadOptions {
|
||||
return &types.ReloadOptions{
|
||||
ID: tc.ID,
|
||||
Source: tc.UpdatedSource,
|
||||
Store: store,
|
||||
}
|
||||
}
|
||||
|
||||
// ListOptions returns list options
|
||||
func (tc *TestCase) ListOptions(store types.StoreType) *types.ListOptions {
|
||||
return &types.ListOptions{
|
||||
Tags: tc.Tags,
|
||||
Store: store,
|
||||
}
|
||||
}
|
||||
|
||||
// AssertInfo verifies if the information is correct
|
||||
func (tc *TestCase) AssertInfo(info *types.Info) bool {
|
||||
if info == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return info.ID == tc.ID &&
|
||||
info.Type == tc.DSLType &&
|
||||
info.Label == tc.Label &&
|
||||
len(info.Tags) == len(tc.Tags) &&
|
||||
info.Description == tc.Description &&
|
||||
!info.Readonly &&
|
||||
!info.Builtin &&
|
||||
!info.Mtime.IsZero() &&
|
||||
!info.Ctime.IsZero()
|
||||
}
|
||||
|
||||
// AssertUpdatedInfo verifies if the updated information is correct
|
||||
func (tc *TestCase) AssertUpdatedInfo(info *types.Info) bool {
|
||||
if info == nil {
|
||||
return false
|
||||
}
|
||||
expectedLabel := ""
|
||||
switch tc.DSLType {
|
||||
case types.TypeModel:
|
||||
expectedLabel = "Updated Model"
|
||||
case types.TypeConnector:
|
||||
expectedLabel = "Updated Connector"
|
||||
case types.TypeMCPClient:
|
||||
expectedLabel = "Updated MCP Client"
|
||||
}
|
||||
expectedDescription := ""
|
||||
switch tc.DSLType {
|
||||
case types.TypeModel:
|
||||
expectedDescription = "Updated Model Description"
|
||||
case types.TypeConnector:
|
||||
expectedDescription = "Updated Connector Description"
|
||||
case types.TypeMCPClient:
|
||||
expectedDescription = "Updated MCP Client Description"
|
||||
}
|
||||
return info.ID == tc.ID &&
|
||||
info.Type == tc.DSLType &&
|
||||
info.Label == expectedLabel &&
|
||||
len(info.Tags) == 2 &&
|
||||
info.Description == expectedDescription &&
|
||||
!info.Readonly &&
|
||||
!info.Builtin &&
|
||||
!info.Mtime.IsZero() &&
|
||||
!info.Ctime.IsZero()
|
||||
}
|
||||
|
||||
// Test DSL creation with different types and stores
|
||||
func TestDSLCreate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
tcFunc func() *TestCase
|
||||
dslType types.Type
|
||||
stores []types.StoreType
|
||||
}{
|
||||
{"Model", NewModelTestCase, types.TypeModel, []types.StoreType{types.StoreTypeDB, types.StoreTypeFile}},
|
||||
{"Connector", NewConnectorTestCase, types.TypeConnector, []types.StoreType{types.StoreTypeDB, types.StoreTypeFile}},
|
||||
{"MCP", NewMCPTestCase, types.TypeMCPClient, []types.StoreType{types.StoreTypeDB, types.StoreTypeFile}},
|
||||
}
|
||||
|
||||
for _, tt := range testCases {
|
||||
for _, store := range tt.stores {
|
||||
t.Run(fmt.Sprintf("%s_%s", tt.name, store), func(t *testing.T) {
|
||||
// Clean test data before each test
|
||||
err := cleanTestData()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to clean test data: %v", err)
|
||||
}
|
||||
|
||||
dsl, err := New(tt.dslType)
|
||||
if !assert.Nil(t, err) {
|
||||
return
|
||||
}
|
||||
|
||||
tc := tt.tcFunc()
|
||||
|
||||
// Create
|
||||
err = dsl.Create(ctx, tc.CreateOptions(store))
|
||||
if !assert.Nil(t, err) {
|
||||
return
|
||||
}
|
||||
|
||||
// Verify exists
|
||||
exists, err := dsl.Exists(ctx, tc.ID)
|
||||
if !assert.Nil(t, err) {
|
||||
return
|
||||
}
|
||||
assert.True(t, exists)
|
||||
|
||||
// Verify info
|
||||
info, err := dsl.Inspect(ctx, tc.ID)
|
||||
if !assert.Nil(t, err) {
|
||||
return
|
||||
}
|
||||
assert.True(t, tc.AssertInfo(info))
|
||||
|
||||
// Cleanup
|
||||
err = dsl.Delete(ctx, tc.DeleteOptions())
|
||||
assert.Nil(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test DSL inspection
|
||||
func TestDSLInspect(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
tcFunc func() *TestCase
|
||||
dslType types.Type
|
||||
stores []types.StoreType
|
||||
}{
|
||||
{"Model", NewModelTestCase, types.TypeModel, []types.StoreType{types.StoreTypeDB, types.StoreTypeFile}},
|
||||
{"Connector", NewConnectorTestCase, types.TypeConnector, []types.StoreType{types.StoreTypeDB, types.StoreTypeFile}},
|
||||
{"MCP", NewMCPTestCase, types.TypeMCPClient, []types.StoreType{types.StoreTypeDB, types.StoreTypeFile}},
|
||||
}
|
||||
|
||||
for _, tt := range testCases {
|
||||
for _, store := range tt.stores {
|
||||
t.Run(fmt.Sprintf("%s_%s", tt.name, store), func(t *testing.T) {
|
||||
// Clean test data before each test
|
||||
err := cleanTestData()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to clean test data: %v", err)
|
||||
}
|
||||
|
||||
dsl, err := New(tt.dslType)
|
||||
if !assert.Nil(t, err) {
|
||||
return
|
||||
}
|
||||
|
||||
tc := tt.tcFunc()
|
||||
|
||||
// Create
|
||||
err = dsl.Create(ctx, tc.CreateOptions(store))
|
||||
if !assert.Nil(t, err) {
|
||||
return
|
||||
}
|
||||
|
||||
// Inspect
|
||||
info, err := dsl.Inspect(ctx, tc.ID)
|
||||
if !assert.Nil(t, err) {
|
||||
return
|
||||
}
|
||||
assert.True(t, tc.AssertInfo(info))
|
||||
|
||||
// Cleanup
|
||||
err = dsl.Delete(ctx, tc.DeleteOptions())
|
||||
assert.Nil(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test DSL source retrieval
|
||||
func TestDSLSource(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
tcFunc func() *TestCase
|
||||
dslType types.Type
|
||||
stores []types.StoreType
|
||||
}{
|
||||
{"Model", NewModelTestCase, types.TypeModel, []types.StoreType{types.StoreTypeDB, types.StoreTypeFile}},
|
||||
{"Connector", NewConnectorTestCase, types.TypeConnector, []types.StoreType{types.StoreTypeDB, types.StoreTypeFile}},
|
||||
{"MCP", NewMCPTestCase, types.TypeMCPClient, []types.StoreType{types.StoreTypeDB, types.StoreTypeFile}},
|
||||
}
|
||||
|
||||
for _, tt := range testCases {
|
||||
for _, store := range tt.stores {
|
||||
t.Run(fmt.Sprintf("%s_%s", tt.name, store), func(t *testing.T) {
|
||||
// Clean test data before each test
|
||||
err := cleanTestData()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to clean test data: %v", err)
|
||||
}
|
||||
|
||||
dsl, err := New(tt.dslType)
|
||||
if !assert.Nil(t, err) {
|
||||
return
|
||||
}
|
||||
|
||||
tc := tt.tcFunc()
|
||||
|
||||
// Create
|
||||
err = dsl.Create(ctx, tc.CreateOptions(store))
|
||||
if !assert.Nil(t, err) {
|
||||
return
|
||||
}
|
||||
|
||||
// Get source
|
||||
source, err := dsl.Source(ctx, tc.ID)
|
||||
if !assert.Nil(t, err) {
|
||||
return
|
||||
}
|
||||
assert.Equal(t, tc.Source, source)
|
||||
|
||||
// Cleanup
|
||||
err = dsl.Delete(ctx, tc.DeleteOptions())
|
||||
assert.Nil(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test DSL listing
|
||||
func TestDSLList(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
tcFunc func() *TestCase
|
||||
dslType types.Type
|
||||
stores []types.StoreType
|
||||
}{
|
||||
{"Model", NewModelTestCase, types.TypeModel, []types.StoreType{types.StoreTypeDB, types.StoreTypeFile}},
|
||||
{"Connector", NewConnectorTestCase, types.TypeConnector, []types.StoreType{types.StoreTypeDB, types.StoreTypeFile}},
|
||||
{"MCP", NewMCPTestCase, types.TypeMCPClient, []types.StoreType{types.StoreTypeDB, types.StoreTypeFile}},
|
||||
}
|
||||
|
||||
for _, tt := range testCases {
|
||||
for _, store := range tt.stores {
|
||||
t.Run(fmt.Sprintf("%s_%s", tt.name, store), func(t *testing.T) {
|
||||
// Clean test data before each test
|
||||
err := cleanTestData()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to clean test data: %v", err)
|
||||
}
|
||||
|
||||
dsl, err := New(tt.dslType)
|
||||
if !assert.Nil(t, err) {
|
||||
return
|
||||
}
|
||||
|
||||
tc1 := tt.tcFunc()
|
||||
tc2 := tt.tcFunc()
|
||||
|
||||
// Create test cases
|
||||
err = dsl.Create(ctx, tc1.CreateOptions(store))
|
||||
if !assert.Nil(t, err) {
|
||||
return
|
||||
}
|
||||
err = dsl.Create(ctx, tc2.CreateOptions(store))
|
||||
if !assert.Nil(t, err) {
|
||||
return
|
||||
}
|
||||
|
||||
// List all
|
||||
list, err := dsl.List(ctx, &types.ListOptions{Store: store})
|
||||
if !assert.Nil(t, err) {
|
||||
return
|
||||
}
|
||||
assert.GreaterOrEqual(t, len(list), 2)
|
||||
|
||||
// List with tags
|
||||
list, err = dsl.List(ctx, tc1.ListOptions(store))
|
||||
if !assert.Nil(t, err) {
|
||||
return
|
||||
}
|
||||
assert.GreaterOrEqual(t, len(list), 1)
|
||||
|
||||
// Cleanup
|
||||
err = dsl.Delete(ctx, tc1.DeleteOptions())
|
||||
assert.Nil(t, err)
|
||||
err = dsl.Delete(ctx, tc2.DeleteOptions())
|
||||
assert.Nil(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test DSL update
|
||||
func TestDSLUpdate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
tcFunc func() *TestCase
|
||||
dslType types.Type
|
||||
stores []types.StoreType
|
||||
}{
|
||||
{"Model", NewModelTestCase, types.TypeModel, []types.StoreType{types.StoreTypeDB, types.StoreTypeFile}},
|
||||
{"Connector", NewConnectorTestCase, types.TypeConnector, []types.StoreType{types.StoreTypeDB, types.StoreTypeFile}},
|
||||
{"MCP", NewMCPTestCase, types.TypeMCPClient, []types.StoreType{types.StoreTypeDB, types.StoreTypeFile}},
|
||||
}
|
||||
|
||||
for _, tt := range testCases {
|
||||
for _, store := range tt.stores {
|
||||
t.Run(fmt.Sprintf("%s_%s", tt.name, store), func(t *testing.T) {
|
||||
// Clean test data before each test
|
||||
err := cleanTestData()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to clean test data: %v", err)
|
||||
}
|
||||
|
||||
dsl, err := New(tt.dslType)
|
||||
if !assert.Nil(t, err) {
|
||||
return
|
||||
}
|
||||
|
||||
tc := tt.tcFunc()
|
||||
|
||||
// Create
|
||||
err = dsl.Create(ctx, tc.CreateOptions(store))
|
||||
if !assert.Nil(t, err) {
|
||||
return
|
||||
}
|
||||
|
||||
// Update
|
||||
err = dsl.Update(ctx, tc.UpdateOptions())
|
||||
if !assert.Nil(t, err) {
|
||||
return
|
||||
}
|
||||
|
||||
// Verify updated info
|
||||
info, err := dsl.Inspect(ctx, tc.ID)
|
||||
if !assert.Nil(t, err) {
|
||||
return
|
||||
}
|
||||
assert.True(t, tc.AssertUpdatedInfo(info))
|
||||
|
||||
// Cleanup
|
||||
err = dsl.Delete(ctx, tc.DeleteOptions())
|
||||
assert.Nil(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test DSL delete
|
||||
func TestDSLDelete(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
tcFunc func() *TestCase
|
||||
dslType types.Type
|
||||
stores []types.StoreType
|
||||
}{
|
||||
{"Model", NewModelTestCase, types.TypeModel, []types.StoreType{types.StoreTypeDB, types.StoreTypeFile}},
|
||||
{"Connector", NewConnectorTestCase, types.TypeConnector, []types.StoreType{types.StoreTypeDB, types.StoreTypeFile}},
|
||||
{"MCP", NewMCPTestCase, types.TypeMCPClient, []types.StoreType{types.StoreTypeDB, types.StoreTypeFile}},
|
||||
}
|
||||
|
||||
for _, tt := range testCases {
|
||||
for _, store := range tt.stores {
|
||||
t.Run(fmt.Sprintf("%s_%s", tt.name, store), func(t *testing.T) {
|
||||
// Clean test data before each test
|
||||
err := cleanTestData()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to clean test data: %v", err)
|
||||
}
|
||||
|
||||
dsl, err := New(tt.dslType)
|
||||
if !assert.Nil(t, err) {
|
||||
return
|
||||
}
|
||||
|
||||
tc := tt.tcFunc()
|
||||
|
||||
// Create
|
||||
err = dsl.Create(ctx, tc.CreateOptions(store))
|
||||
if !assert.Nil(t, err) {
|
||||
return
|
||||
}
|
||||
|
||||
// Delete
|
||||
err = dsl.Delete(ctx, tc.DeleteOptions())
|
||||
if !assert.Nil(t, err) {
|
||||
return
|
||||
}
|
||||
|
||||
// Verify deleted
|
||||
exists, err := dsl.Exists(ctx, tc.ID)
|
||||
if !assert.Nil(t, err) {
|
||||
return
|
||||
}
|
||||
assert.False(t, exists)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test DSL full flow (create, inspect, update, delete)
|
||||
func TestDSLFlow(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
tcFunc func() *TestCase
|
||||
dslType types.Type
|
||||
stores []types.StoreType
|
||||
}{
|
||||
{"Model", NewModelTestCase, types.TypeModel, []types.StoreType{types.StoreTypeDB, types.StoreTypeFile}},
|
||||
{"Connector", NewConnectorTestCase, types.TypeConnector, []types.StoreType{types.StoreTypeDB, types.StoreTypeFile}},
|
||||
{"MCP", NewMCPTestCase, types.TypeMCPClient, []types.StoreType{types.StoreTypeDB, types.StoreTypeFile}},
|
||||
}
|
||||
|
||||
for _, tt := range testCases {
|
||||
for _, store := range tt.stores {
|
||||
t.Run(fmt.Sprintf("%s_%s", tt.name, store), func(t *testing.T) {
|
||||
// Clean test data before each test
|
||||
err := cleanTestData()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to clean test data: %v", err)
|
||||
}
|
||||
|
||||
dsl, err := New(tt.dslType)
|
||||
if !assert.Nil(t, err) {
|
||||
return
|
||||
}
|
||||
|
||||
tc := tt.tcFunc()
|
||||
|
||||
// Create
|
||||
err = dsl.Create(ctx, tc.CreateOptions(store))
|
||||
if !assert.Nil(t, err) {
|
||||
return
|
||||
}
|
||||
|
||||
// Inspect
|
||||
info, err := dsl.Inspect(ctx, tc.ID)
|
||||
if !assert.Nil(t, err) {
|
||||
return
|
||||
}
|
||||
assert.True(t, tc.AssertInfo(info))
|
||||
|
||||
// Get source
|
||||
source, err := dsl.Source(ctx, tc.ID)
|
||||
if !assert.Nil(t, err) {
|
||||
return
|
||||
}
|
||||
assert.Equal(t, tc.Source, source)
|
||||
|
||||
// Update
|
||||
err = dsl.Update(ctx, tc.UpdateOptions())
|
||||
if !assert.Nil(t, err) {
|
||||
return
|
||||
}
|
||||
|
||||
// Verify updated
|
||||
info, err = dsl.Inspect(ctx, tc.ID)
|
||||
if !assert.Nil(t, err) {
|
||||
return
|
||||
}
|
||||
assert.True(t, tc.AssertUpdatedInfo(info))
|
||||
|
||||
// Delete
|
||||
err = dsl.Delete(ctx, tc.DeleteOptions())
|
||||
if !assert.Nil(t, err) {
|
||||
return
|
||||
}
|
||||
|
||||
// Verify deleted
|
||||
exists, err := dsl.Exists(ctx, tc.ID)
|
||||
if !assert.Nil(t, err) {
|
||||
return
|
||||
}
|
||||
assert.False(t, exists)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
223
dsl/io/cases_test.go
Normal file
223
dsl/io/cases_test.go
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
package io
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
jsoniter "github.com/json-iterator/go"
|
||||
"github.com/yaoapp/gou/application"
|
||||
"github.com/yaoapp/gou/model"
|
||||
"github.com/yaoapp/kun/log"
|
||||
"github.com/yaoapp/yao/config"
|
||||
"github.com/yaoapp/yao/data"
|
||||
"github.com/yaoapp/yao/dsl/types"
|
||||
"github.com/yaoapp/yao/test"
|
||||
)
|
||||
|
||||
// systemModels system models
|
||||
var systemModels = map[string]string{
|
||||
"__yao.dsl": "yao/models/dsl.mod.yao",
|
||||
}
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
// Setup
|
||||
test.Prepare(&testing.T{}, config.Conf)
|
||||
defer test.Clean()
|
||||
|
||||
// Load system models
|
||||
model.WithCrypt([]byte(fmt.Sprintf(`{"key":"%s"}`, config.Conf.DB.AESKey)), "AES")
|
||||
model.WithCrypt([]byte(`{}`), "PASSWORD")
|
||||
err := loadSystemModels()
|
||||
if err != nil {
|
||||
log.Error("Load system models error: %s", err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Run tests
|
||||
code := m.Run()
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
// loadSystemModels load system models
|
||||
func loadSystemModels() error {
|
||||
for id, path := range systemModels {
|
||||
content, err := data.Read(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Parse model
|
||||
var data map[string]interface{}
|
||||
err = application.Parse(path, content, &data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Set prefix
|
||||
if table, ok := data["table"].(map[string]interface{}); ok {
|
||||
if name, ok := table["name"].(string); ok {
|
||||
table["name"] = "__yao_" + name
|
||||
content, err = jsoniter.Marshal(data)
|
||||
if err != nil {
|
||||
log.Error("failed to marshal model data: %v", err)
|
||||
return fmt.Errorf("failed to marshal model data: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load Model
|
||||
mod, err := model.LoadSource(content, id, filepath.Join("__system", path))
|
||||
if err != nil {
|
||||
log.Error("load system model %s error: %s", id, err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
// Drop table first
|
||||
err = mod.DropTable()
|
||||
if err != nil {
|
||||
log.Error("drop table error: %s", err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
// Auto migrate
|
||||
err = mod.Migrate(false, model.WithDonotInsertValues(true))
|
||||
if err != nil {
|
||||
log.Error("migrate system model %s error: %s", id, err.Error())
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// cleanTestData cleans test data from database
|
||||
func cleanTestData() error {
|
||||
m := model.Select("__yao.dsl")
|
||||
err := m.DropTable()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = m.Migrate(false, model.WithDonotInsertValues(true))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// getTestID 生成唯一的测试ID
|
||||
func getTestID() string {
|
||||
return fmt.Sprintf("test_%d", time.Now().UnixNano())
|
||||
}
|
||||
|
||||
// TestCase 定义单个测试用例
|
||||
type TestCase struct {
|
||||
ID string
|
||||
Source string
|
||||
UpdatedSource string
|
||||
Tags []string
|
||||
Label string
|
||||
Description string
|
||||
}
|
||||
|
||||
// NewTestCase 创建新的测试用例
|
||||
func NewTestCase() *TestCase {
|
||||
id := getTestID()
|
||||
return &TestCase{
|
||||
ID: id,
|
||||
Source: fmt.Sprintf(`{
|
||||
"name": "%s",
|
||||
"table": { "name": "%s", "comment": "Test Table" },
|
||||
"columns": [
|
||||
{ "name": "id", "type": "ID" }
|
||||
],
|
||||
"tags": ["test_%s"],
|
||||
"label": "Test Label",
|
||||
"description": "Test Description"
|
||||
}`, id, id, id),
|
||||
UpdatedSource: fmt.Sprintf(`{
|
||||
"name": "%s",
|
||||
"table": { "name": "%s", "comment": "Updated Test Table" },
|
||||
"columns": [
|
||||
{ "name": "id", "type": "ID" }
|
||||
],
|
||||
"tags": ["test_%s", "updated"],
|
||||
"label": "Updated Label",
|
||||
"description": "Updated Description"
|
||||
}`, id, id, id),
|
||||
Tags: []string{fmt.Sprintf("test_%s", id)},
|
||||
Label: "Test Label",
|
||||
Description: "Test Description",
|
||||
}
|
||||
}
|
||||
|
||||
// CreateOptions 返回创建选项
|
||||
func (tc *TestCase) CreateOptions() *types.CreateOptions {
|
||||
return &types.CreateOptions{
|
||||
ID: tc.ID,
|
||||
Source: tc.Source,
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateOptions 返回更新选项
|
||||
func (tc *TestCase) UpdateOptions() *types.UpdateOptions {
|
||||
return &types.UpdateOptions{
|
||||
ID: tc.ID,
|
||||
Source: tc.UpdatedSource,
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateInfoOptions 返回更新信息选项
|
||||
func (tc *TestCase) UpdateInfoOptions() *types.UpdateOptions {
|
||||
return &types.UpdateOptions{
|
||||
ID: tc.ID,
|
||||
Info: &types.Info{
|
||||
Label: "Updated via Info",
|
||||
Tags: []string{"tag1", "info"},
|
||||
Description: "Updated via info field",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ListOptions 返回列表选项
|
||||
func (tc *TestCase) ListOptions(withSource bool) *types.ListOptions {
|
||||
return &types.ListOptions{
|
||||
Source: withSource,
|
||||
Tags: tc.Tags,
|
||||
}
|
||||
}
|
||||
|
||||
// AssertInfo 验证信息是否正确
|
||||
func (tc *TestCase) AssertInfo(info *types.Info) bool {
|
||||
if info == nil {
|
||||
return false
|
||||
}
|
||||
return info.ID == tc.ID &&
|
||||
info.Label == tc.Label &&
|
||||
len(info.Tags) == len(tc.Tags) &&
|
||||
info.Description == tc.Description
|
||||
}
|
||||
|
||||
// AssertUpdatedInfo 验证更新后的信息是否正确
|
||||
func (tc *TestCase) AssertUpdatedInfo(info *types.Info) bool {
|
||||
if info == nil {
|
||||
return false
|
||||
}
|
||||
return info.ID == tc.ID &&
|
||||
info.Label == "Updated Label" &&
|
||||
len(info.Tags) == 2 &&
|
||||
info.Description == "Updated Description"
|
||||
}
|
||||
|
||||
// AssertUpdatedInfoViaInfo 验证通过Info更新后的信息是否正确
|
||||
func (tc *TestCase) AssertUpdatedInfoViaInfo(info *types.Info) bool {
|
||||
if info == nil {
|
||||
return false
|
||||
}
|
||||
return info.ID == tc.ID &&
|
||||
info.Label == "Updated via Info" &&
|
||||
len(info.Tags) == 2 &&
|
||||
info.Description == "Updated via info field"
|
||||
}
|
||||
467
dsl/io/db.go
Normal file
467
dsl/io/db.go
Normal file
|
|
@ -0,0 +1,467 @@
|
|||
package io
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
jsoniter "github.com/json-iterator/go"
|
||||
"github.com/yaoapp/gou/model"
|
||||
"github.com/yaoapp/yao/dsl/types"
|
||||
)
|
||||
|
||||
// DB is the db io
|
||||
type DB struct {
|
||||
Type types.Type
|
||||
}
|
||||
|
||||
// NewDB create a new db io
|
||||
func NewDB(typ types.Type) types.IO {
|
||||
return &DB{Type: typ}
|
||||
}
|
||||
|
||||
// fmtRow format the row data for DSL info
|
||||
func fmtRow(row map[string]interface{}) map[string]interface{} {
|
||||
// Handle source field first
|
||||
if source, ok := row["source"]; ok {
|
||||
if str, ok := source.(string); ok {
|
||||
row["source"] = str
|
||||
}
|
||||
}
|
||||
|
||||
// Map fields
|
||||
if id, ok := row["dsl_id"]; ok {
|
||||
row["id"] = id
|
||||
delete(row, "dsl_id")
|
||||
}
|
||||
if readonly, ok := row["readonly"]; ok {
|
||||
row["readonly"] = toBool(readonly)
|
||||
delete(row, "readonly")
|
||||
}
|
||||
if builtin, ok := row["built_in"]; ok {
|
||||
row["built_in"] = toBool(builtin)
|
||||
}
|
||||
|
||||
// Convert time values
|
||||
if mtime, ok := row["mtime"]; ok && mtime != nil {
|
||||
if timeStr := toTime(mtime); timeStr != "" {
|
||||
row["mtime"] = timeStr
|
||||
}
|
||||
}
|
||||
if ctime, ok := row["ctime"]; ok && ctime != nil {
|
||||
if timeStr := toTime(ctime); timeStr != "" {
|
||||
row["ctime"] = timeStr
|
||||
}
|
||||
}
|
||||
|
||||
return row
|
||||
}
|
||||
|
||||
// Inspect get the info from the db
|
||||
func (db *DB) Inspect(id string) (*types.Info, bool, error) {
|
||||
|
||||
// Get from database
|
||||
m := model.Select("__yao.dsl")
|
||||
|
||||
// Get the info
|
||||
var info types.Info
|
||||
rows, err := m.Get(model.QueryParam{
|
||||
Wheres: []model.QueryWhere{
|
||||
{Column: "dsl_id", Value: id},
|
||||
{Column: "type", Value: db.Type},
|
||||
},
|
||||
Select: []interface{}{
|
||||
"dsl_id",
|
||||
"type",
|
||||
"label",
|
||||
"path",
|
||||
"sort",
|
||||
"tags",
|
||||
"description",
|
||||
"store",
|
||||
"mtime",
|
||||
"ctime",
|
||||
"readonly",
|
||||
"built_in",
|
||||
},
|
||||
Limit: 1,
|
||||
Orders: []model.QueryOrder{{Column: "sort", Option: "asc"}, {Column: "mtime", Option: "desc"}},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
if len(rows) == 0 {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
// Format row data
|
||||
row := fmtRow(rows[0])
|
||||
|
||||
raw, err := jsoniter.Marshal(row)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
err = jsoniter.Unmarshal(raw, &info)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
// Force set Store to DB since this record is from database
|
||||
info.Store = types.StoreTypeDB
|
||||
|
||||
return &info, true, nil
|
||||
}
|
||||
|
||||
// Source get the source from the db
|
||||
func (db *DB) Source(id string) (string, bool, error) {
|
||||
|
||||
// Get from database
|
||||
m := model.Select("__yao.dsl")
|
||||
|
||||
// Get the source
|
||||
rows, err := m.Get(model.QueryParam{
|
||||
Wheres: []model.QueryWhere{
|
||||
{Column: "dsl_id", Value: id},
|
||||
{Column: "type", Value: db.Type},
|
||||
},
|
||||
Select: []interface{}{"source"},
|
||||
Limit: 1,
|
||||
})
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
|
||||
if len(rows) == 0 {
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
if rows[0]["source"] == nil {
|
||||
return "", true, nil
|
||||
}
|
||||
|
||||
source, ok := rows[0]["source"].(string)
|
||||
if !ok {
|
||||
return "", true, fmt.Errorf("%s %s source is not a string", db.Type, id)
|
||||
}
|
||||
|
||||
return source, true, nil
|
||||
}
|
||||
|
||||
// List get the list from the db
|
||||
func (db *DB) List(options *types.ListOptions) ([]*types.Info, error) {
|
||||
|
||||
// Get from database
|
||||
m := model.Select("__yao.dsl")
|
||||
|
||||
var orders []model.QueryOrder = []model.QueryOrder{{Column: "mtime", Option: "desc"}}
|
||||
if options.Sort == "sort" {
|
||||
orders = []model.QueryOrder{{Column: "sort", Option: "asc"}}
|
||||
}
|
||||
|
||||
var wheres []model.QueryWhere = []model.QueryWhere{{Column: "type", Value: db.Type}}
|
||||
|
||||
// Filter by tags
|
||||
if len(options.Tags) > 0 {
|
||||
var orwheres []model.QueryWhere = []model.QueryWhere{}
|
||||
for _, tag := range options.Tags {
|
||||
match := "%" + strings.TrimSpace(tag) + "%"
|
||||
orwheres = append(orwheres, model.QueryWhere{Column: "tags", Value: match, OP: "like", Method: "orwhere"})
|
||||
}
|
||||
wheres = append(wheres, model.QueryWhere{Wheres: orwheres})
|
||||
}
|
||||
|
||||
// Select fields
|
||||
fields := []interface{}{
|
||||
"dsl_id",
|
||||
"type",
|
||||
"label",
|
||||
"path",
|
||||
"sort",
|
||||
"tags",
|
||||
"description",
|
||||
"store",
|
||||
"mtime",
|
||||
"ctime",
|
||||
"readonly",
|
||||
"built_in",
|
||||
}
|
||||
if options.Source {
|
||||
fields = append(fields, "source")
|
||||
}
|
||||
|
||||
// Get the list
|
||||
rows, err := m.Get(model.QueryParam{
|
||||
Wheres: wheres,
|
||||
Select: fields,
|
||||
Orders: orders,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(rows) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Format rows data
|
||||
for i := range rows {
|
||||
rows[i] = fmtRow(rows[i])
|
||||
}
|
||||
|
||||
var infos []*types.Info
|
||||
raw, err := jsoniter.Marshal(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = jsoniter.Unmarshal(raw, &infos)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Force set Store to DB since these records are from database
|
||||
for _, info := range infos {
|
||||
info.Store = types.StoreTypeDB
|
||||
}
|
||||
|
||||
return infos, nil
|
||||
}
|
||||
|
||||
// Create create the dsl
|
||||
func (db *DB) Create(options *types.CreateOptions) error {
|
||||
|
||||
if options.Source == "" {
|
||||
return fmt.Errorf("%s %s source is required", db.Type, options.ID)
|
||||
}
|
||||
|
||||
// Parse the source to extract metadata
|
||||
var sourceData map[string]interface{}
|
||||
err := jsoniter.Unmarshal([]byte(options.Source), &sourceData)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Extract common fields from source
|
||||
var label, description string
|
||||
var tags []string
|
||||
var sort int
|
||||
|
||||
if v, ok := sourceData["label"]; ok {
|
||||
if s, ok := v.(string); ok {
|
||||
label = s
|
||||
}
|
||||
}
|
||||
|
||||
if v, ok := sourceData["description"]; ok {
|
||||
if s, ok := v.(string); ok {
|
||||
description = s
|
||||
}
|
||||
}
|
||||
|
||||
if v, ok := sourceData["tags"]; ok {
|
||||
if tagsList, ok := v.([]interface{}); ok {
|
||||
for _, tag := range tagsList {
|
||||
if s, ok := tag.(string); ok {
|
||||
tags = append(tags, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if v, ok := sourceData["sort"]; ok {
|
||||
if s, ok := v.(float64); ok {
|
||||
sort = int(s)
|
||||
}
|
||||
}
|
||||
|
||||
// Set default store type if not specified
|
||||
store := options.Store
|
||||
if store == "" {
|
||||
store = types.StoreTypeFile
|
||||
}
|
||||
|
||||
// Get the info
|
||||
m := model.Select("__yao.dsl")
|
||||
data := map[string]interface{}{
|
||||
"source": options.Source,
|
||||
"dsl_id": options.ID,
|
||||
"type": db.Type,
|
||||
"label": label,
|
||||
"path": types.ToPath(db.Type, options.ID),
|
||||
"sort": sort,
|
||||
"tags": tags,
|
||||
"description": description,
|
||||
"store": store,
|
||||
"mtime": time.Now(),
|
||||
"ctime": time.Now(),
|
||||
"readonly": 0,
|
||||
"built_in": 0,
|
||||
"created_at": time.Now(),
|
||||
"updated_at": time.Now(),
|
||||
}
|
||||
|
||||
_, err = m.Create(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update update the dsl
|
||||
func (db *DB) Update(options *types.UpdateOptions) error {
|
||||
if options.Source == "" && options.Info == nil {
|
||||
return fmt.Errorf("%s %s one of source or info is required", db.Type, options.ID)
|
||||
}
|
||||
|
||||
m := model.Select("__yao.dsl")
|
||||
|
||||
// Check if the dsl exists
|
||||
rows, err := m.Get(model.QueryParam{
|
||||
Wheres: []model.QueryWhere{
|
||||
{Column: "dsl_id", Value: options.ID},
|
||||
{Column: "type", Value: db.Type},
|
||||
},
|
||||
Select: []interface{}{"id"},
|
||||
Limit: 1,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(rows) == 0 {
|
||||
return fmt.Errorf("%s %s not found", db.Type, options.ID)
|
||||
}
|
||||
|
||||
// update source
|
||||
var data map[string]interface{} = map[string]interface{}{
|
||||
"source": options.Source,
|
||||
}
|
||||
if options.Source != "" {
|
||||
// Parse source to extract metadata
|
||||
var sourceData map[string]interface{}
|
||||
err = jsoniter.Unmarshal([]byte(options.Source), &sourceData)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Extract common fields from source
|
||||
if v, ok := sourceData["label"]; ok {
|
||||
if s, ok := v.(string); ok {
|
||||
data["label"] = s
|
||||
}
|
||||
}
|
||||
|
||||
if v, ok := sourceData["description"]; ok {
|
||||
if s, ok := v.(string); ok {
|
||||
data["description"] = s
|
||||
}
|
||||
}
|
||||
|
||||
if v, ok := sourceData["tags"]; ok {
|
||||
if tagsList, ok := v.([]interface{}); ok {
|
||||
var tags []string
|
||||
for _, tag := range tagsList {
|
||||
if s, ok := tag.(string); ok {
|
||||
tags = append(tags, s)
|
||||
}
|
||||
}
|
||||
data["tags"] = tags
|
||||
}
|
||||
}
|
||||
|
||||
if v, ok := sourceData["sort"]; ok {
|
||||
if s, ok := v.(float64); ok {
|
||||
data["sort"] = int(s)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Update info
|
||||
if options.Info.Label != "" {
|
||||
data["label"] = options.Info.Label
|
||||
}
|
||||
if options.Info.Description != "" {
|
||||
data["description"] = options.Info.Description
|
||||
}
|
||||
if len(options.Info.Tags) > 0 {
|
||||
data["tags"] = options.Info.Tags
|
||||
}
|
||||
if options.Info.Sort != 0 {
|
||||
data["sort"] = options.Info.Sort
|
||||
}
|
||||
if options.Info.Status != "" {
|
||||
data["status"] = options.Info.Status
|
||||
}
|
||||
if options.Info.Store != "" {
|
||||
data["store"] = options.Info.Store
|
||||
}
|
||||
if options.Info.Readonly {
|
||||
data["readonly"] = 1
|
||||
}
|
||||
if options.Info.Builtin {
|
||||
data["built_in"] = 1
|
||||
}
|
||||
}
|
||||
|
||||
data["updated_at"] = time.Now()
|
||||
data["mtime"] = time.Now()
|
||||
|
||||
err = m.Update(rows[0]["id"], data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete delete the dsl
|
||||
func (db *DB) Delete(id string) error {
|
||||
|
||||
// Get from database
|
||||
m := model.Select("__yao.dsl")
|
||||
|
||||
// Check if the dsl exists
|
||||
rows, err := m.Get(model.QueryParam{
|
||||
Wheres: []model.QueryWhere{
|
||||
{Column: "dsl_id", Value: id},
|
||||
{Column: "type", Value: db.Type},
|
||||
},
|
||||
Select: []interface{}{"id", "dsl_id"},
|
||||
Limit: 1,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(rows) == 0 {
|
||||
return fmt.Errorf("%s %s not found", db.Type, id)
|
||||
}
|
||||
|
||||
// Delete the dsl
|
||||
row := rows[0]
|
||||
return m.Delete(row["id"])
|
||||
}
|
||||
|
||||
// Exists check if the dsl exists
|
||||
func (db *DB) Exists(id string) (bool, error) {
|
||||
|
||||
// Get from database
|
||||
m := model.Select("__yao.dsl")
|
||||
|
||||
// Check if the dsl exists
|
||||
rows, err := m.Get(model.QueryParam{
|
||||
Wheres: []model.QueryWhere{
|
||||
{Column: "dsl_id", Value: id},
|
||||
{Column: "type", Value: db.Type},
|
||||
},
|
||||
Select: []interface{}{"id", "dsl_id"},
|
||||
Limit: 1,
|
||||
})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return len(rows) > 0, nil
|
||||
}
|
||||
164
dsl/io/db_test.go
Normal file
164
dsl/io/db_test.go
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
package io
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/yaoapp/yao/dsl/types"
|
||||
)
|
||||
|
||||
func TestDBNew(t *testing.T) {
|
||||
db := NewDB(types.TypeModel)
|
||||
dbImpl, ok := db.(*DB)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, types.TypeModel, dbImpl.Type)
|
||||
}
|
||||
|
||||
func TestDBCreate(t *testing.T) {
|
||||
prepare(t)
|
||||
db := NewDB(types.TypeModel)
|
||||
tc := NewTestCase()
|
||||
|
||||
err := db.Create(tc.CreateOptions())
|
||||
assert.Nil(t, err)
|
||||
|
||||
// Check if exists
|
||||
exists, err := db.Exists(tc.ID)
|
||||
assert.Nil(t, err)
|
||||
assert.True(t, exists)
|
||||
|
||||
// Create again should fail
|
||||
err = db.Create(tc.CreateOptions())
|
||||
assert.NotNil(t, err)
|
||||
}
|
||||
|
||||
func TestDBInspect(t *testing.T) {
|
||||
prepare(t)
|
||||
db := NewDB(types.TypeModel)
|
||||
tc := NewTestCase()
|
||||
|
||||
err := db.Create(tc.CreateOptions())
|
||||
assert.Nil(t, err)
|
||||
|
||||
info, exists, err := db.Inspect(tc.ID)
|
||||
assert.Nil(t, err)
|
||||
assert.True(t, exists)
|
||||
assert.True(t, tc.AssertInfo(info))
|
||||
}
|
||||
|
||||
func TestDBSource(t *testing.T) {
|
||||
prepare(t)
|
||||
db := NewDB(types.TypeModel)
|
||||
tc := NewTestCase()
|
||||
|
||||
err := db.Create(tc.CreateOptions())
|
||||
assert.Nil(t, err)
|
||||
|
||||
data, exists, err := db.Source(tc.ID)
|
||||
assert.Nil(t, err)
|
||||
assert.True(t, exists)
|
||||
assert.Equal(t, tc.Source, data)
|
||||
}
|
||||
|
||||
func TestDBList(t *testing.T) {
|
||||
prepare(t)
|
||||
db := NewDB(types.TypeModel)
|
||||
tc1 := NewTestCase()
|
||||
tc2 := NewTestCase()
|
||||
|
||||
// Create test files
|
||||
err := db.Create(tc1.CreateOptions())
|
||||
assert.Nil(t, err)
|
||||
|
||||
err = db.Create(tc2.CreateOptions())
|
||||
assert.Nil(t, err)
|
||||
|
||||
// List all
|
||||
list, err := db.List(&types.ListOptions{})
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, 2, len(list))
|
||||
|
||||
// List with tag
|
||||
list, err = db.List(tc1.ListOptions(false))
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, 1, len(list))
|
||||
if assert.Greater(t, len(list), 0, "List should not be empty") {
|
||||
assert.Equal(t, tc1.ID, list[0].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDBUpdate(t *testing.T) {
|
||||
prepare(t)
|
||||
db := NewDB(types.TypeModel)
|
||||
tc := NewTestCase()
|
||||
|
||||
err := db.Create(tc.CreateOptions())
|
||||
assert.Nil(t, err)
|
||||
|
||||
// Update source
|
||||
err = db.Update(tc.UpdateOptions())
|
||||
assert.Nil(t, err)
|
||||
|
||||
info, exists, err := db.Inspect(tc.ID)
|
||||
assert.Nil(t, err)
|
||||
assert.True(t, exists)
|
||||
assert.True(t, tc.AssertUpdatedInfo(info))
|
||||
|
||||
// Update info
|
||||
err = db.Update(tc.UpdateInfoOptions())
|
||||
assert.Nil(t, err)
|
||||
|
||||
info, exists, err = db.Inspect(tc.ID)
|
||||
assert.Nil(t, err)
|
||||
assert.True(t, exists)
|
||||
assert.True(t, tc.AssertUpdatedInfoViaInfo(info))
|
||||
}
|
||||
|
||||
func TestDBDelete(t *testing.T) {
|
||||
prepare(t)
|
||||
db := NewDB(types.TypeModel)
|
||||
tc := NewTestCase()
|
||||
|
||||
err := db.Create(tc.CreateOptions())
|
||||
assert.Nil(t, err)
|
||||
|
||||
err = db.Delete(tc.ID)
|
||||
assert.Nil(t, err)
|
||||
|
||||
exists, err := db.Exists(tc.ID)
|
||||
assert.Nil(t, err)
|
||||
assert.False(t, exists)
|
||||
}
|
||||
|
||||
func TestDBFlow(t *testing.T) {
|
||||
prepare(t)
|
||||
db := NewDB(types.TypeModel)
|
||||
tc := NewTestCase()
|
||||
|
||||
// Create
|
||||
err := db.Create(tc.CreateOptions())
|
||||
assert.Nil(t, err)
|
||||
|
||||
// Inspect
|
||||
info, exists, err := db.Inspect(tc.ID)
|
||||
assert.Nil(t, err)
|
||||
assert.True(t, exists)
|
||||
assert.True(t, tc.AssertInfo(info))
|
||||
|
||||
// Update
|
||||
err = db.Update(tc.UpdateOptions())
|
||||
assert.Nil(t, err)
|
||||
|
||||
info, exists, err = db.Inspect(tc.ID)
|
||||
assert.Nil(t, err)
|
||||
assert.True(t, exists)
|
||||
assert.True(t, tc.AssertUpdatedInfo(info))
|
||||
|
||||
// Delete
|
||||
err = db.Delete(tc.ID)
|
||||
assert.Nil(t, err)
|
||||
|
||||
exists, err = db.Exists(tc.ID)
|
||||
assert.Nil(t, err)
|
||||
assert.False(t, exists)
|
||||
}
|
||||
275
dsl/io/fs.go
Normal file
275
dsl/io/fs.go
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
package io
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
jsoniter "github.com/json-iterator/go"
|
||||
"github.com/yaoapp/gou/application"
|
||||
"github.com/yaoapp/yao/dsl/types"
|
||||
)
|
||||
|
||||
// FS is the fs io
|
||||
type FS struct {
|
||||
Type types.Type
|
||||
}
|
||||
|
||||
// NewFS create a new fs io
|
||||
func NewFS(typ types.Type) types.IO {
|
||||
return &FS{Type: typ}
|
||||
}
|
||||
|
||||
// Inspect get the info from the file
|
||||
func (fs *FS) Inspect(id string) (*types.Info, bool, error) {
|
||||
file := types.ToPath(fs.Type, id)
|
||||
exists, err := application.App.Exists(file)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if !exists {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
// Read the file
|
||||
data, err := application.App.Read(file)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
// Parse the source to extract metadata
|
||||
var sourceData map[string]interface{}
|
||||
err = application.Parse(file, data, &sourceData)
|
||||
if err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
|
||||
// Extract common fields from source
|
||||
var label, description string
|
||||
var tags []string
|
||||
var sort int
|
||||
|
||||
if v, ok := sourceData["label"]; ok {
|
||||
if s, ok := v.(string); ok {
|
||||
label = s
|
||||
}
|
||||
}
|
||||
|
||||
if v, ok := sourceData["description"]; ok {
|
||||
if s, ok := v.(string); ok {
|
||||
description = s
|
||||
}
|
||||
}
|
||||
|
||||
if v, ok := sourceData["tags"]; ok {
|
||||
if tagsList, ok := v.([]interface{}); ok {
|
||||
for _, tag := range tagsList {
|
||||
if s, ok := tag.(string); ok {
|
||||
tags = append(tags, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if v, ok := sourceData["sort"]; ok {
|
||||
if s, ok := v.(float64); ok {
|
||||
sort = int(s)
|
||||
}
|
||||
}
|
||||
|
||||
// Get file info for timestamps
|
||||
fileInfo, err := application.App.Info(file)
|
||||
if err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
|
||||
// Create Info structure with correct fields
|
||||
info := &types.Info{
|
||||
ID: id,
|
||||
Type: fs.Type,
|
||||
Label: label,
|
||||
Description: description,
|
||||
Tags: tags,
|
||||
Sort: sort,
|
||||
Path: file,
|
||||
Store: types.StoreTypeFile,
|
||||
Readonly: false,
|
||||
Builtin: false,
|
||||
Status: types.StatusLoading,
|
||||
Mtime: fileInfo.ModTime(),
|
||||
Ctime: fileInfo.ModTime(),
|
||||
}
|
||||
|
||||
return info, true, nil
|
||||
}
|
||||
|
||||
// Source get the source from the file
|
||||
func (fs *FS) Source(id string) (string, bool, error) {
|
||||
path := types.ToPath(fs.Type, id)
|
||||
exists, err := application.App.Exists(path)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
if !exists {
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
// Read the file
|
||||
data, err := application.App.Read(path)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
return string(data), true, nil
|
||||
}
|
||||
|
||||
// List get the list from the path
|
||||
func (fs *FS) List(options *types.ListOptions) ([]*types.Info, error) {
|
||||
root, exts := types.TypeRootAndExts(fs.Type)
|
||||
var infos []*types.Info = []*types.Info{}
|
||||
patterns := []string{}
|
||||
for _, ext := range exts {
|
||||
patterns = append(patterns, "*"+ext)
|
||||
}
|
||||
var errs []error
|
||||
err := application.App.Walk(root, func(root, file string, isdir bool) error {
|
||||
if isdir {
|
||||
return nil
|
||||
}
|
||||
id := types.WithTypeToID(fs.Type, file)
|
||||
info, _, err := fs.Inspect(id)
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Filter by options
|
||||
if len(options.Tags) > 0 {
|
||||
if len(info.Tags) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, tag := range options.Tags {
|
||||
for _, t := range info.Tags {
|
||||
if t == tag {
|
||||
if options.Source {
|
||||
source, _, err := fs.Source(id)
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
return nil
|
||||
}
|
||||
info.Source = source
|
||||
}
|
||||
infos = append(infos, info)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add to the list
|
||||
if options.Source {
|
||||
source, _, err := fs.Source(id)
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
return nil
|
||||
}
|
||||
info.Source = source
|
||||
}
|
||||
infos = append(infos, info)
|
||||
return err
|
||||
}, patterns...)
|
||||
|
||||
return infos, err
|
||||
}
|
||||
|
||||
// Create create the file
|
||||
func (fs *FS) Create(options *types.CreateOptions) error {
|
||||
|
||||
path := types.ToPath(fs.Type, options.ID)
|
||||
|
||||
// Check if the file is a directory
|
||||
exists, err := application.App.Exists(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if exists {
|
||||
return fmt.Errorf("%v %s already exists", fs.Type, options.ID)
|
||||
}
|
||||
|
||||
// Create the file
|
||||
return application.App.Write(path, []byte(options.Source))
|
||||
}
|
||||
|
||||
// Update update the file
|
||||
func (fs *FS) Update(options *types.UpdateOptions) error {
|
||||
|
||||
// Validate the options
|
||||
if options.Source == "" && options.Info == nil {
|
||||
return fmt.Errorf("%v %s one of source or info is required", fs.Type, options.ID)
|
||||
}
|
||||
|
||||
path := types.ToPath(fs.Type, options.ID)
|
||||
|
||||
// Check if the file exists
|
||||
exists, err := application.App.Exists(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !exists {
|
||||
return fmt.Errorf("%v %s not found", fs.Type, options.ID)
|
||||
}
|
||||
|
||||
// Update source
|
||||
if options.Source != "" {
|
||||
return application.App.Write(path, []byte(options.Source))
|
||||
}
|
||||
|
||||
// Update info
|
||||
var source map[string]interface{}
|
||||
data, err := application.App.Read(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = application.Parse(path, data, &source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Update the info
|
||||
source["id"] = options.ID
|
||||
source["label"] = options.Info.Label
|
||||
source["tags"] = options.Info.Tags
|
||||
source["description"] = options.Info.Description
|
||||
new, err := jsoniter.MarshalIndent(source, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return application.App.Write(path, []byte(new))
|
||||
}
|
||||
|
||||
// Delete delete the file
|
||||
func (fs *FS) Delete(id string) error {
|
||||
|
||||
path := types.ToPath(fs.Type, id)
|
||||
|
||||
// Check if the file is a directory
|
||||
exists, err := application.App.Exists(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !exists {
|
||||
return fmt.Errorf("%v %s not found", fs.Type, id)
|
||||
}
|
||||
|
||||
// Delete the file
|
||||
return application.App.Remove(path)
|
||||
}
|
||||
|
||||
// Exists check if the file exists
|
||||
func (fs *FS) Exists(id string) (bool, error) {
|
||||
path := types.ToPath(fs.Type, id)
|
||||
return application.App.Exists(path)
|
||||
}
|
||||
221
dsl/io/fs_test.go
Normal file
221
dsl/io/fs_test.go
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
package io
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/yaoapp/gou/application"
|
||||
"github.com/yaoapp/yao/dsl/types"
|
||||
)
|
||||
|
||||
func prepare(t *testing.T) {
|
||||
root := os.Getenv("YAO_TEST_APPLICATION")
|
||||
if root == "" {
|
||||
t.Fatal("YAO_TEST_APPLICATION environment variable is not set")
|
||||
}
|
||||
|
||||
// Create models directory if it doesn't exist
|
||||
modelsDir := filepath.Join(root, "models")
|
||||
if err := os.MkdirAll(modelsDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Clean test files
|
||||
files, err := os.ReadDir(modelsDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Remove test files
|
||||
for _, file := range files {
|
||||
if !file.IsDir() && strings.HasPrefix(file.Name(), "test_") && strings.HasSuffix(file.Name(), ".mod.yao") {
|
||||
path := filepath.Join(modelsDir, file.Name())
|
||||
if err := os.Remove(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean test data from database
|
||||
err = cleanTestData()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
app, err := application.OpenFromDisk(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
application.App = app
|
||||
}
|
||||
|
||||
func TestFSNew(t *testing.T) {
|
||||
fs := NewFS(types.TypeModel)
|
||||
fsImpl, ok := fs.(*FS)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, types.TypeModel, fsImpl.Type)
|
||||
}
|
||||
|
||||
func TestFSCreate(t *testing.T) {
|
||||
prepare(t)
|
||||
fs := NewFS(types.TypeModel)
|
||||
tc := NewTestCase()
|
||||
|
||||
err := fs.Create(tc.CreateOptions())
|
||||
assert.Nil(t, err)
|
||||
|
||||
// Check if exists
|
||||
exists, err := fs.Exists(tc.ID)
|
||||
assert.Nil(t, err)
|
||||
assert.True(t, exists)
|
||||
|
||||
// Create again should fail
|
||||
err = fs.Create(tc.CreateOptions())
|
||||
assert.NotNil(t, err)
|
||||
}
|
||||
|
||||
func TestFSInspect(t *testing.T) {
|
||||
prepare(t)
|
||||
fs := NewFS(types.TypeModel)
|
||||
tc := NewTestCase()
|
||||
|
||||
err := fs.Create(tc.CreateOptions())
|
||||
assert.Nil(t, err)
|
||||
|
||||
info, exists, err := fs.Inspect(tc.ID)
|
||||
assert.Nil(t, err)
|
||||
assert.True(t, exists)
|
||||
assert.True(t, tc.AssertInfo(info))
|
||||
}
|
||||
|
||||
func TestFSSource(t *testing.T) {
|
||||
prepare(t)
|
||||
fs := NewFS(types.TypeModel)
|
||||
tc := NewTestCase()
|
||||
|
||||
err := fs.Create(tc.CreateOptions())
|
||||
assert.Nil(t, err)
|
||||
|
||||
data, exists, err := fs.Source(tc.ID)
|
||||
assert.Nil(t, err)
|
||||
assert.True(t, exists)
|
||||
assert.Equal(t, tc.Source, data)
|
||||
}
|
||||
|
||||
func TestFSList(t *testing.T) {
|
||||
prepare(t)
|
||||
fs := NewFS(types.TypeModel)
|
||||
|
||||
// Get initial count
|
||||
initialList, err := fs.List(&types.ListOptions{})
|
||||
assert.Nil(t, err)
|
||||
initialCount := len(initialList)
|
||||
|
||||
tc1 := NewTestCase()
|
||||
tc2 := NewTestCase()
|
||||
|
||||
// Create test files
|
||||
err = fs.Create(tc1.CreateOptions())
|
||||
assert.Nil(t, err)
|
||||
|
||||
err = fs.Create(tc2.CreateOptions())
|
||||
assert.Nil(t, err)
|
||||
|
||||
// List all
|
||||
list, err := fs.List(&types.ListOptions{})
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, initialCount+2, len(list))
|
||||
|
||||
// List with tag - should return both files since tags are OR relationship
|
||||
list, err = fs.List(tc1.ListOptions(false))
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, 2, len(list))
|
||||
// Verify both files are in the results
|
||||
found := false
|
||||
for _, info := range list {
|
||||
if info.ID == tc1.ID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "Should find tc1's file in results")
|
||||
}
|
||||
|
||||
func TestFSUpdate(t *testing.T) {
|
||||
prepare(t)
|
||||
fs := NewFS(types.TypeModel)
|
||||
tc := NewTestCase()
|
||||
|
||||
err := fs.Create(tc.CreateOptions())
|
||||
assert.Nil(t, err)
|
||||
|
||||
// Update source
|
||||
err = fs.Update(tc.UpdateOptions())
|
||||
assert.Nil(t, err)
|
||||
|
||||
info, exists, err := fs.Inspect(tc.ID)
|
||||
assert.Nil(t, err)
|
||||
assert.True(t, exists)
|
||||
assert.True(t, tc.AssertUpdatedInfo(info))
|
||||
|
||||
// Update info
|
||||
err = fs.Update(tc.UpdateInfoOptions())
|
||||
assert.Nil(t, err)
|
||||
|
||||
info, exists, err = fs.Inspect(tc.ID)
|
||||
assert.Nil(t, err)
|
||||
assert.True(t, exists)
|
||||
assert.True(t, tc.AssertUpdatedInfoViaInfo(info))
|
||||
}
|
||||
|
||||
func TestFSDelete(t *testing.T) {
|
||||
prepare(t)
|
||||
fs := NewFS(types.TypeModel)
|
||||
tc := NewTestCase()
|
||||
|
||||
err := fs.Create(tc.CreateOptions())
|
||||
assert.Nil(t, err)
|
||||
|
||||
err = fs.Delete(tc.ID)
|
||||
assert.Nil(t, err)
|
||||
|
||||
exists, err := fs.Exists(tc.ID)
|
||||
assert.Nil(t, err)
|
||||
assert.False(t, exists)
|
||||
}
|
||||
|
||||
func TestFSFlow(t *testing.T) {
|
||||
prepare(t)
|
||||
fs := NewFS(types.TypeModel)
|
||||
tc := NewTestCase()
|
||||
|
||||
// Create
|
||||
err := fs.Create(tc.CreateOptions())
|
||||
assert.Nil(t, err)
|
||||
|
||||
// Inspect
|
||||
info, exists, err := fs.Inspect(tc.ID)
|
||||
assert.Nil(t, err)
|
||||
assert.True(t, exists)
|
||||
assert.True(t, tc.AssertInfo(info))
|
||||
|
||||
// Update
|
||||
err = fs.Update(tc.UpdateOptions())
|
||||
assert.Nil(t, err)
|
||||
|
||||
info, exists, err = fs.Inspect(tc.ID)
|
||||
assert.Nil(t, err)
|
||||
assert.True(t, exists)
|
||||
assert.True(t, tc.AssertUpdatedInfo(info))
|
||||
|
||||
// Delete
|
||||
err = fs.Delete(tc.ID)
|
||||
assert.Nil(t, err)
|
||||
|
||||
exists, err = fs.Exists(tc.ID)
|
||||
assert.Nil(t, err)
|
||||
assert.False(t, exists)
|
||||
}
|
||||
51
dsl/io/utils.go
Normal file
51
dsl/io/utils.go
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
package io
|
||||
|
||||
import "time"
|
||||
|
||||
// toBool converts various types to boolean
|
||||
func toBool(v interface{}) bool {
|
||||
if v == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
switch val := v.(type) {
|
||||
case bool:
|
||||
return val
|
||||
case int:
|
||||
return val == 1
|
||||
case int64:
|
||||
return val == 1
|
||||
case float64:
|
||||
return val == 1
|
||||
case string:
|
||||
return val == "1" || val == "true"
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// toTime converts various time formats to RFC3339 string
|
||||
func toTime(v interface{}) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
switch val := v.(type) {
|
||||
case string:
|
||||
// Try common formats
|
||||
formats := []string{
|
||||
"2006-01-02 15:04:05", // SQLite format
|
||||
"2006-01-02T15:04:05Z07:00", // RFC3339 format
|
||||
"2006-01-02T15:04:05Z", // RFC3339 without timezone
|
||||
time.RFC3339,
|
||||
}
|
||||
for _, format := range formats {
|
||||
if t, err := time.Parse(format, val); err == nil {
|
||||
return t.UTC().Format(time.RFC3339) // Convert to UTC and format as RFC3339
|
||||
}
|
||||
}
|
||||
case time.Time:
|
||||
return val.UTC().Format(time.RFC3339) // Convert to UTC and format as RFC3339
|
||||
}
|
||||
return ""
|
||||
}
|
||||
325
dsl/mcp/cases_test.go
Normal file
325
dsl/mcp/cases_test.go
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
package mcp
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
jsoniter "github.com/json-iterator/go"
|
||||
"github.com/yaoapp/gou/application"
|
||||
"github.com/yaoapp/gou/model"
|
||||
"github.com/yaoapp/kun/log"
|
||||
"github.com/yaoapp/yao/config"
|
||||
"github.com/yaoapp/yao/data"
|
||||
"github.com/yaoapp/yao/dsl/types"
|
||||
"github.com/yaoapp/yao/test"
|
||||
)
|
||||
|
||||
// systemModels system models
|
||||
var systemModels = map[string]string{
|
||||
"__yao.dsl": "yao/models/dsl.mod.yao",
|
||||
}
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
// Setup
|
||||
test.Prepare(&testing.T{}, config.Conf)
|
||||
defer test.Clean()
|
||||
|
||||
// Load system models
|
||||
model.WithCrypt([]byte(fmt.Sprintf(`{"key":"%s"}`, config.Conf.DB.AESKey)), "AES")
|
||||
model.WithCrypt([]byte(`{}`), "PASSWORD")
|
||||
err := loadSystemModels()
|
||||
if err != nil {
|
||||
log.Error("Load system models error: %s", err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Load application
|
||||
root := os.Getenv("GOU_TEST_APPLICATION")
|
||||
app, err := application.OpenFromDisk(root) // Load app
|
||||
if err != nil {
|
||||
log.Error("Load application error: %s", err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
application.Load(app)
|
||||
|
||||
// Run tests
|
||||
code := m.Run()
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
// loadSystemModels load system models
|
||||
func loadSystemModels() error {
|
||||
for id, path := range systemModels {
|
||||
content, err := data.Read(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Parse model
|
||||
var data map[string]interface{}
|
||||
err = application.Parse(path, content, &data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Set prefix
|
||||
if table, ok := data["table"].(map[string]interface{}); ok {
|
||||
if name, ok := table["name"].(string); ok {
|
||||
table["name"] = "__yao_" + name
|
||||
content, err = jsoniter.Marshal(data)
|
||||
if err != nil {
|
||||
log.Error("failed to marshal model data: %v", err)
|
||||
return fmt.Errorf("failed to marshal model data: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load Model
|
||||
mod, err := model.LoadSource(content, id, path)
|
||||
if err != nil {
|
||||
log.Error("load system model %s error: %s", id, err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
// Drop table first
|
||||
err = mod.DropTable()
|
||||
if err != nil {
|
||||
log.Error("drop table error: %s", err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
// Auto migrate
|
||||
err = mod.Migrate(false, model.WithDonotInsertValues(true))
|
||||
if err != nil {
|
||||
log.Error("migrate system model %s error: %s", id, err.Error())
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestCase defines a single test case
|
||||
type TestCase struct {
|
||||
ID string
|
||||
Source string
|
||||
UpdatedSource string
|
||||
Tags []string
|
||||
Label string
|
||||
Description string
|
||||
}
|
||||
|
||||
// NewTestCase creates a new test case
|
||||
func NewTestCase() *TestCase {
|
||||
id := getTestID()
|
||||
return &TestCase{
|
||||
ID: id,
|
||||
Source: fmt.Sprintf(`{
|
||||
"name": "Test MCP Client %s",
|
||||
"label": "Test MCP Client",
|
||||
"description": "Test MCP Client Description",
|
||||
"tags": ["test_%s"],
|
||||
"transport": "stdio",
|
||||
"command": "echo",
|
||||
"arguments": ["hello", "world"],
|
||||
"env": {
|
||||
"MCP_TEST": "true"
|
||||
},
|
||||
"enable_sampling": true,
|
||||
"enable_roots": false,
|
||||
"timeout": "30s"
|
||||
}`, id, id),
|
||||
UpdatedSource: fmt.Sprintf(`{
|
||||
"name": "Updated MCP Client %s",
|
||||
"label": "Updated MCP Client",
|
||||
"description": "Updated MCP Client Description",
|
||||
"tags": ["test_%s", "updated"],
|
||||
"transport": "stdio",
|
||||
"command": "echo",
|
||||
"arguments": ["hello", "updated"],
|
||||
"env": {
|
||||
"MCP_TEST": "true",
|
||||
"MCP_UPDATED": "true"
|
||||
},
|
||||
"enable_sampling": false,
|
||||
"enable_roots": true,
|
||||
"timeout": "60s"
|
||||
}`, id, id),
|
||||
Tags: []string{fmt.Sprintf("test_%s", id)},
|
||||
Label: "Test MCP Client",
|
||||
Description: "Test MCP Client Description",
|
||||
}
|
||||
}
|
||||
|
||||
// NewHTTPTestCase creates a new HTTP test case
|
||||
func NewHTTPTestCase() *TestCase {
|
||||
id := getTestID()
|
||||
return &TestCase{
|
||||
ID: id,
|
||||
Source: fmt.Sprintf(`{
|
||||
"name": "Test HTTP MCP Client %s",
|
||||
"label": "Test HTTP MCP Client",
|
||||
"description": "Test HTTP MCP Client Description",
|
||||
"tags": ["test_%s", "http"],
|
||||
"transport": "http",
|
||||
"url": "http://localhost:8080/mcp",
|
||||
"authorization_token": "Bearer test-token",
|
||||
"enable_sampling": true,
|
||||
"enable_roots": true,
|
||||
"timeout": "30s"
|
||||
}`, id, id),
|
||||
UpdatedSource: fmt.Sprintf(`{
|
||||
"name": "Updated HTTP MCP Client %s",
|
||||
"label": "Updated HTTP MCP Client",
|
||||
"description": "Updated HTTP MCP Client Description",
|
||||
"tags": ["test_%s", "http", "updated"],
|
||||
"transport": "http",
|
||||
"url": "http://localhost:8080/mcp/v2",
|
||||
"authorization_token": "Bearer updated-token",
|
||||
"enable_sampling": false,
|
||||
"enable_roots": false,
|
||||
"timeout": "60s"
|
||||
}`, id, id),
|
||||
Tags: []string{fmt.Sprintf("test_%s", id), "http"},
|
||||
Label: "Test HTTP MCP Client",
|
||||
Description: "Test HTTP MCP Client Description",
|
||||
}
|
||||
}
|
||||
|
||||
// NewSSETestCase creates a new SSE test case
|
||||
func NewSSETestCase() *TestCase {
|
||||
id := getTestID()
|
||||
return &TestCase{
|
||||
ID: id,
|
||||
Source: fmt.Sprintf(`{
|
||||
"name": "Test SSE MCP Client %s",
|
||||
"label": "Test SSE MCP Client",
|
||||
"description": "Test SSE MCP Client Description",
|
||||
"tags": ["test_%s", "sse"],
|
||||
"transport": "sse",
|
||||
"url": "http://localhost:8080/sse",
|
||||
"authorization_token": "Bearer sse-token",
|
||||
"enable_sampling": true,
|
||||
"enable_elicitation": true,
|
||||
"timeout": "45s"
|
||||
}`, id, id),
|
||||
UpdatedSource: fmt.Sprintf(`{
|
||||
"name": "Updated SSE MCP Client %s",
|
||||
"label": "Updated SSE MCP Client",
|
||||
"description": "Updated SSE MCP Client Description",
|
||||
"tags": ["test_%s", "sse", "updated"],
|
||||
"transport": "sse",
|
||||
"url": "http://localhost:8080/sse/v2",
|
||||
"authorization_token": "Bearer updated-sse-token",
|
||||
"enable_sampling": false,
|
||||
"enable_elicitation": false,
|
||||
"timeout": "90s"
|
||||
}`, id, id),
|
||||
Tags: []string{fmt.Sprintf("test_%s", id), "sse"},
|
||||
Label: "Test SSE MCP Client",
|
||||
Description: "Test SSE MCP Client Description",
|
||||
}
|
||||
}
|
||||
|
||||
// getTestID generates a unique test ID
|
||||
func getTestID() string {
|
||||
return fmt.Sprintf("test_%d", time.Now().UnixNano())
|
||||
}
|
||||
|
||||
// CreateOptions returns creation options
|
||||
func (tc *TestCase) CreateOptions() *types.CreateOptions {
|
||||
return &types.CreateOptions{
|
||||
ID: tc.ID,
|
||||
Source: tc.Source,
|
||||
}
|
||||
}
|
||||
|
||||
// LoadOptions returns load options
|
||||
func (tc *TestCase) LoadOptions() *types.LoadOptions {
|
||||
return &types.LoadOptions{
|
||||
ID: tc.ID,
|
||||
Source: tc.Source,
|
||||
}
|
||||
}
|
||||
|
||||
// UnloadOptions returns unload options
|
||||
func (tc *TestCase) UnloadOptions() *types.UnloadOptions {
|
||||
return &types.UnloadOptions{
|
||||
ID: tc.ID,
|
||||
}
|
||||
}
|
||||
|
||||
// ReloadOptions returns reload options
|
||||
func (tc *TestCase) ReloadOptions() *types.ReloadOptions {
|
||||
return &types.ReloadOptions{
|
||||
ID: tc.ID,
|
||||
Source: tc.UpdatedSource,
|
||||
}
|
||||
}
|
||||
|
||||
// AssertInfo verifies if the information is correct
|
||||
func (tc *TestCase) AssertInfo(info *types.Info) bool {
|
||||
if info == nil {
|
||||
return false
|
||||
}
|
||||
return info.ID == tc.ID &&
|
||||
info.Type == types.TypeMCPClient &&
|
||||
info.Label == tc.Label &&
|
||||
len(info.Tags) == len(tc.Tags) &&
|
||||
info.Description == tc.Description &&
|
||||
!info.Readonly &&
|
||||
!info.Builtin &&
|
||||
!info.Mtime.IsZero() &&
|
||||
!info.Ctime.IsZero()
|
||||
}
|
||||
|
||||
// AssertUpdatedInfo verifies if the updated information is correct
|
||||
func (tc *TestCase) AssertUpdatedInfo(info *types.Info) bool {
|
||||
if info == nil {
|
||||
return false
|
||||
}
|
||||
expectedTags := append(tc.Tags, "updated")
|
||||
return info.ID == tc.ID &&
|
||||
info.Type == types.TypeMCPClient &&
|
||||
info.Label == "Updated MCP Client" &&
|
||||
len(info.Tags) == len(expectedTags) &&
|
||||
info.Description == "Updated MCP Client Description" &&
|
||||
!info.Readonly &&
|
||||
!info.Builtin &&
|
||||
!info.Mtime.IsZero() &&
|
||||
!info.Ctime.IsZero()
|
||||
}
|
||||
|
||||
// AssertHTTPInfo verifies if the HTTP client information is correct
|
||||
func (tc *TestCase) AssertHTTPInfo(info *types.Info) bool {
|
||||
if info == nil {
|
||||
return false
|
||||
}
|
||||
return info.ID == tc.ID &&
|
||||
info.Type == types.TypeMCPClient &&
|
||||
info.Label == "Test HTTP MCP Client" &&
|
||||
len(info.Tags) == 2 && // test_xxx and http
|
||||
info.Description == "Test HTTP MCP Client Description" &&
|
||||
!info.Readonly &&
|
||||
!info.Builtin &&
|
||||
!info.Mtime.IsZero() &&
|
||||
!info.Ctime.IsZero()
|
||||
}
|
||||
|
||||
// AssertSSEInfo verifies if the SSE client information is correct
|
||||
func (tc *TestCase) AssertSSEInfo(info *types.Info) bool {
|
||||
if info == nil {
|
||||
return false
|
||||
}
|
||||
return info.ID == tc.ID &&
|
||||
info.Type == types.TypeMCPClient &&
|
||||
info.Label == "Test SSE MCP Client" &&
|
||||
len(info.Tags) == 2 && // test_xxx and sse
|
||||
info.Description == "Test SSE MCP Client Description" &&
|
||||
!info.Readonly &&
|
||||
!info.Builtin &&
|
||||
!info.Mtime.IsZero() &&
|
||||
!info.Ctime.IsZero()
|
||||
}
|
||||
184
dsl/mcp/client.go
Normal file
184
dsl/mcp/client.go
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
goumcp "github.com/yaoapp/gou/mcp"
|
||||
"github.com/yaoapp/yao/dsl/types"
|
||||
)
|
||||
|
||||
// YaoMCPClient is the MCP client DSL manager
|
||||
type YaoMCPClient struct {
|
||||
root string // The relative path of the MCP client DSL
|
||||
fs types.IO // The file system IO interface
|
||||
db types.IO // The database IO interface
|
||||
}
|
||||
|
||||
// NewClient returns a new MCP client DSL manager
|
||||
func NewClient(root string, fs types.IO, db types.IO) types.Manager {
|
||||
return &YaoMCPClient{root: root, fs: fs, db: db}
|
||||
}
|
||||
|
||||
// Loaded return all loaded DSLs
|
||||
func (client *YaoMCPClient) Loaded(ctx context.Context) (map[string]*types.Info, error) {
|
||||
infos := map[string]*types.Info{}
|
||||
|
||||
// Get all loaded MCP clients
|
||||
clientIDs := goumcp.ListClients()
|
||||
|
||||
for _, id := range clientIDs {
|
||||
// Get the client
|
||||
mcpClient, err := goumcp.Select(id)
|
||||
if err != nil {
|
||||
continue // Skip if client not found
|
||||
}
|
||||
|
||||
// Get meta info from the client
|
||||
meta := mcpClient.GetMetaInfo()
|
||||
|
||||
infos[id] = &types.Info{
|
||||
ID: id,
|
||||
Path: types.ToPath(types.TypeMCPClient, id),
|
||||
Type: types.TypeMCPClient,
|
||||
Label: meta.Label,
|
||||
Sort: meta.Sort,
|
||||
Description: meta.Description,
|
||||
Tags: meta.Tags,
|
||||
Readonly: meta.Readonly,
|
||||
Builtin: meta.Builtin,
|
||||
Mtime: meta.Mtime,
|
||||
Ctime: meta.Ctime,
|
||||
}
|
||||
}
|
||||
|
||||
return infos, nil
|
||||
}
|
||||
|
||||
// Load will unload the DSL first, then load the DSL from DB or file system
|
||||
func (client *YaoMCPClient) Load(ctx context.Context, options *types.LoadOptions) error {
|
||||
if options == nil {
|
||||
return fmt.Errorf("load options is required")
|
||||
}
|
||||
|
||||
if options.ID == "" {
|
||||
return fmt.Errorf("load options id is required")
|
||||
}
|
||||
|
||||
var err error
|
||||
|
||||
// Case 1: If Source is provided, use LoadClientSource
|
||||
if options.Source != "" {
|
||||
_, err = goumcp.LoadClientSource(options.Source, options.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else if options.Path != "" && options.Store == types.StoreTypeFile {
|
||||
// Case 2: If Path is provided and Store is file, use LoadClient with Path
|
||||
_, err = goumcp.LoadClient(options.Path, options.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else if options.Store == types.StoreTypeDB {
|
||||
// Case 3: If Store is db, get Source from DB first
|
||||
if client.db == nil {
|
||||
return fmt.Errorf("db io is required for store type db")
|
||||
}
|
||||
source, exists, err := client.db.Source(options.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
return fmt.Errorf("mcp client %s not found in database", options.ID)
|
||||
}
|
||||
_, err = goumcp.LoadClientSource(source, options.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
// Case 4: Default case, use LoadClient with ID
|
||||
path := types.ToPath(types.TypeMCPClient, options.ID)
|
||||
_, err = goumcp.LoadClient(path, options.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Unload will unload the DSL from memory
|
||||
func (client *YaoMCPClient) Unload(ctx context.Context, options *types.UnloadOptions) error {
|
||||
if options == nil {
|
||||
return fmt.Errorf("unload options is required")
|
||||
}
|
||||
|
||||
if options.ID == "" {
|
||||
return fmt.Errorf("unload options id is required")
|
||||
}
|
||||
|
||||
// Use the UnloadClient function from gou/mcp package
|
||||
goumcp.UnloadClient(options.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reload will unload the DSL first, then reload the DSL from DB or file system
|
||||
func (client *YaoMCPClient) Reload(ctx context.Context, options *types.ReloadOptions) error {
|
||||
if options == nil {
|
||||
return fmt.Errorf("reload options is required")
|
||||
}
|
||||
|
||||
if options.ID == "" {
|
||||
return fmt.Errorf("reload options id is required")
|
||||
}
|
||||
|
||||
// First unload
|
||||
goumcp.UnloadClient(options.ID)
|
||||
|
||||
// Then load
|
||||
var err error
|
||||
if options.Source != "" {
|
||||
_, err = goumcp.LoadClientSource(options.Source, options.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else if options.Path != "" && options.Store == types.StoreTypeFile {
|
||||
_, err = goumcp.LoadClient(options.Path, options.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else if options.Store == types.StoreTypeDB {
|
||||
if client.db == nil {
|
||||
return fmt.Errorf("db io is required for store type db")
|
||||
}
|
||||
source, exists, err := client.db.Source(options.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
return fmt.Errorf("mcp client %s not found in database", options.ID)
|
||||
}
|
||||
_, err = goumcp.LoadClientSource(source, options.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
path := types.ToPath(types.TypeMCPClient, options.ID)
|
||||
_, err = goumcp.LoadClient(path, options.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate will validate the DSL from source
|
||||
func (client *YaoMCPClient) Validate(ctx context.Context, source string) (bool, []types.LintMessage) {
|
||||
return true, []types.LintMessage{}
|
||||
}
|
||||
|
||||
// Execute will execute the DSL
|
||||
func (client *YaoMCPClient) Execute(ctx context.Context, id string, method string, args ...any) (any, error) {
|
||||
return nil, fmt.Errorf("Not implemented")
|
||||
}
|
||||
330
dsl/mcp/client_test.go
Normal file
330
dsl/mcp/client_test.go
Normal file
|
|
@ -0,0 +1,330 @@
|
|||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/yaoapp/yao/dsl/io"
|
||||
"github.com/yaoapp/yao/dsl/types"
|
||||
)
|
||||
|
||||
func TestMCPClientLoad(t *testing.T) {
|
||||
testCase := NewTestCase()
|
||||
fsio := io.NewFS(types.TypeMCPClient)
|
||||
dbio := io.NewDB(types.TypeMCPClient)
|
||||
manager := NewClient("mcps", fsio, dbio)
|
||||
|
||||
// Test Load with nil options
|
||||
err := manager.Load(context.Background(), nil)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "load options is required")
|
||||
|
||||
// Test Load with empty ID
|
||||
err = manager.Load(context.Background(), &types.LoadOptions{})
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "load options id is required")
|
||||
|
||||
// Test Load with Source
|
||||
err = manager.Load(context.Background(), testCase.LoadOptions())
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Test Load from filesystem
|
||||
err = fsio.Create(testCase.CreateOptions())
|
||||
assert.NoError(t, err)
|
||||
|
||||
path := types.ToPath(types.TypeMCPClient, testCase.ID)
|
||||
err = manager.Load(context.Background(), &types.LoadOptions{
|
||||
ID: testCase.ID,
|
||||
Path: path,
|
||||
Store: types.StoreTypeFile,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Test Load from database
|
||||
err = dbio.Create(testCase.CreateOptions())
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = manager.Load(context.Background(), &types.LoadOptions{
|
||||
ID: testCase.ID,
|
||||
Store: types.StoreTypeDB,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Clean up
|
||||
err = fsio.Delete(testCase.ID)
|
||||
assert.NoError(t, err)
|
||||
err = dbio.Delete(testCase.ID)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestMCPClientUnload(t *testing.T) {
|
||||
testCase := NewTestCase()
|
||||
fsio := io.NewFS(types.TypeMCPClient)
|
||||
dbio := io.NewDB(types.TypeMCPClient)
|
||||
manager := NewClient("mcps", fsio, dbio)
|
||||
|
||||
// Test Unload with nil options
|
||||
err := manager.Unload(context.Background(), nil)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "unload options is required")
|
||||
|
||||
// Test Unload with empty ID
|
||||
err = manager.Unload(context.Background(), &types.UnloadOptions{})
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "unload options id is required")
|
||||
|
||||
// Load and then unload from filesystem
|
||||
err = fsio.Create(testCase.CreateOptions())
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = manager.Load(context.Background(), &types.LoadOptions{
|
||||
ID: testCase.ID,
|
||||
Store: types.StoreTypeFile,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = manager.Unload(context.Background(), testCase.UnloadOptions())
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Clean up
|
||||
err = fsio.Delete(testCase.ID)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestMCPClientReload(t *testing.T) {
|
||||
testCase := NewTestCase()
|
||||
fsio := io.NewFS(types.TypeMCPClient)
|
||||
dbio := io.NewDB(types.TypeMCPClient)
|
||||
manager := NewClient("mcps", fsio, dbio)
|
||||
|
||||
// Test Reload with nil options
|
||||
err := manager.Reload(context.Background(), nil)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "reload options is required")
|
||||
|
||||
// Test Reload with empty ID
|
||||
err = manager.Reload(context.Background(), &types.ReloadOptions{})
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "reload options id is required")
|
||||
|
||||
// Load and then reload from filesystem
|
||||
err = fsio.Create(testCase.CreateOptions())
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = manager.Load(context.Background(), &types.LoadOptions{
|
||||
ID: testCase.ID,
|
||||
Store: types.StoreTypeFile,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = manager.Reload(context.Background(), testCase.ReloadOptions())
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Clean up
|
||||
err = fsio.Delete(testCase.ID)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestMCPClientLoaded(t *testing.T) {
|
||||
testCase := NewTestCase()
|
||||
fsio := io.NewFS(types.TypeMCPClient)
|
||||
dbio := io.NewDB(types.TypeMCPClient)
|
||||
manager := NewClient("mcps", fsio, dbio)
|
||||
|
||||
// Load from filesystem
|
||||
err := fsio.Create(testCase.CreateOptions())
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = manager.Load(context.Background(), &types.LoadOptions{
|
||||
ID: testCase.ID,
|
||||
Store: types.StoreTypeFile,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Test Loaded
|
||||
infos, err := manager.Loaded(context.Background())
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, infos)
|
||||
assert.Contains(t, infos, testCase.ID)
|
||||
|
||||
// Verify metadata fields
|
||||
fsInfo := infos[testCase.ID]
|
||||
assert.Equal(t, testCase.ID, fsInfo.ID)
|
||||
assert.Equal(t, types.TypeMCPClient, fsInfo.Type)
|
||||
assert.Equal(t, testCase.Label, fsInfo.Label)
|
||||
assert.Equal(t, testCase.Description, fsInfo.Description)
|
||||
assert.ElementsMatch(t, testCase.Tags, fsInfo.Tags)
|
||||
assert.False(t, fsInfo.Readonly)
|
||||
assert.False(t, fsInfo.Builtin)
|
||||
|
||||
// Clean up
|
||||
err = fsio.Delete(testCase.ID)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestMCPClientValidate(t *testing.T) {
|
||||
manager := NewClient("mcps", nil, nil)
|
||||
|
||||
// Test Validate
|
||||
valid, messages := manager.Validate(context.Background(), "test source")
|
||||
assert.True(t, valid)
|
||||
assert.Empty(t, messages)
|
||||
}
|
||||
|
||||
func TestMCPClientExecute(t *testing.T) {
|
||||
manager := NewClient("mcps", nil, nil)
|
||||
|
||||
// Test Execute
|
||||
result, err := manager.Execute(context.Background(), "test_id", "test_method")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "Not implemented")
|
||||
assert.Nil(t, result)
|
||||
}
|
||||
|
||||
func TestMCPClientHTTPLoad(t *testing.T) {
|
||||
testCase := NewHTTPTestCase()
|
||||
fsio := io.NewFS(types.TypeMCPClient)
|
||||
dbio := io.NewDB(types.TypeMCPClient)
|
||||
manager := NewClient("mcps", fsio, dbio)
|
||||
|
||||
// Test Load with HTTP Source
|
||||
err := manager.Load(context.Background(), testCase.LoadOptions())
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Test Loaded
|
||||
infos, err := manager.Loaded(context.Background())
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, infos)
|
||||
assert.Contains(t, infos, testCase.ID)
|
||||
|
||||
// Verify HTTP metadata fields
|
||||
httpInfo := infos[testCase.ID]
|
||||
assert.Equal(t, testCase.ID, httpInfo.ID)
|
||||
assert.Equal(t, types.TypeMCPClient, httpInfo.Type)
|
||||
assert.Equal(t, "Test HTTP MCP Client", httpInfo.Label)
|
||||
assert.Equal(t, "Test HTTP MCP Client Description", httpInfo.Description)
|
||||
assert.Contains(t, httpInfo.Tags, "http")
|
||||
assert.False(t, httpInfo.Readonly)
|
||||
assert.False(t, httpInfo.Builtin)
|
||||
|
||||
// Test Unload
|
||||
err = manager.Unload(context.Background(), testCase.UnloadOptions())
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestMCPClientSSELoad(t *testing.T) {
|
||||
testCase := NewSSETestCase()
|
||||
fsio := io.NewFS(types.TypeMCPClient)
|
||||
dbio := io.NewDB(types.TypeMCPClient)
|
||||
manager := NewClient("mcps", fsio, dbio)
|
||||
|
||||
// Test Load with SSE Source
|
||||
err := manager.Load(context.Background(), testCase.LoadOptions())
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Test Loaded
|
||||
infos, err := manager.Loaded(context.Background())
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, infos)
|
||||
assert.Contains(t, infos, testCase.ID)
|
||||
|
||||
// Verify SSE metadata fields
|
||||
sseInfo := infos[testCase.ID]
|
||||
assert.Equal(t, testCase.ID, sseInfo.ID)
|
||||
assert.Equal(t, types.TypeMCPClient, sseInfo.Type)
|
||||
assert.Equal(t, "Test SSE MCP Client", sseInfo.Label)
|
||||
assert.Equal(t, "Test SSE MCP Client Description", sseInfo.Description)
|
||||
assert.Contains(t, sseInfo.Tags, "sse")
|
||||
assert.False(t, sseInfo.Readonly)
|
||||
assert.False(t, sseInfo.Builtin)
|
||||
|
||||
// Test Unload
|
||||
err = manager.Unload(context.Background(), testCase.UnloadOptions())
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestMCPClientLoadWithDatabaseStore(t *testing.T) {
|
||||
testCase := NewTestCase()
|
||||
fsio := io.NewFS(types.TypeMCPClient)
|
||||
dbio := io.NewDB(types.TypeMCPClient)
|
||||
manager := NewClient("mcps", fsio, dbio)
|
||||
|
||||
// Create in database first
|
||||
err := dbio.Create(testCase.CreateOptions())
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Test Load from database
|
||||
err = manager.Load(context.Background(), &types.LoadOptions{
|
||||
ID: testCase.ID,
|
||||
Store: types.StoreTypeDB,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Test Loaded
|
||||
infos, err := manager.Loaded(context.Background())
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, infos)
|
||||
assert.Contains(t, infos, testCase.ID)
|
||||
|
||||
// Verify metadata fields
|
||||
dbInfo := infos[testCase.ID]
|
||||
assert.Equal(t, testCase.ID, dbInfo.ID)
|
||||
assert.Equal(t, types.TypeMCPClient, dbInfo.Type)
|
||||
assert.Equal(t, testCase.Label, dbInfo.Label)
|
||||
assert.Equal(t, testCase.Description, dbInfo.Description)
|
||||
assert.ElementsMatch(t, testCase.Tags, dbInfo.Tags)
|
||||
assert.False(t, dbInfo.Readonly)
|
||||
assert.False(t, dbInfo.Builtin)
|
||||
|
||||
// Test Reload from database
|
||||
err = manager.Reload(context.Background(), &types.ReloadOptions{
|
||||
ID: testCase.ID,
|
||||
Source: testCase.UpdatedSource,
|
||||
Store: types.StoreTypeDB,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Clean up
|
||||
err = dbio.Delete(testCase.ID)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestMCPClientLoadWithFileStore(t *testing.T) {
|
||||
testCase := NewTestCase()
|
||||
fsio := io.NewFS(types.TypeMCPClient)
|
||||
dbio := io.NewDB(types.TypeMCPClient)
|
||||
manager := NewClient("mcps", fsio, dbio)
|
||||
|
||||
// Create in file system first
|
||||
err := fsio.Create(testCase.CreateOptions())
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Test Load from file system with explicit path
|
||||
path := types.ToPath(types.TypeMCPClient, testCase.ID)
|
||||
err = manager.Load(context.Background(), &types.LoadOptions{
|
||||
ID: testCase.ID,
|
||||
Path: path,
|
||||
Store: types.StoreTypeFile,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Test Loaded
|
||||
infos, err := manager.Loaded(context.Background())
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, infos)
|
||||
assert.Contains(t, infos, testCase.ID)
|
||||
|
||||
// Test Reload from file system
|
||||
err = manager.Reload(context.Background(), &types.ReloadOptions{
|
||||
ID: testCase.ID,
|
||||
Path: path,
|
||||
Source: testCase.UpdatedSource,
|
||||
Store: types.StoreTypeFile,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Clean up
|
||||
err = fsio.Delete(testCase.ID)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
47
dsl/mcp/server.go
Normal file
47
dsl/mcp/server.go
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/yaoapp/yao/dsl/types"
|
||||
)
|
||||
|
||||
// YaoMCPServer is the MCP client DSL manager
|
||||
type YaoMCPServer struct {
|
||||
root string // The relative path of the MCP client DSL
|
||||
}
|
||||
|
||||
// NewServer returns a new MCP server DSL manager
|
||||
func NewServer(root string) types.Manager {
|
||||
return &YaoMCPServer{root: root}
|
||||
}
|
||||
|
||||
// Loaded return all loaded DSLs
|
||||
func (server *YaoMCPServer) Loaded(ctx context.Context) (map[string]*types.Info, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Load will unload the DSL first, then load the DSL from DB or file system
|
||||
func (server *YaoMCPServer) Load(ctx context.Context, options *types.LoadOptions) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Unload will unload the DSL from memory
|
||||
func (server *YaoMCPServer) Unload(ctx context.Context, options *types.UnloadOptions) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reload will unload the DSL first, then reload the DSL from DB or file system
|
||||
func (server *YaoMCPServer) Reload(ctx context.Context, options *types.ReloadOptions) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate will validate the DSL from source
|
||||
func (server *YaoMCPServer) Validate(ctx context.Context, source string) (bool, []types.LintMessage) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Execute will execute the DSL
|
||||
func (server *YaoMCPServer) Execute(ctx context.Context, id string, method string, args ...any) (any, error) {
|
||||
return nil, nil
|
||||
}
|
||||
244
dsl/model/cases_test.go
Normal file
244
dsl/model/cases_test.go
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
package model
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
jsoniter "github.com/json-iterator/go"
|
||||
"github.com/yaoapp/gou/application"
|
||||
"github.com/yaoapp/gou/model"
|
||||
"github.com/yaoapp/kun/log"
|
||||
"github.com/yaoapp/yao/config"
|
||||
"github.com/yaoapp/yao/data"
|
||||
"github.com/yaoapp/yao/dsl/types"
|
||||
"github.com/yaoapp/yao/test"
|
||||
)
|
||||
|
||||
// systemModels system models
|
||||
var systemModels = map[string]string{
|
||||
"__yao.dsl": "yao/models/dsl.mod.yao",
|
||||
}
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
// Setup
|
||||
test.Prepare(&testing.T{}, config.Conf)
|
||||
defer test.Clean()
|
||||
|
||||
// Load system models
|
||||
model.WithCrypt([]byte(fmt.Sprintf(`{"key":"%s"}`, config.Conf.DB.AESKey)), "AES")
|
||||
model.WithCrypt([]byte(`{}`), "PASSWORD")
|
||||
err := loadSystemModels()
|
||||
if err != nil {
|
||||
log.Error("Load system models error: %s", err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Run tests
|
||||
code := m.Run()
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
// loadSystemModels load system models
|
||||
func loadSystemModels() error {
|
||||
for id, path := range systemModels {
|
||||
content, err := data.Read(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Parse model
|
||||
var data map[string]interface{}
|
||||
err = application.Parse(path, content, &data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Set prefix
|
||||
if table, ok := data["table"].(map[string]interface{}); ok {
|
||||
if name, ok := table["name"].(string); ok {
|
||||
table["name"] = "__yao_" + name
|
||||
content, err = jsoniter.Marshal(data)
|
||||
if err != nil {
|
||||
log.Error("failed to marshal model data: %v", err)
|
||||
return fmt.Errorf("failed to marshal model data: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load Model
|
||||
mod, err := model.LoadSource(content, id, filepath.Join("__system", path))
|
||||
if err != nil {
|
||||
log.Error("load system model %s error: %s", id, err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
// Drop table first
|
||||
err = mod.DropTable()
|
||||
if err != nil {
|
||||
log.Error("drop table error: %s", err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
// Auto migrate
|
||||
err = mod.Migrate(false, model.WithDonotInsertValues(true))
|
||||
if err != nil {
|
||||
log.Error("migrate system model %s error: %s", id, err.Error())
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// cleanTestData cleans test data from database
|
||||
func cleanTestData() error {
|
||||
m := model.Select("__yao.dsl")
|
||||
err := m.DropTable()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = m.Migrate(false, model.WithDonotInsertValues(true))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// getTestID generates a unique test ID
|
||||
func getTestID() string {
|
||||
return fmt.Sprintf("test_%d", time.Now().UnixNano())
|
||||
}
|
||||
|
||||
// TestCase defines a single test case
|
||||
type TestCase struct {
|
||||
ID string
|
||||
Source string
|
||||
UpdatedSource string
|
||||
Tags []string
|
||||
Label string
|
||||
Description string
|
||||
}
|
||||
|
||||
// NewTestCase creates a new test case
|
||||
func NewTestCase() *TestCase {
|
||||
id := getTestID()
|
||||
return &TestCase{
|
||||
ID: id,
|
||||
Source: fmt.Sprintf(`{
|
||||
"name": "%s",
|
||||
"table": { "name": "%s", "comment": "Test User" },
|
||||
"columns": [
|
||||
{ "name": "id", "type": "ID" },
|
||||
{ "name": "name", "type": "string", "length": 80, "comment": "User Name", "index": true },
|
||||
{ "name": "status", "type": "enum", "option": ["active", "disabled"], "default": "active", "comment": "Status", "index": true }
|
||||
],
|
||||
"tags": ["test_%s"],
|
||||
"label": "Test Label",
|
||||
"description": "Test Description",
|
||||
"option": { "timestamps": true, "soft_deletes": true }
|
||||
}`, id, id, id),
|
||||
UpdatedSource: fmt.Sprintf(`{
|
||||
"name": "%s",
|
||||
"table": { "name": "%s", "comment": "Updated Test User" },
|
||||
"columns": [
|
||||
{ "name": "id", "type": "ID" },
|
||||
{ "name": "name", "type": "string", "length": 80, "comment": "User Name", "index": true },
|
||||
{ "name": "status", "type": "enum", "option": ["active", "disabled", "pending"], "default": "active", "comment": "Status", "index": true }
|
||||
],
|
||||
"tags": ["test_%s", "updated"],
|
||||
"label": "Updated Label",
|
||||
"description": "Updated Description",
|
||||
"option": { "timestamps": true, "soft_deletes": true }
|
||||
}`, id, id, id),
|
||||
Tags: []string{fmt.Sprintf("test_%s", id)},
|
||||
Label: "Test Label",
|
||||
Description: "Test Description",
|
||||
}
|
||||
}
|
||||
|
||||
// CreateOptions returns creation options
|
||||
func (tc *TestCase) CreateOptions() *types.CreateOptions {
|
||||
return &types.CreateOptions{
|
||||
ID: tc.ID,
|
||||
Source: tc.Source,
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateOptions returns update options
|
||||
func (tc *TestCase) UpdateOptions() *types.UpdateOptions {
|
||||
return &types.UpdateOptions{
|
||||
ID: tc.ID,
|
||||
Source: tc.UpdatedSource,
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateInfoOptions returns update info options
|
||||
func (tc *TestCase) UpdateInfoOptions() *types.UpdateOptions {
|
||||
return &types.UpdateOptions{
|
||||
ID: tc.ID,
|
||||
Info: &types.Info{
|
||||
Label: "Updated via Info",
|
||||
Tags: []string{"tag1", "info"},
|
||||
Description: "Updated via info field",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ListOptions returns list options
|
||||
func (tc *TestCase) ListOptions(withSource bool) *types.ListOptions {
|
||||
return &types.ListOptions{
|
||||
Source: withSource,
|
||||
Tags: tc.Tags,
|
||||
}
|
||||
}
|
||||
|
||||
// AssertInfo verifies if the information is correct
|
||||
func (tc *TestCase) AssertInfo(info *types.Info) bool {
|
||||
if info == nil {
|
||||
return false
|
||||
}
|
||||
return info.ID == tc.ID &&
|
||||
info.Type == types.TypeModel &&
|
||||
info.Label == tc.Label &&
|
||||
len(info.Tags) == len(tc.Tags) &&
|
||||
info.Description == tc.Description &&
|
||||
!info.Readonly &&
|
||||
!info.Builtin &&
|
||||
!info.Mtime.IsZero() &&
|
||||
!info.Ctime.IsZero()
|
||||
}
|
||||
|
||||
// AssertUpdatedInfo verifies if the updated information is correct
|
||||
func (tc *TestCase) AssertUpdatedInfo(info *types.Info) bool {
|
||||
if info == nil {
|
||||
return false
|
||||
}
|
||||
return info.ID == tc.ID &&
|
||||
info.Type == types.TypeModel &&
|
||||
info.Label == "Updated Label" &&
|
||||
len(info.Tags) == 2 &&
|
||||
info.Description == "Updated Description" &&
|
||||
!info.Readonly &&
|
||||
!info.Builtin &&
|
||||
!info.Mtime.IsZero() &&
|
||||
!info.Ctime.IsZero()
|
||||
}
|
||||
|
||||
// AssertUpdatedInfoViaInfo verifies if the information updated via Info is correct
|
||||
func (tc *TestCase) AssertUpdatedInfoViaInfo(info *types.Info) bool {
|
||||
if info == nil {
|
||||
return false
|
||||
}
|
||||
return info.ID == tc.ID &&
|
||||
info.Type == types.TypeModel &&
|
||||
info.Label == "Updated via Info" &&
|
||||
len(info.Tags) == 2 &&
|
||||
info.Description == "Updated via info field" &&
|
||||
!info.Readonly &&
|
||||
!info.Builtin &&
|
||||
!info.Mtime.IsZero() &&
|
||||
!info.Ctime.IsZero()
|
||||
}
|
||||
256
dsl/model/model.go
Normal file
256
dsl/model/model.go
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
package model
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/yaoapp/gou/model"
|
||||
"github.com/yaoapp/kun/exception"
|
||||
"github.com/yaoapp/yao/dsl/types"
|
||||
)
|
||||
|
||||
// YaoModel is the MCP client DSL manager
|
||||
type YaoModel struct {
|
||||
root string // The relative path of the model DSL
|
||||
fs types.IO // The file system IO interface
|
||||
db types.IO // The database IO interface
|
||||
}
|
||||
|
||||
// New returns a new connector DSL manager
|
||||
func New(root string, fs types.IO, db types.IO) types.Manager {
|
||||
return &YaoModel{root: root, fs: fs, db: db}
|
||||
}
|
||||
|
||||
// Loaded return all loaded DSLs
|
||||
func (m *YaoModel) Loaded(ctx context.Context) (map[string]*types.Info, error) {
|
||||
|
||||
infos := map[string]*types.Info{}
|
||||
for id, mod := range model.Models {
|
||||
meta := mod.GetMetaInfo()
|
||||
infos[id] = &types.Info{
|
||||
ID: id,
|
||||
Path: mod.File,
|
||||
Type: types.TypeModel,
|
||||
Label: meta.Label,
|
||||
Sort: meta.Sort,
|
||||
Description: meta.Description,
|
||||
Tags: meta.Tags,
|
||||
Readonly: meta.Readonly,
|
||||
Builtin: meta.Builtin,
|
||||
Mtime: meta.Mtime,
|
||||
Ctime: meta.Ctime,
|
||||
}
|
||||
}
|
||||
|
||||
return infos, nil
|
||||
}
|
||||
|
||||
// Load will unload the DSL first, then load the DSL from DB or file system
|
||||
func (m *YaoModel) Load(ctx context.Context, options *types.LoadOptions) error {
|
||||
|
||||
if options == nil {
|
||||
return fmt.Errorf("load options is required")
|
||||
}
|
||||
|
||||
if options.ID == "" {
|
||||
return fmt.Errorf("load options id is required")
|
||||
}
|
||||
|
||||
var opts map[string]interface{}
|
||||
if options.Options != nil {
|
||||
opts = options.Options
|
||||
}
|
||||
|
||||
var migration bool = false
|
||||
if v, ok := opts["migration"]; ok {
|
||||
migration = v.(bool)
|
||||
}
|
||||
|
||||
var reset bool = false
|
||||
if v, ok := opts["reset"]; ok {
|
||||
reset = v.(bool)
|
||||
}
|
||||
|
||||
var mod *model.Model
|
||||
var err error
|
||||
|
||||
// Case 1: If Source is provided, use LoadSource
|
||||
if options.Source != "" {
|
||||
mod, err = model.LoadSourceSync([]byte(options.Source), options.ID, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else if options.Path != "" && options.Store == "fs" {
|
||||
// Case 2: If Path is provided and Store is fs, use LoadSync with Path
|
||||
mod, err = model.LoadSync(options.Path, options.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else if options.Store == "db" {
|
||||
// Case 3: If Store is db, get Source from DB first
|
||||
if m.db == nil {
|
||||
return fmt.Errorf("db io is required for store type db")
|
||||
}
|
||||
source, exists, err := m.db.Source(options.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
return fmt.Errorf("model %s not found in database", options.ID)
|
||||
}
|
||||
mod, err = model.LoadSourceSync([]byte(source), options.ID, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
// Case 4: Default case, use LoadSync with ID
|
||||
path := types.ToPath(types.TypeModel, options.ID)
|
||||
mod, err = model.LoadSync(path, options.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if migration || reset {
|
||||
return mod.Migrate(reset, model.WithDonotInsertValues(true))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Unload will unload the DSL from memory
|
||||
func (m *YaoModel) Unload(ctx context.Context, options *types.UnloadOptions) error {
|
||||
|
||||
if options == nil {
|
||||
return fmt.Errorf("unload options is required")
|
||||
}
|
||||
|
||||
if options.ID == "" {
|
||||
return fmt.Errorf("unload options id is required")
|
||||
}
|
||||
|
||||
var opts map[string]interface{}
|
||||
if options.Options != nil {
|
||||
opts = options.Options
|
||||
}
|
||||
|
||||
var dropTable bool = false
|
||||
if v, ok := opts["dropTable"]; ok {
|
||||
dropTable = v.(bool)
|
||||
}
|
||||
|
||||
// Try to get model, handle panic
|
||||
var mod *model.Model
|
||||
var err error
|
||||
func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
if ex, ok := r.(exception.Exception); ok {
|
||||
if ex.Message == fmt.Sprintf("Model:%s; not found", options.ID) {
|
||||
err = fmt.Errorf("model %s not found", options.ID)
|
||||
return
|
||||
}
|
||||
}
|
||||
panic(r)
|
||||
}
|
||||
}()
|
||||
mod = model.Select(options.ID)
|
||||
}()
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if mod == nil {
|
||||
return fmt.Errorf("model %s not found", options.ID)
|
||||
}
|
||||
|
||||
if dropTable {
|
||||
return mod.DropTable()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reload will unload the DSL first, then reload the DSL from DB or file system
|
||||
func (m *YaoModel) Reload(ctx context.Context, options *types.ReloadOptions) error {
|
||||
|
||||
if options == nil {
|
||||
return fmt.Errorf("reload options is required")
|
||||
}
|
||||
|
||||
if options.ID == "" {
|
||||
return fmt.Errorf("reload options id is required")
|
||||
}
|
||||
|
||||
var opts map[string]interface{}
|
||||
if options.Options != nil {
|
||||
opts = options.Options
|
||||
}
|
||||
|
||||
var migrate bool = false
|
||||
if v, ok := opts["migrate"]; ok {
|
||||
migrate = v.(bool)
|
||||
}
|
||||
|
||||
var reset bool = false
|
||||
if v, ok := opts["reset"]; ok {
|
||||
reset = v.(bool)
|
||||
}
|
||||
|
||||
var mod *model.Model
|
||||
var err error
|
||||
|
||||
// Case 1: If Source is provided, use LoadSource
|
||||
if options.Source != "" {
|
||||
mod, err = model.LoadSourceSync([]byte(options.Source), options.ID, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else if options.Path != "" && options.Store == "fs" {
|
||||
// Case 2: If Path is provided and Store is fs, use LoadSync with Path
|
||||
mod, err = model.LoadSync(options.Path, options.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else if options.Store == "db" {
|
||||
// Case 3: If Store is db, get Source from DB first
|
||||
if m.db == nil {
|
||||
return fmt.Errorf("db io is required for store type db")
|
||||
}
|
||||
source, exists, err := m.db.Source(options.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
return fmt.Errorf("model %s not found in database", options.ID)
|
||||
}
|
||||
mod, err = model.LoadSourceSync([]byte(source), options.ID, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
// Case 4: Default case, use LoadSync with ID
|
||||
path := types.ToPath(types.TypeModel, options.ID)
|
||||
mod, err = model.LoadSync(path, options.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if migrate || reset {
|
||||
return mod.Migrate(reset, model.WithDonotInsertValues(true))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate will validate the DSL from source
|
||||
func (m *YaoModel) Validate(ctx context.Context, source string) (bool, []types.LintMessage) {
|
||||
return true, []types.LintMessage{}
|
||||
}
|
||||
|
||||
// Execute will execute the DSL
|
||||
func (m *YaoModel) Execute(ctx context.Context, id string, method string, args ...any) (any, error) {
|
||||
return nil, fmt.Errorf("Not implemented")
|
||||
}
|
||||
350
dsl/model/model_test.go
Normal file
350
dsl/model/model_test.go
Normal file
|
|
@ -0,0 +1,350 @@
|
|||
package model
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/yaoapp/yao/dsl/io"
|
||||
"github.com/yaoapp/yao/dsl/types"
|
||||
)
|
||||
|
||||
func TestModelLoad(t *testing.T) {
|
||||
testCase := NewTestCase()
|
||||
fsio := io.NewFS(types.TypeModel)
|
||||
dbio := io.NewDB(types.TypeModel)
|
||||
manager := New("", fsio, dbio)
|
||||
|
||||
// Test Load with nil options
|
||||
err := manager.Load(context.Background(), nil)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "load options is required")
|
||||
|
||||
// Test Load with empty ID
|
||||
err = manager.Load(context.Background(), &types.LoadOptions{})
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "load options id is required")
|
||||
|
||||
// Test Load with Source
|
||||
err = manager.Load(context.Background(), &types.LoadOptions{
|
||||
ID: testCase.ID,
|
||||
Source: testCase.Source,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Test Load from filesystem
|
||||
err = fsio.Create(&types.CreateOptions{
|
||||
ID: testCase.ID + "_fs",
|
||||
Source: testCase.Source,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
path := types.ToPath(types.TypeModel, testCase.ID+"_fs")
|
||||
err = manager.Load(context.Background(), &types.LoadOptions{
|
||||
ID: testCase.ID + "_fs",
|
||||
Path: path,
|
||||
Store: "fs",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Test Load from database
|
||||
err = dbio.Create(&types.CreateOptions{
|
||||
ID: testCase.ID + "_db",
|
||||
Source: testCase.Source,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = manager.Load(context.Background(), &types.LoadOptions{
|
||||
ID: testCase.ID + "_db",
|
||||
Store: "db",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Test Load with default path (should use filesystem)
|
||||
err = manager.Load(context.Background(), &types.LoadOptions{
|
||||
ID: testCase.ID + "_fs",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Test Load with migration
|
||||
err = manager.Load(context.Background(), &types.LoadOptions{
|
||||
ID: testCase.ID + "_fs",
|
||||
Options: map[string]interface{}{"migration": true},
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Test Load with reset
|
||||
err = manager.Load(context.Background(), &types.LoadOptions{
|
||||
ID: testCase.ID + "_fs",
|
||||
Options: map[string]interface{}{"reset": true},
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Clean up
|
||||
err = fsio.Delete(testCase.ID + "_fs")
|
||||
assert.NoError(t, err)
|
||||
err = dbio.Delete(testCase.ID + "_db")
|
||||
assert.NoError(t, err)
|
||||
err = cleanTestData()
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestModelLoadWithDB(t *testing.T) {
|
||||
testCase := NewTestCase()
|
||||
dbio := io.NewDB(types.TypeModel)
|
||||
manager := New("", nil, dbio)
|
||||
|
||||
// Create model in DB first
|
||||
err := dbio.Create(&types.CreateOptions{
|
||||
ID: testCase.ID,
|
||||
Source: testCase.Source,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Test Load with Store=db
|
||||
err = manager.Load(context.Background(), &types.LoadOptions{
|
||||
ID: testCase.ID,
|
||||
Store: "db",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Test Load non-existent model from DB
|
||||
err = manager.Load(context.Background(), &types.LoadOptions{
|
||||
ID: "non-existent",
|
||||
Store: "db",
|
||||
})
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "not found in database")
|
||||
|
||||
// Clean up
|
||||
err = dbio.Delete(testCase.ID)
|
||||
assert.NoError(t, err)
|
||||
err = cleanTestData()
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestModelUnload(t *testing.T) {
|
||||
testCase := NewTestCase()
|
||||
fsio := io.NewFS(types.TypeModel)
|
||||
dbio := io.NewDB(types.TypeModel)
|
||||
manager := New("", fsio, dbio)
|
||||
|
||||
// Test Unload with nil options
|
||||
err := manager.Unload(context.Background(), nil)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "unload options is required")
|
||||
|
||||
// Test Unload with empty ID
|
||||
err = manager.Unload(context.Background(), &types.UnloadOptions{})
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "unload options id is required")
|
||||
|
||||
// Test Unload non-existent model
|
||||
err = manager.Unload(context.Background(), &types.UnloadOptions{
|
||||
ID: "non-existent",
|
||||
})
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "model non-existent not found")
|
||||
|
||||
// Test Unload from filesystem
|
||||
err = fsio.Create(&types.CreateOptions{
|
||||
ID: testCase.ID + "_fs",
|
||||
Source: testCase.Source,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = manager.Load(context.Background(), &types.LoadOptions{
|
||||
ID: testCase.ID + "_fs",
|
||||
Store: "fs",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = manager.Unload(context.Background(), &types.UnloadOptions{
|
||||
ID: testCase.ID + "_fs",
|
||||
Options: map[string]interface{}{"dropTable": true},
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Test Unload from database
|
||||
err = dbio.Create(&types.CreateOptions{
|
||||
ID: testCase.ID + "_db",
|
||||
Source: testCase.Source,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = manager.Load(context.Background(), &types.LoadOptions{
|
||||
ID: testCase.ID + "_db",
|
||||
Store: "db",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = manager.Unload(context.Background(), &types.UnloadOptions{
|
||||
ID: testCase.ID + "_db",
|
||||
Options: map[string]interface{}{"dropTable": true},
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Clean up
|
||||
err = fsio.Delete(testCase.ID + "_fs")
|
||||
assert.NoError(t, err)
|
||||
err = dbio.Delete(testCase.ID + "_db")
|
||||
assert.NoError(t, err)
|
||||
err = cleanTestData()
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestModelReload(t *testing.T) {
|
||||
testCase := NewTestCase()
|
||||
fsio := io.NewFS(types.TypeModel)
|
||||
dbio := io.NewDB(types.TypeModel)
|
||||
manager := New("", fsio, dbio)
|
||||
|
||||
// Test Reload with nil options
|
||||
err := manager.Reload(context.Background(), nil)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "reload options is required")
|
||||
|
||||
// Test Reload with empty ID
|
||||
err = manager.Reload(context.Background(), &types.ReloadOptions{})
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "reload options id is required")
|
||||
|
||||
// Test Reload from filesystem
|
||||
err = fsio.Create(&types.CreateOptions{
|
||||
ID: testCase.ID + "_fs",
|
||||
Source: testCase.Source,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = manager.Load(context.Background(), &types.LoadOptions{
|
||||
ID: testCase.ID + "_fs",
|
||||
Store: "fs",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = manager.Reload(context.Background(), &types.ReloadOptions{
|
||||
ID: testCase.ID + "_fs",
|
||||
Store: "fs",
|
||||
Options: map[string]interface{}{"migrate": true},
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Test Reload from database
|
||||
err = dbio.Create(&types.CreateOptions{
|
||||
ID: testCase.ID + "_db",
|
||||
Source: testCase.Source,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = manager.Load(context.Background(), &types.LoadOptions{
|
||||
ID: testCase.ID + "_db",
|
||||
Store: "db",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = manager.Reload(context.Background(), &types.ReloadOptions{
|
||||
ID: testCase.ID + "_db",
|
||||
Store: "db",
|
||||
Options: map[string]interface{}{"migrate": true},
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Clean up
|
||||
err = fsio.Delete(testCase.ID + "_fs")
|
||||
assert.NoError(t, err)
|
||||
err = dbio.Delete(testCase.ID + "_db")
|
||||
assert.NoError(t, err)
|
||||
err = cleanTestData()
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestModelLoaded(t *testing.T) {
|
||||
testCase := NewTestCase()
|
||||
fsio := io.NewFS(types.TypeModel)
|
||||
dbio := io.NewDB(types.TypeModel)
|
||||
manager := New("", fsio, dbio)
|
||||
|
||||
// Test Load from filesystem
|
||||
err := fsio.Create(&types.CreateOptions{
|
||||
ID: testCase.ID + "_fs",
|
||||
Source: testCase.Source,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = manager.Load(context.Background(), &types.LoadOptions{
|
||||
ID: testCase.ID + "_fs",
|
||||
Store: "fs",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Test Load from database
|
||||
err = dbio.Create(&types.CreateOptions{
|
||||
ID: testCase.ID + "_db",
|
||||
Source: testCase.Source,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = manager.Load(context.Background(), &types.LoadOptions{
|
||||
ID: testCase.ID + "_db",
|
||||
Store: "db",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Test Loaded
|
||||
infos, err := manager.Loaded(context.Background())
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, infos)
|
||||
assert.Contains(t, infos, testCase.ID+"_fs")
|
||||
assert.Contains(t, infos, testCase.ID+"_db")
|
||||
|
||||
// Verify metadata fields for filesystem model
|
||||
fsInfo := infos[testCase.ID+"_fs"]
|
||||
assert.Equal(t, testCase.ID+"_fs", fsInfo.ID)
|
||||
assert.Equal(t, types.TypeModel, fsInfo.Type)
|
||||
assert.Equal(t, testCase.Label, fsInfo.Label)
|
||||
assert.Equal(t, testCase.Description, fsInfo.Description)
|
||||
assert.ElementsMatch(t, testCase.Tags, fsInfo.Tags)
|
||||
assert.False(t, fsInfo.Readonly)
|
||||
assert.False(t, fsInfo.Builtin)
|
||||
// assert.False(t, fsInfo.Mtime.IsZero())
|
||||
// assert.False(t, fsInfo.Ctime.IsZero())
|
||||
|
||||
// Verify metadata fields for database model
|
||||
dbInfo := infos[testCase.ID+"_db"]
|
||||
assert.Equal(t, testCase.ID+"_db", dbInfo.ID)
|
||||
assert.Equal(t, types.TypeModel, dbInfo.Type)
|
||||
assert.Equal(t, testCase.Label, dbInfo.Label)
|
||||
assert.Equal(t, testCase.Description, dbInfo.Description)
|
||||
assert.ElementsMatch(t, testCase.Tags, dbInfo.Tags)
|
||||
assert.False(t, dbInfo.Readonly)
|
||||
assert.False(t, dbInfo.Builtin)
|
||||
// assert.False(t, dbInfo.Mtime.IsZero())
|
||||
// assert.False(t, dbInfo.Ctime.IsZero())
|
||||
|
||||
// Clean up
|
||||
err = fsio.Delete(testCase.ID + "_fs")
|
||||
assert.NoError(t, err)
|
||||
err = dbio.Delete(testCase.ID + "_db")
|
||||
assert.NoError(t, err)
|
||||
err = cleanTestData()
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestModelValidate(t *testing.T) {
|
||||
manager := New("", nil, nil)
|
||||
|
||||
// Test Validate
|
||||
valid, messages := manager.Validate(context.Background(), "test source")
|
||||
assert.True(t, valid)
|
||||
assert.Empty(t, messages)
|
||||
}
|
||||
|
||||
func TestModelExecute(t *testing.T) {
|
||||
manager := New("", nil, nil)
|
||||
|
||||
// Test Execute
|
||||
result, err := manager.Execute(context.Background(), "test_id", "test_method")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "Not implemented")
|
||||
assert.Nil(t, result)
|
||||
}
|
||||
62
dsl/types/interfaces.go
Normal file
62
dsl/types/interfaces.go
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
package types
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
// DSL interface
|
||||
type DSL interface {
|
||||
Inspect(ctx context.Context, id string) (*Info, error) // Inspect DSL
|
||||
Path(ctx context.Context, id string) (string, error) // Get Path by id, ( If the DSL is saved as file, return the file path )
|
||||
Source(ctx context.Context, id string) (string, error) // Get Source by id
|
||||
List(ctx context.Context, opts *ListOptions) ([]*Info, error) // List All DSLs including unloaded/error DSLs
|
||||
Exists(ctx context.Context, id string) (bool, error) // Check if the DSL exists
|
||||
|
||||
// DSL Operations
|
||||
Create(ctx context.Context, options *CreateOptions) error // Create DSL, Create will unload the DSL first, then create the DSL to DB
|
||||
Update(ctx context.Context, options *UpdateOptions) error // Update DSL, Update will unload the DSL first, then update the DSL, if update info only, will not unload the DSL
|
||||
Delete(ctx context.Context, options *DeleteOptions) error // Delete DSL, Delete will unload the DSL first, then delete the DSL file
|
||||
|
||||
// Load manager
|
||||
Load(ctx context.Context, options *LoadOptions) error // Load DSL, Load will unload the DSL first, then load the DSL from DB or file system
|
||||
Reload(ctx context.Context, options *ReloadOptions) error // Reload DSL, Reload will unload the DSL first, then reload the DSL from DB or file system
|
||||
Unload(ctx context.Context, options *UnloadOptions) error // Unload DSL, Unload will unload the DSL from memory
|
||||
|
||||
// Execute
|
||||
Execute(ctx context.Context, id string, method string, args ...any) (any, error) // Execute DSL (Some DSLs can be executed)
|
||||
|
||||
// Validate
|
||||
Validate(ctx context.Context, source string) (bool, []LintMessage) // Validate DSL, Validate will validate the DSL from source
|
||||
}
|
||||
|
||||
// Manager interface
|
||||
type Manager interface {
|
||||
// Get all loaded DSLs
|
||||
Loaded(ctx context.Context) (map[string]*Info, error) // Get all loaded DSLs
|
||||
|
||||
// Load DSL, Load will unload the DSL first, then load the DSL from DB or file system
|
||||
Load(ctx context.Context, options *LoadOptions) error
|
||||
|
||||
// Unload DSL, Unload will unload the DSL from memory
|
||||
Unload(ctx context.Context, options *UnloadOptions) error
|
||||
|
||||
// Reload DSL, Reload will unload the DSL first, then reload the DSL from DB or file system
|
||||
Reload(ctx context.Context, options *ReloadOptions) error
|
||||
|
||||
// Validate DSL, Validate will validate the DSL from source
|
||||
Validate(ctx context.Context, source string) (bool, []LintMessage)
|
||||
|
||||
// Execute DSL (Some DSLs can be executed)
|
||||
Execute(ctx context.Context, id string, method string, args ...any) (any, error)
|
||||
}
|
||||
|
||||
// IO interface
|
||||
type IO interface {
|
||||
Inspect(id string) (*Info, bool, error)
|
||||
Source(id string) (string, bool, error)
|
||||
List(options *ListOptions) ([]*Info, error)
|
||||
Create(options *CreateOptions) error
|
||||
Update(options *UpdateOptions) error
|
||||
Delete(id string) error
|
||||
Exists(id string) (bool, error)
|
||||
}
|
||||
170
dsl/types/types.go
Normal file
170
dsl/types/types.go
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
package types
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Type for DSL
|
||||
type Type string
|
||||
|
||||
// Status for DSL
|
||||
type Status string
|
||||
|
||||
// StoreType for DSL store
|
||||
type StoreType string
|
||||
|
||||
// LintSeverity for DSL linter
|
||||
type LintSeverity string
|
||||
|
||||
// StoreType for DSL store
|
||||
const (
|
||||
StoreTypeDB StoreType = "db"
|
||||
StoreTypeFile StoreType = "file"
|
||||
)
|
||||
|
||||
// Status for DSL
|
||||
const (
|
||||
StatusLoading Status = "loading"
|
||||
StatusLoaded Status = "loaded"
|
||||
StatusError Status = "error"
|
||||
)
|
||||
|
||||
// LintSeverity for DSL linter
|
||||
const (
|
||||
LintSeverityError LintSeverity = "error"
|
||||
LintSeverityWarning LintSeverity = "warning"
|
||||
LintSeverityInfo LintSeverity = "info"
|
||||
LintSeverityHint LintSeverity = "hint"
|
||||
)
|
||||
|
||||
// Type for DSL
|
||||
const (
|
||||
// TypeModel for model
|
||||
TypeModel Type = "model"
|
||||
// TypeAPI for api
|
||||
TypeAPI Type = "api"
|
||||
// TypeConnector for connector
|
||||
TypeConnector Type = "connector"
|
||||
// TypeMCPServer for MCP server
|
||||
TypeMCPServer Type = "mcp-server"
|
||||
// TypeMCPClient for MCP client
|
||||
TypeMCPClient Type = "mcp-client"
|
||||
// TypeStore for store
|
||||
TypeStore Type = "store"
|
||||
// TypeSchedule for schedule
|
||||
TypeSchedule Type = "schedule"
|
||||
|
||||
// TypeTable for table
|
||||
TypeTable Type = "table"
|
||||
// TypeForm for form
|
||||
TypeForm Type = "form"
|
||||
// TypeList for list
|
||||
TypeList Type = "list"
|
||||
// TypeChart for chart
|
||||
TypeChart Type = "chart"
|
||||
// TypeDashboard for dashboard
|
||||
TypeDashboard Type = "dashboard"
|
||||
|
||||
// TypeFlow for flow
|
||||
TypeFlow Type = "flow"
|
||||
// TypePipe for pipe
|
||||
TypePipe Type = "pipe"
|
||||
// TypeAIGC for aigc
|
||||
TypeAIGC Type = "aigc"
|
||||
|
||||
// TypeUnknown for unknown
|
||||
TypeUnknown Type = "unknown"
|
||||
)
|
||||
|
||||
// Info for DSL
|
||||
type Info struct {
|
||||
ID string `json:"id" yaml:"id"` // Unique identifier for the DSL instance
|
||||
|
||||
Type Type `json:"type" yaml:"type"` // DSL type (model, api, table, form, list, chart, dashboard, etc.)
|
||||
Label string `json:"label,omitempty" yaml:"label,omitempty"` // Display name for the DSL
|
||||
Description string `json:"description,omitempty" yaml:"description,omitempty"` // Detailed description of the DSL
|
||||
Tags []string `json:"tags,omitempty" yaml:"tags,omitempty"` // Tags for categorization and filtering
|
||||
|
||||
Sort int `json:"sort,omitempty" yaml:"sort,omitempty"` // Sort order for display, default is 0
|
||||
Path string `json:"path" yaml:"path"` // File system path or identifier
|
||||
Store StoreType `json:"store" yaml:"store"` // Storage type (file or database)
|
||||
|
||||
Readonly bool `json:"readonly,omitempty" yaml:"readonly,omitempty"` // Whether the DSL is readonly
|
||||
Builtin bool `json:"built_in,omitempty" yaml:"built_in,omitempty"` // Whether this is a built-in DSL
|
||||
|
||||
Status Status `json:"status,omitempty" yaml:"status,omitempty"` // Current status (loading, loaded, error)
|
||||
Mtime time.Time `json:"mtime" yaml:"mtime"` // Last modification timestamp
|
||||
Ctime time.Time `json:"ctime" yaml:"ctime"` // Creation timestamp
|
||||
|
||||
Source string `json:"source,omitempty" yaml:"source,omitempty"` // Source content, only available when explicitly requested
|
||||
}
|
||||
|
||||
// ListOptions for DSL list
|
||||
type ListOptions struct {
|
||||
Sort string
|
||||
Order string
|
||||
Store StoreType
|
||||
Source bool
|
||||
Tags []string
|
||||
Pattern string // Pattern for file name matching, e.g. "test_*" for test files
|
||||
}
|
||||
|
||||
// CreateOptions for DSL upsert
|
||||
type CreateOptions struct {
|
||||
ID string // ID is the id of the DSL, if not provided, a new id will be generated, required
|
||||
Source string // Source is the source of the DSL, if not provided, the DSL will be loaded from the file system
|
||||
Store StoreType // Store is the store type of the DSL, if not provided, the DSL will be loaded from the file system
|
||||
Load map[string]interface{} // LoadOptions is the options for the DSL, if not provided, the DSL will be loaded from the file system
|
||||
}
|
||||
|
||||
// UpdateOptions for DSL upsert
|
||||
type UpdateOptions struct {
|
||||
ID string // ID is the id of the DSL, if not provided, a new id will be generated, required
|
||||
Info *Info // Info is the info of the DSL, if not provided, the DSL will be loaded from the file system, one of info or source must be provided
|
||||
Source string // Source is the source of the DSL, if not provided, the DSL will be loaded from the file system, one of info or source must be provided
|
||||
Reload map[string]interface{} // ReloadOptions is the options for the DSL, if not provided, the DSL will be loaded from the file system
|
||||
}
|
||||
|
||||
// DeleteOptions for DSL delete options
|
||||
type DeleteOptions struct {
|
||||
ID string // ID is the id of the DSL, if not provided, a new id will be generated, required
|
||||
Path string // Path is the path of the DSL, if not provided, the DSL will be loaded from the file system
|
||||
Options map[string]interface{} // Options is the options for the DSL, if not provided, the DSL will be loaded from the file system
|
||||
}
|
||||
|
||||
// LoadOptions for DSL load options
|
||||
type LoadOptions struct {
|
||||
ID string
|
||||
Path string
|
||||
Source string
|
||||
Store StoreType
|
||||
Options map[string]interface{}
|
||||
}
|
||||
|
||||
// UnloadOptions for DSL unload options
|
||||
type UnloadOptions struct {
|
||||
ID string
|
||||
Path string
|
||||
Store StoreType
|
||||
Options map[string]interface{}
|
||||
}
|
||||
|
||||
// ReloadOptions for DSL reload options
|
||||
type ReloadOptions struct {
|
||||
ID string
|
||||
Path string
|
||||
Source string
|
||||
Store StoreType
|
||||
Options map[string]interface{}
|
||||
}
|
||||
|
||||
// LintMessage for DSL linter
|
||||
type LintMessage struct {
|
||||
File string
|
||||
Line int
|
||||
Column int
|
||||
Message string
|
||||
Severity LintSeverity
|
||||
}
|
||||
|
||||
var lintMessages []LintMessage
|
||||
194
dsl/types/utils.go
Normal file
194
dsl/types/utils.go
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
package types
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ToPath convert id to path
|
||||
func ToPath(typ Type, id string) string {
|
||||
|
||||
// Get the root path and the extensions of the type
|
||||
root, exts := TypeRootAndExts(typ)
|
||||
ext := ".yao"
|
||||
if len(exts) > 0 {
|
||||
ext = exts[0]
|
||||
}
|
||||
|
||||
// 1. Replace all . to /
|
||||
path := strings.ReplaceAll(id, ".", string(os.PathSeparator))
|
||||
// 2. Replace all __ to .
|
||||
path = strings.ReplaceAll(path, "__", ".")
|
||||
// 3. Join the root path
|
||||
return filepath.Join(root, path) + ext
|
||||
}
|
||||
|
||||
// ToID convert file path to id
|
||||
func ToID(path string) string {
|
||||
typ := DetectType(path)
|
||||
return WithTypeToID(typ, path)
|
||||
}
|
||||
|
||||
// WithTypeToID convert file path to id
|
||||
func WithTypeToID(typ Type, path string) string {
|
||||
|
||||
// Get the root path and the extensions of the type
|
||||
root, exts := TypeRootAndExts(typ)
|
||||
|
||||
// 0. if the first character is /, remove it
|
||||
if strings.HasPrefix(path, string(os.PathSeparator)) {
|
||||
path = strings.TrimPrefix(path, string(os.PathSeparator))
|
||||
}
|
||||
|
||||
// 1. Split the path by /
|
||||
parts := strings.Split(path, string(os.PathSeparator))
|
||||
if len(parts) > 0 && parts[0] == root {
|
||||
// Skip the root path
|
||||
parts = parts[1:]
|
||||
|
||||
// Remove the extension only if parts is not empty
|
||||
if len(parts) > 0 {
|
||||
last := parts[len(parts)-1]
|
||||
for _, ext := range exts {
|
||||
if strings.HasSuffix(last, ext) {
|
||||
parts[len(parts)-1] = strings.TrimSuffix(last, ext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Join the parts
|
||||
path = strings.Join(parts, string(os.PathSeparator))
|
||||
}
|
||||
|
||||
// 2. Replace All . to __
|
||||
path = strings.ReplaceAll(path, ".", "__")
|
||||
|
||||
// 3. Replace all / to .
|
||||
path = strings.ReplaceAll(path, string(os.PathSeparator), ".")
|
||||
|
||||
return path
|
||||
}
|
||||
|
||||
// DetectType detect the type by the file path
|
||||
func DetectType(path string) Type {
|
||||
parts := strings.Split(path, string(os.PathSeparator))
|
||||
if len(parts) < 2 {
|
||||
return TypeUnknown
|
||||
}
|
||||
|
||||
root := parts[0]
|
||||
last := parts[len(parts)-1]
|
||||
extParts := strings.Split(last, ".")
|
||||
if len(extParts) < 2 {
|
||||
return TypeUnknown
|
||||
}
|
||||
ext := extParts[len(extParts)-2]
|
||||
|
||||
// Detect the type by the extension
|
||||
switch ext {
|
||||
case "http":
|
||||
return TypeAPI
|
||||
case "sch":
|
||||
return TypeSchedule
|
||||
case "table":
|
||||
return TypeTable
|
||||
case "form":
|
||||
return TypeForm
|
||||
case "list":
|
||||
return TypeList
|
||||
case "chart":
|
||||
return TypeChart
|
||||
case "dash":
|
||||
return TypeDashboard
|
||||
case "flow":
|
||||
return TypeFlow
|
||||
case "pipe":
|
||||
return TypePipe
|
||||
case "ai":
|
||||
return TypeAIGC
|
||||
case "mod":
|
||||
return TypeModel
|
||||
case "conn":
|
||||
return TypeConnector
|
||||
case "lru", "redis", "mongo", "badger":
|
||||
return TypeStore
|
||||
}
|
||||
|
||||
// Detect the type by the root path
|
||||
switch root {
|
||||
case "models":
|
||||
return TypeModel
|
||||
case "connectors":
|
||||
return TypeConnector
|
||||
case "mcps":
|
||||
return TypeMCPClient
|
||||
case "apis":
|
||||
if ext == "http" {
|
||||
return TypeAPI
|
||||
}
|
||||
if ext == "mcp" {
|
||||
return TypeMCPServer
|
||||
}
|
||||
return TypeUnknown
|
||||
case "schedules":
|
||||
return TypeSchedule
|
||||
case "tables":
|
||||
return TypeTable
|
||||
case "forms":
|
||||
return TypeForm
|
||||
case "lists":
|
||||
return TypeList
|
||||
case "charts":
|
||||
return TypeChart
|
||||
case "dashboards":
|
||||
return TypeDashboard
|
||||
case "flows":
|
||||
return TypeFlow
|
||||
case "pipes":
|
||||
return TypePipe
|
||||
case "aigcs":
|
||||
return TypeAIGC
|
||||
case "stores":
|
||||
return TypeStore
|
||||
default:
|
||||
return TypeUnknown
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// TypeRootAndExts return the root path and the extensions of the type
|
||||
func TypeRootAndExts(typ Type) (string, []string) {
|
||||
switch typ {
|
||||
case TypeModel:
|
||||
return "models", []string{".mod.yao", ".mod.jsonc", ".mod.json"}
|
||||
case TypeConnector:
|
||||
return "connectors", []string{".conn.yao", ".conn.jsonc", ".conn.json"}
|
||||
case TypeMCPClient, TypeMCPServer:
|
||||
return "mcps", []string{".mcp.yao", ".mcp.jsonc", ".mcp.json"}
|
||||
case TypeAPI:
|
||||
return "apis", []string{".http.yao", ".http.jsonc", ".http.json"}
|
||||
case TypeSchedule:
|
||||
return "schedules", []string{".sch.yao", ".sch.jsonc", ".sch.json"}
|
||||
case TypeTable:
|
||||
return "tables", []string{".table.yao", ".table.jsonc", ".table.json"}
|
||||
case TypeForm:
|
||||
return "forms", []string{".form.yao", ".form.jsonc", ".form.json"}
|
||||
case TypeList:
|
||||
return "lists", []string{".list.yao", ".list.jsonc", ".list.json"}
|
||||
case TypeChart:
|
||||
return "charts", []string{".chart.yao", ".chart.jsonc", ".chart.json"}
|
||||
case TypeDashboard:
|
||||
return "dashboards", []string{".dash.yao", ".dash.jsonc", ".dash.json"}
|
||||
case TypeFlow:
|
||||
return "flows", []string{".flow.yao", ".flow.jsonc", ".flow.json"}
|
||||
case TypePipe:
|
||||
return "pipes", []string{".pipe.yao", ".pipe.jsonc", ".pipe.json"}
|
||||
case TypeAIGC:
|
||||
return "aigcs", []string{".ai.yao", ".ai.jsonc", ".ai.json"}
|
||||
case TypeStore:
|
||||
return "stores", []string{".lru.yao", ".redis.yao", ".mongo.yao", ".badger.yao", ".store.yao", ".store.jsonc", ".store.json"}
|
||||
default:
|
||||
return "", []string{}
|
||||
}
|
||||
}
|
||||
601
dsl/types/utils_test.go
Normal file
601
dsl/types/utils_test.go
Normal file
|
|
@ -0,0 +1,601 @@
|
|||
package types
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestToPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
typ Type
|
||||
id string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "Model with dots and underscores",
|
||||
typ: TypeModel,
|
||||
id: "user__profile.admin",
|
||||
want: filepath.Join("models", "user.profile", "admin.mod.yao"),
|
||||
},
|
||||
{
|
||||
name: "API with simple id",
|
||||
typ: TypeAPI,
|
||||
id: "user.login",
|
||||
want: filepath.Join("apis", "user", "login.http.yao"),
|
||||
},
|
||||
{
|
||||
name: "Unknown type (defaults to .yao)",
|
||||
typ: TypeUnknown,
|
||||
id: "test",
|
||||
want: filepath.Join("", "test.yao"),
|
||||
},
|
||||
{
|
||||
name: "Connector with nested path",
|
||||
typ: TypeConnector,
|
||||
id: "database.mysql__config",
|
||||
want: filepath.Join("connectors", "database", "mysql.config.conn.yao"),
|
||||
},
|
||||
{
|
||||
name: "Type with no extensions",
|
||||
typ: Type("unknown"),
|
||||
id: "test",
|
||||
want: filepath.Join("", "test.yao"),
|
||||
},
|
||||
{
|
||||
name: "Type with empty extensions",
|
||||
typ: Type(""),
|
||||
id: "test",
|
||||
want: filepath.Join("", "test.yao"),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := ToPath(tt.typ, tt.id); got != tt.want {
|
||||
t.Errorf("ToPath() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestToID(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "Model file path",
|
||||
path: filepath.Join("models", "user.mod.yao"),
|
||||
want: "user",
|
||||
},
|
||||
{
|
||||
name: "API file path",
|
||||
path: filepath.Join("apis", "user", "login.http.yao"),
|
||||
want: "user.login",
|
||||
},
|
||||
{
|
||||
name: "Form file path with dots",
|
||||
path: filepath.Join("forms", "user.profile", "edit.form.yao"),
|
||||
want: "user__profile.edit",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := ToID(tt.path); got != tt.want {
|
||||
t.Errorf("ToID() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithTypeToID(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
typ Type
|
||||
path string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "Path with leading separator",
|
||||
typ: TypeModel,
|
||||
path: string(os.PathSeparator) + filepath.Join("models", "user.mod.yao"),
|
||||
want: "user",
|
||||
},
|
||||
{
|
||||
name: "Path without leading separator",
|
||||
typ: TypeModel,
|
||||
path: filepath.Join("models", "user.mod.yao"),
|
||||
want: "user",
|
||||
},
|
||||
{
|
||||
name: "Path with root not matching",
|
||||
typ: TypeModel,
|
||||
path: filepath.Join("other", "user.mod.yao"),
|
||||
want: "other.user__mod__yao",
|
||||
},
|
||||
{
|
||||
name: "Nested path with dots",
|
||||
typ: TypeForm,
|
||||
path: filepath.Join("forms", "user.profile", "edit.form.yao"),
|
||||
want: "user__profile.edit",
|
||||
},
|
||||
{
|
||||
name: "Multiple extensions matching",
|
||||
typ: TypeModel,
|
||||
path: filepath.Join("models", "user.mod.jsonc"),
|
||||
want: "user",
|
||||
},
|
||||
{
|
||||
name: "No extension matching",
|
||||
typ: TypeModel,
|
||||
path: filepath.Join("models", "user.txt"),
|
||||
want: "user__txt",
|
||||
},
|
||||
{
|
||||
name: "Path with single part",
|
||||
typ: TypeModel,
|
||||
path: "user.mod.yao",
|
||||
want: "user__mod__yao",
|
||||
},
|
||||
{
|
||||
name: "Store type with multiple extensions",
|
||||
typ: TypeStore,
|
||||
path: filepath.Join("stores", "cache.redis.yao"),
|
||||
want: "cache",
|
||||
},
|
||||
{
|
||||
name: "Empty path",
|
||||
typ: TypeModel,
|
||||
path: "",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "Path with root matching but no parts",
|
||||
typ: TypeModel,
|
||||
path: "models",
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := WithTypeToID(tt.typ, tt.path); got != tt.want {
|
||||
t.Errorf("WithTypeToID() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectType(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
want Type
|
||||
}{
|
||||
// Test by extension
|
||||
{
|
||||
name: "HTTP API",
|
||||
path: filepath.Join("apis", "user.http.yao"),
|
||||
want: TypeAPI,
|
||||
},
|
||||
{
|
||||
name: "Schedule",
|
||||
path: filepath.Join("schedules", "backup.sch.yao"),
|
||||
want: TypeSchedule,
|
||||
},
|
||||
{
|
||||
name: "Table",
|
||||
path: filepath.Join("tables", "user.table.yao"),
|
||||
want: TypeTable,
|
||||
},
|
||||
{
|
||||
name: "Form",
|
||||
path: filepath.Join("forms", "user.form.yao"),
|
||||
want: TypeForm,
|
||||
},
|
||||
{
|
||||
name: "List",
|
||||
path: filepath.Join("lists", "user.list.yao"),
|
||||
want: TypeList,
|
||||
},
|
||||
{
|
||||
name: "Chart",
|
||||
path: filepath.Join("charts", "sales.chart.yao"),
|
||||
want: TypeChart,
|
||||
},
|
||||
{
|
||||
name: "Dashboard",
|
||||
path: filepath.Join("dashboards", "main.dash.yao"),
|
||||
want: TypeDashboard,
|
||||
},
|
||||
{
|
||||
name: "Flow",
|
||||
path: filepath.Join("flows", "process.flow.yao"),
|
||||
want: TypeFlow,
|
||||
},
|
||||
{
|
||||
name: "Pipe",
|
||||
path: filepath.Join("pipes", "transform.pipe.yao"),
|
||||
want: TypePipe,
|
||||
},
|
||||
{
|
||||
name: "AIGC",
|
||||
path: filepath.Join("aigcs", "chat.ai.yao"),
|
||||
want: TypeAIGC,
|
||||
},
|
||||
{
|
||||
name: "Model by extension",
|
||||
path: filepath.Join("models", "user.mod.yao"),
|
||||
want: TypeModel,
|
||||
},
|
||||
{
|
||||
name: "Connector by extension",
|
||||
path: filepath.Join("connectors", "db.conn.yao"),
|
||||
want: TypeConnector,
|
||||
},
|
||||
{
|
||||
name: "Store LRU",
|
||||
path: filepath.Join("stores", "cache.lru.yao"),
|
||||
want: TypeStore,
|
||||
},
|
||||
{
|
||||
name: "LRU extension in non-stores directory",
|
||||
path: filepath.Join("other", "cache.lru.yao"),
|
||||
want: TypeStore,
|
||||
},
|
||||
{
|
||||
name: "Store Redis",
|
||||
path: filepath.Join("stores", "cache.redis.yao"),
|
||||
want: TypeStore,
|
||||
},
|
||||
{
|
||||
name: "Store Mongo",
|
||||
path: filepath.Join("stores", "cache.mongo.yao"),
|
||||
want: TypeStore,
|
||||
},
|
||||
{
|
||||
name: "Store Badger",
|
||||
path: filepath.Join("stores", "cache.badger.yao"),
|
||||
want: TypeStore,
|
||||
},
|
||||
{
|
||||
name: "Store by extension",
|
||||
path: filepath.Join("stores", "cache.store.yao"),
|
||||
want: TypeStore,
|
||||
},
|
||||
{
|
||||
name: "MCP extension in non-apis directory",
|
||||
path: filepath.Join("other", "service.mcp.yao"),
|
||||
want: TypeUnknown,
|
||||
},
|
||||
// Test by root path
|
||||
{
|
||||
name: "Model by root",
|
||||
path: filepath.Join("models", "user.yao"),
|
||||
want: TypeModel,
|
||||
},
|
||||
{
|
||||
name: "Connector by root",
|
||||
path: filepath.Join("connectors", "db.yao"),
|
||||
want: TypeConnector,
|
||||
},
|
||||
{
|
||||
name: "MCP Client",
|
||||
path: filepath.Join("mcps", "client.yao"),
|
||||
want: TypeMCPClient,
|
||||
},
|
||||
{
|
||||
name: "API by root with http ext",
|
||||
path: filepath.Join("apis", "user.http.yao"),
|
||||
want: TypeAPI,
|
||||
},
|
||||
{
|
||||
name: "MCP Server",
|
||||
path: filepath.Join("apis", "server.mcp.yao"),
|
||||
want: TypeMCPServer,
|
||||
},
|
||||
{
|
||||
name: "MCP by extension",
|
||||
path: filepath.Join("mcps", "client.mcp.yao"),
|
||||
want: TypeMCPClient,
|
||||
},
|
||||
{
|
||||
name: "API by root unknown ext",
|
||||
path: filepath.Join("apis", "user.unknown.yao"),
|
||||
want: TypeUnknown,
|
||||
},
|
||||
{
|
||||
name: "Schedule by root",
|
||||
path: filepath.Join("schedules", "backup.yao"),
|
||||
want: TypeSchedule,
|
||||
},
|
||||
{
|
||||
name: "Table by root",
|
||||
path: filepath.Join("tables", "user.yao"),
|
||||
want: TypeTable,
|
||||
},
|
||||
{
|
||||
name: "Form by root",
|
||||
path: filepath.Join("forms", "user.yao"),
|
||||
want: TypeForm,
|
||||
},
|
||||
{
|
||||
name: "List by root",
|
||||
path: filepath.Join("lists", "user.yao"),
|
||||
want: TypeList,
|
||||
},
|
||||
{
|
||||
name: "Chart by root",
|
||||
path: filepath.Join("charts", "sales.yao"),
|
||||
want: TypeChart,
|
||||
},
|
||||
{
|
||||
name: "Dashboard by root",
|
||||
path: filepath.Join("dashboards", "main.yao"),
|
||||
want: TypeDashboard,
|
||||
},
|
||||
{
|
||||
name: "Flow by root",
|
||||
path: filepath.Join("flows", "process.yao"),
|
||||
want: TypeFlow,
|
||||
},
|
||||
{
|
||||
name: "Pipe by root",
|
||||
path: filepath.Join("pipes", "transform.yao"),
|
||||
want: TypePipe,
|
||||
},
|
||||
{
|
||||
name: "AIGC by root",
|
||||
path: filepath.Join("aigcs", "chat.yao"),
|
||||
want: TypeAIGC,
|
||||
},
|
||||
{
|
||||
name: "Store by root",
|
||||
path: filepath.Join("stores", "cache.yao"),
|
||||
want: TypeStore,
|
||||
},
|
||||
{
|
||||
name: "Unknown root",
|
||||
path: filepath.Join("unknown", "file.yao"),
|
||||
want: TypeUnknown,
|
||||
},
|
||||
// Edge cases
|
||||
{
|
||||
name: "Path with less than 2 parts",
|
||||
path: "file.yao",
|
||||
want: TypeUnknown,
|
||||
},
|
||||
{
|
||||
name: "File without extension",
|
||||
path: filepath.Join("models", "user"),
|
||||
want: TypeUnknown,
|
||||
},
|
||||
{
|
||||
name: "File with single dot",
|
||||
path: filepath.Join("models", "user.yao"),
|
||||
want: TypeModel,
|
||||
},
|
||||
{
|
||||
name: "Empty path",
|
||||
path: "",
|
||||
want: TypeUnknown,
|
||||
},
|
||||
{
|
||||
name: "Path with single component",
|
||||
path: "file",
|
||||
want: TypeUnknown,
|
||||
},
|
||||
{
|
||||
name: "File with extension parts length < 2",
|
||||
path: filepath.Join("models", "user"),
|
||||
want: TypeUnknown,
|
||||
},
|
||||
{
|
||||
name: "File with extension matching filename",
|
||||
path: filepath.Join("models", "http.yao"),
|
||||
want: TypeAPI,
|
||||
},
|
||||
{
|
||||
name: "File with extension matching filename - sch",
|
||||
path: filepath.Join("schedules", "sch.yao"),
|
||||
want: TypeSchedule,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := DetectType(tt.path); got != tt.want {
|
||||
t.Errorf("DetectType() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTypeRootAndExts(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
typ Type
|
||||
wantRoot string
|
||||
wantExts []string
|
||||
}{
|
||||
{
|
||||
name: "Model",
|
||||
typ: TypeModel,
|
||||
wantRoot: "models",
|
||||
wantExts: []string{".mod.yao", ".mod.jsonc", ".mod.json"},
|
||||
},
|
||||
{
|
||||
name: "Connector",
|
||||
typ: TypeConnector,
|
||||
wantRoot: "connectors",
|
||||
wantExts: []string{".conn.yao", ".conn.jsonc", ".conn.json"},
|
||||
},
|
||||
{
|
||||
name: "MCP Client",
|
||||
typ: TypeMCPClient,
|
||||
wantRoot: "mcps",
|
||||
wantExts: []string{".mcp.yao", ".mcp.jsonc", ".mcp.json"},
|
||||
},
|
||||
{
|
||||
name: "MCP Server",
|
||||
typ: TypeMCPServer,
|
||||
wantRoot: "mcps",
|
||||
wantExts: []string{".mcp.yao", ".mcp.jsonc", ".mcp.json"},
|
||||
},
|
||||
{
|
||||
name: "API",
|
||||
typ: TypeAPI,
|
||||
wantRoot: "apis",
|
||||
wantExts: []string{".http.yao", ".http.jsonc", ".http.json"},
|
||||
},
|
||||
{
|
||||
name: "Schedule",
|
||||
typ: TypeSchedule,
|
||||
wantRoot: "schedules",
|
||||
wantExts: []string{".sch.yao", ".sch.jsonc", ".sch.json"},
|
||||
},
|
||||
{
|
||||
name: "Table",
|
||||
typ: TypeTable,
|
||||
wantRoot: "tables",
|
||||
wantExts: []string{".table.yao", ".table.jsonc", ".table.json"},
|
||||
},
|
||||
{
|
||||
name: "Form",
|
||||
typ: TypeForm,
|
||||
wantRoot: "forms",
|
||||
wantExts: []string{".form.yao", ".form.jsonc", ".form.json"},
|
||||
},
|
||||
{
|
||||
name: "List",
|
||||
typ: TypeList,
|
||||
wantRoot: "lists",
|
||||
wantExts: []string{".list.yao", ".list.jsonc", ".list.json"},
|
||||
},
|
||||
{
|
||||
name: "Chart",
|
||||
typ: TypeChart,
|
||||
wantRoot: "charts",
|
||||
wantExts: []string{".chart.yao", ".chart.jsonc", ".chart.json"},
|
||||
},
|
||||
{
|
||||
name: "Dashboard",
|
||||
typ: TypeDashboard,
|
||||
wantRoot: "dashboards",
|
||||
wantExts: []string{".dash.yao", ".dash.jsonc", ".dash.json"},
|
||||
},
|
||||
{
|
||||
name: "Flow",
|
||||
typ: TypeFlow,
|
||||
wantRoot: "flows",
|
||||
wantExts: []string{".flow.yao", ".flow.jsonc", ".flow.json"},
|
||||
},
|
||||
{
|
||||
name: "Pipe",
|
||||
typ: TypePipe,
|
||||
wantRoot: "pipes",
|
||||
wantExts: []string{".pipe.yao", ".pipe.jsonc", ".pipe.json"},
|
||||
},
|
||||
{
|
||||
name: "AIGC",
|
||||
typ: TypeAIGC,
|
||||
wantRoot: "aigcs",
|
||||
wantExts: []string{".ai.yao", ".ai.jsonc", ".ai.json"},
|
||||
},
|
||||
{
|
||||
name: "Store",
|
||||
typ: TypeStore,
|
||||
wantRoot: "stores",
|
||||
wantExts: []string{".lru.yao", ".redis.yao", ".mongo.yao", ".badger.yao", ".store.yao", ".store.jsonc", ".store.json"},
|
||||
},
|
||||
{
|
||||
name: "Unknown",
|
||||
typ: TypeUnknown,
|
||||
wantRoot: "",
|
||||
wantExts: []string{},
|
||||
},
|
||||
{
|
||||
name: "Empty type",
|
||||
typ: Type(""),
|
||||
wantRoot: "",
|
||||
wantExts: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gotRoot, gotExts := TypeRootAndExts(tt.typ)
|
||||
if gotRoot != tt.wantRoot {
|
||||
t.Errorf("TypeRootAndExts() root = %v, want %v", gotRoot, tt.wantRoot)
|
||||
}
|
||||
if len(gotExts) != len(tt.wantExts) {
|
||||
t.Errorf("TypeRootAndExts() exts length = %v, want %v", len(gotExts), len(tt.wantExts))
|
||||
return
|
||||
}
|
||||
for i, ext := range gotExts {
|
||||
if ext != tt.wantExts[i] {
|
||||
t.Errorf("TypeRootAndExts() exts[%d] = %v, want %v", i, ext, tt.wantExts[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Test integration scenarios
|
||||
func TestIntegration(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
typ Type
|
||||
id string
|
||||
wantPath string
|
||||
wantID string
|
||||
}{
|
||||
{
|
||||
name: "Model round trip",
|
||||
typ: TypeModel,
|
||||
id: "user__profile.admin",
|
||||
wantPath: filepath.Join("models", "user.profile", "admin.mod.yao"),
|
||||
wantID: "user__profile.admin",
|
||||
},
|
||||
{
|
||||
name: "API round trip",
|
||||
typ: TypeAPI,
|
||||
id: "user.login",
|
||||
wantPath: filepath.Join("apis", "user", "login.http.yao"),
|
||||
wantID: "user.login",
|
||||
},
|
||||
{
|
||||
name: "Complex nested path",
|
||||
typ: TypeForm,
|
||||
id: "admin__panel.user__management.edit",
|
||||
wantPath: filepath.Join("forms", "admin.panel", "user.management", "edit.form.yao"),
|
||||
wantID: "admin__panel.user__management.edit",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Test ID to Path
|
||||
path := ToPath(tt.typ, tt.id)
|
||||
if path != tt.wantPath {
|
||||
t.Errorf("ToPath() = %v, want %v", path, tt.wantPath)
|
||||
}
|
||||
|
||||
// Test Path to ID
|
||||
id := WithTypeToID(tt.typ, path)
|
||||
if id != tt.wantID {
|
||||
t.Errorf("WithTypeToID() = %v, want %v", id, tt.wantID)
|
||||
}
|
||||
|
||||
// Test DetectType
|
||||
detectedType := DetectType(path)
|
||||
if detectedType != tt.typ {
|
||||
t.Errorf("DetectType() = %v, want %v", detectedType, tt.typ)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
186
engine/load.go
186
engine/load.go
|
|
@ -12,6 +12,7 @@ import (
|
|||
"github.com/yaoapp/kun/exception"
|
||||
"github.com/yaoapp/yao/aigc"
|
||||
"github.com/yaoapp/yao/api"
|
||||
"github.com/yaoapp/yao/attachment"
|
||||
"github.com/yaoapp/yao/cert"
|
||||
"github.com/yaoapp/yao/config"
|
||||
"github.com/yaoapp/yao/connector"
|
||||
|
|
@ -19,10 +20,11 @@ import (
|
|||
"github.com/yaoapp/yao/flow"
|
||||
"github.com/yaoapp/yao/fs"
|
||||
"github.com/yaoapp/yao/i18n"
|
||||
"github.com/yaoapp/yao/importer"
|
||||
"github.com/yaoapp/yao/kb"
|
||||
"github.com/yaoapp/yao/moapi"
|
||||
"github.com/yaoapp/yao/model"
|
||||
"github.com/yaoapp/yao/neo"
|
||||
"github.com/yaoapp/yao/openapi"
|
||||
"github.com/yaoapp/yao/pack"
|
||||
"github.com/yaoapp/yao/pipe"
|
||||
"github.com/yaoapp/yao/plugin"
|
||||
|
|
@ -61,8 +63,14 @@ type LoadOption struct {
|
|||
IsReload bool `json:"reload"`
|
||||
}
|
||||
|
||||
// Warning the warning
|
||||
type Warning struct {
|
||||
Widget string
|
||||
Error error
|
||||
}
|
||||
|
||||
// Load application engine
|
||||
func Load(cfg config.Config, options LoadOption) (err error) {
|
||||
func Load(cfg config.Config, options LoadOption) (warnings []Warning, err error) {
|
||||
|
||||
defer func() { err = exception.Catch(recover()) }()
|
||||
exception.Mode = cfg.Mode
|
||||
|
|
@ -80,78 +88,98 @@ func Load(cfg config.Config, options LoadOption) (err error) {
|
|||
err = loadApp(cfg.AppSource)
|
||||
if err != nil {
|
||||
printErr(cfg.Mode, "Load Application", err)
|
||||
warnings = append(warnings, Warning{Widget: "Load Application", Error: err})
|
||||
}
|
||||
|
||||
// Make Database connections
|
||||
err = share.DBConnect(cfg.DB)
|
||||
if err != nil {
|
||||
printErr(cfg.Mode, "DB", err)
|
||||
// printErr(cfg.Mode, "DB", err)
|
||||
warnings = append(warnings, Warning{Widget: "DB", Error: err})
|
||||
}
|
||||
|
||||
// Load Certs
|
||||
err = cert.Load(cfg)
|
||||
if err != nil {
|
||||
printErr(cfg.Mode, "Cert", err)
|
||||
// printErr(cfg.Mode, "Cert", err)
|
||||
warnings = append(warnings, Warning{Widget: "Cert", Error: err})
|
||||
}
|
||||
|
||||
// Load Connectors
|
||||
err = connector.Load(cfg)
|
||||
if err != nil {
|
||||
printErr(cfg.Mode, "Connector", err)
|
||||
// printErr(cfg.Mode, "Connector", err)
|
||||
warnings = append(warnings, Warning{Widget: "Connector", Error: err})
|
||||
}
|
||||
|
||||
// Load FileSystem
|
||||
err = fs.Load(cfg)
|
||||
if err != nil {
|
||||
printErr(cfg.Mode, "FileSystem", err)
|
||||
// printErr(cfg.Mode, "FileSystem", err)
|
||||
warnings = append(warnings, Warning{Widget: "FileSystem", Error: err})
|
||||
}
|
||||
|
||||
// Load i18n
|
||||
err = i18n.Load(cfg)
|
||||
if err != nil {
|
||||
printErr(cfg.Mode, "i18n", err)
|
||||
// printErr(cfg.Mode, "i18n", err)
|
||||
warnings = append(warnings, Warning{Widget: "i18n", Error: err})
|
||||
}
|
||||
|
||||
// start v8 runtime
|
||||
err = runtime.Start(cfg)
|
||||
if err != nil {
|
||||
printErr(cfg.Mode, "Runtime", err)
|
||||
// printErr(cfg.Mode, "Runtime", err)
|
||||
warnings = append(warnings, Warning{Widget: "Runtime", Error: err})
|
||||
}
|
||||
|
||||
// Load Query Engine
|
||||
err = query.Load(cfg)
|
||||
if err != nil {
|
||||
printErr(cfg.Mode, "Query Engine", err)
|
||||
// printErr(cfg.Mode, "Query Engine", err)
|
||||
warnings = append(warnings, Warning{Widget: "Query Engine", Error: err})
|
||||
}
|
||||
|
||||
// Load Scripts
|
||||
err = script.Load(cfg)
|
||||
if err != nil {
|
||||
printErr(cfg.Mode, "Script", err)
|
||||
// printErr(cfg.Mode, "Script", err)
|
||||
warnings = append(warnings, Warning{Widget: "Script", Error: err})
|
||||
}
|
||||
|
||||
// Load Models
|
||||
err = model.Load(cfg)
|
||||
if err != nil {
|
||||
printErr(cfg.Mode, "Model", err)
|
||||
// printErr(cfg.Mode, "Model", err)
|
||||
warnings = append(warnings, Warning{Widget: "Model", Error: err})
|
||||
}
|
||||
|
||||
// Load Data flows
|
||||
err = flow.Load(cfg)
|
||||
if err != nil {
|
||||
printErr(cfg.Mode, "Flow", err)
|
||||
// printErr(cfg.Mode, "Flow", err)
|
||||
warnings = append(warnings, Warning{Widget: "Flow", Error: err})
|
||||
}
|
||||
|
||||
// Load Stores
|
||||
err = store.Load(cfg)
|
||||
if err != nil {
|
||||
printErr(cfg.Mode, "Store", err)
|
||||
// printErr(cfg.Mode, "Store", err)
|
||||
warnings = append(warnings, Warning{Widget: "Store", Error: err})
|
||||
}
|
||||
|
||||
// Load Uploaders
|
||||
err = attachment.Load(cfg)
|
||||
if err != nil {
|
||||
// printErr(cfg.Mode, "Uploader", err)
|
||||
warnings = append(warnings, Warning{Widget: "Uploader", Error: err})
|
||||
}
|
||||
|
||||
// Load Plugins
|
||||
err = plugin.Load(cfg)
|
||||
if err != nil {
|
||||
printErr(cfg.Mode, "Plugin", err)
|
||||
// printErr(cfg.Mode, "Plugin", err)
|
||||
warnings = append(warnings, Warning{Widget: "Plugin", Error: err})
|
||||
}
|
||||
|
||||
// Load Rules
|
||||
|
|
@ -165,110 +193,141 @@ func Load(cfg config.Config, options LoadOption) (err error) {
|
|||
// Load build-in widgets (table / form / chart / ...)
|
||||
err = widgets.Load(cfg)
|
||||
if err != nil {
|
||||
printErr(cfg.Mode, "Widgets", err)
|
||||
// printErr(cfg.Mode, "Widgets", err)
|
||||
warnings = append(warnings, Warning{Widget: "Widgets", Error: err})
|
||||
}
|
||||
|
||||
// Load Importers
|
||||
err = importer.Load(cfg)
|
||||
if err != nil {
|
||||
printErr(cfg.Mode, "Plugin", err)
|
||||
}
|
||||
// err = importer.Load(cfg)
|
||||
// if err != nil {
|
||||
// // printErr(cfg.Mode, "Plugin", err)
|
||||
// warnings = append(warnings, Warning{Widget: "Plugin", Error: err})
|
||||
// }
|
||||
|
||||
// Load Apis
|
||||
err = api.Load(cfg) // 加载业务接口 API
|
||||
if err != nil {
|
||||
printErr(cfg.Mode, "API", err)
|
||||
// printErr(cfg.Mode, "API", err)
|
||||
warnings = append(warnings, Warning{Widget: "API", Error: err})
|
||||
}
|
||||
|
||||
// Load Sockets
|
||||
err = socket.Load(cfg) // Load sockets
|
||||
if err != nil {
|
||||
printErr(cfg.Mode, "Socket", err)
|
||||
// printErr(cfg.Mode, "Socket", err)
|
||||
warnings = append(warnings, Warning{Widget: "Socket", Error: err})
|
||||
}
|
||||
|
||||
// Load websockets (client mode)
|
||||
err = websocket.Load(cfg)
|
||||
if err != nil {
|
||||
printErr(cfg.Mode, "WebSocket", err)
|
||||
// printErr(cfg.Mode, "WebSocket", err)
|
||||
warnings = append(warnings, Warning{Widget: "WebSocket", Error: err})
|
||||
}
|
||||
|
||||
// Load tasks
|
||||
err = task.Load(cfg)
|
||||
if err != nil {
|
||||
printErr(cfg.Mode, "Task", err)
|
||||
// printErr(cfg.Mode, "Task", err)
|
||||
warnings = append(warnings, Warning{Widget: "Task", Error: err})
|
||||
}
|
||||
|
||||
// Load schedules
|
||||
err = schedule.Load(cfg)
|
||||
if err != nil {
|
||||
printErr(cfg.Mode, "Schedule", err)
|
||||
// printErr(cfg.Mode, "Schedule", err)
|
||||
warnings = append(warnings, Warning{Widget: "Schedule", Error: err})
|
||||
}
|
||||
|
||||
// Load AIGC
|
||||
err = aigc.Load(cfg)
|
||||
if err != nil {
|
||||
printErr(cfg.Mode, "AIGC", err)
|
||||
}
|
||||
|
||||
// Load Neo
|
||||
err = neo.Load(cfg)
|
||||
if err != nil {
|
||||
printErr(cfg.Mode, "Neo", err)
|
||||
// printErr(cfg.Mode, "AIGC", err)
|
||||
warnings = append(warnings, Warning{Widget: "AIGC", Error: err})
|
||||
}
|
||||
|
||||
// Load Custom Widget
|
||||
err = widget.Load(cfg)
|
||||
if err != nil {
|
||||
printErr(cfg.Mode, "Widget", err)
|
||||
// printErr(cfg.Mode, "Widget", err)
|
||||
warnings = append(warnings, Warning{Widget: "Widget", Error: err})
|
||||
}
|
||||
|
||||
// Load Custom Widget Instances
|
||||
err = widget.LoadInstances()
|
||||
if err != nil {
|
||||
printErr(cfg.Mode, "Widget", err)
|
||||
// printErr(cfg.Mode, "Widget", err)
|
||||
warnings = append(warnings, Warning{Widget: "Widget", Error: err})
|
||||
}
|
||||
|
||||
// Load SUI
|
||||
err = sui.Load(cfg)
|
||||
if err != nil {
|
||||
printErr(cfg.Mode, "SUI", err)
|
||||
// printErr(cfg.Mode, "SUI", err)
|
||||
warnings = append(warnings, Warning{Widget: "SUI", Error: err})
|
||||
}
|
||||
|
||||
// Load Moapi
|
||||
err = moapi.Load(cfg)
|
||||
if err != nil {
|
||||
printErr(cfg.Mode, "Moapi", err)
|
||||
// printErr(cfg.Mode, "Moapi", err)
|
||||
warnings = append(warnings, Warning{Widget: "Moapi", Error: err})
|
||||
}
|
||||
|
||||
// Load Pipe
|
||||
err = pipe.Load(cfg)
|
||||
if err != nil {
|
||||
printErr(cfg.Mode, "Pipe", err)
|
||||
// printErr(cfg.Mode, "Pipe", err)
|
||||
warnings = append(warnings, Warning{Widget: "Pipe", Error: err})
|
||||
}
|
||||
|
||||
// Load Knowledge Base
|
||||
_, err = kb.Load(cfg)
|
||||
if err != nil {
|
||||
// printErr(cfg.Mode, "Knowledge Base", err)
|
||||
warnings = append(warnings, Warning{Widget: "Knowledge Base", Error: err})
|
||||
}
|
||||
|
||||
// Load Neo
|
||||
err = neo.Load(cfg)
|
||||
if err != nil {
|
||||
// printErr(cfg.Mode, "Neo", err)
|
||||
warnings = append(warnings, Warning{Widget: "Neo", Error: err})
|
||||
}
|
||||
|
||||
for name, hook := range LoadHooks {
|
||||
err = hook(cfg)
|
||||
if err != nil {
|
||||
printErr(cfg.Mode, name, err)
|
||||
// printErr(cfg.Mode, name, err)
|
||||
warnings = append(warnings, Warning{Widget: name, Error: err})
|
||||
}
|
||||
}
|
||||
|
||||
// Load OpenAPI
|
||||
_, err = openapi.Load(cfg)
|
||||
if err != nil {
|
||||
// printErr(cfg.Mode, "OpenAPI", err)
|
||||
warnings = append(warnings, Warning{Widget: "OpenAPI", Error: err})
|
||||
}
|
||||
|
||||
// Execute AfterLoad Process if exists
|
||||
if share.App.AfterLoad != "" && !options.IgnoredAfterLoad {
|
||||
p, err := process.Of(share.App.AfterLoad, options)
|
||||
if err != nil {
|
||||
printErr(cfg.Mode, "AfterLoad", err)
|
||||
return err
|
||||
warnings = append(warnings, Warning{Widget: "AfterLoad", Error: err})
|
||||
return warnings, err
|
||||
}
|
||||
|
||||
_, err = p.Exec()
|
||||
if err != nil {
|
||||
printErr(cfg.Mode, "AfterLoad", err)
|
||||
return err
|
||||
warnings = append(warnings, Warning{Widget: "AfterLoad", Error: err})
|
||||
return warnings, err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
return warnings, nil
|
||||
}
|
||||
|
||||
// Unload application engine
|
||||
|
|
@ -378,6 +437,12 @@ func Reload(cfg config.Config, options LoadOption) (err error) {
|
|||
printErr(cfg.Mode, "Store", err)
|
||||
}
|
||||
|
||||
// Load Uploaders
|
||||
err = attachment.Load(cfg)
|
||||
if err != nil {
|
||||
printErr(cfg.Mode, "Uploader", err)
|
||||
}
|
||||
|
||||
// Load Plugins
|
||||
err = plugin.Load(cfg)
|
||||
if err != nil {
|
||||
|
|
@ -434,12 +499,25 @@ func Reload(cfg config.Config, options LoadOption) (err error) {
|
|||
printErr(cfg.Mode, "AIGC", err)
|
||||
}
|
||||
|
||||
// Load Knowledge Base
|
||||
_, err = kb.Load(cfg)
|
||||
if err != nil {
|
||||
printErr(cfg.Mode, "Knowledge Base", err)
|
||||
|
||||
}
|
||||
|
||||
// Load Neo
|
||||
err = neo.Load(cfg)
|
||||
if err != nil {
|
||||
printErr(cfg.Mode, "Neo", err)
|
||||
}
|
||||
|
||||
// Load OpenAPI
|
||||
_, err = openapi.Load(cfg)
|
||||
if err != nil {
|
||||
printErr(cfg.Mode, "OpenAPI", err)
|
||||
}
|
||||
|
||||
// Execute AfterLoad Process if exists
|
||||
if share.App.AfterLoad != "" && !options.IgnoredAfterLoad {
|
||||
options.IsReload = true
|
||||
|
|
@ -465,7 +543,19 @@ func Restart(cfg config.Config, options LoadOption) error {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return Load(cfg, options)
|
||||
|
||||
warnings, err := Load(cfg, options)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(warnings) > 0 {
|
||||
for _, warning := range warnings {
|
||||
printErr(cfg.Mode, warning.Widget, warning.Error)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadApp load the application from bindata / pkg / disk
|
||||
|
|
@ -555,8 +645,20 @@ func loadApp(root string) error {
|
|||
}
|
||||
return []byte(val)
|
||||
})
|
||||
|
||||
// Parse app.yao
|
||||
share.App = share.AppInfo{}
|
||||
return application.Parse(appFile, appData, &share.App)
|
||||
err = application.Parse(appFile, appData, &share.App)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Set default prefix
|
||||
if share.App.Prefix == "" {
|
||||
share.App.Prefix = "yao_"
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func printErr(mode, widget string, err error) {
|
||||
|
|
|
|||
|
|
@ -13,14 +13,14 @@ import (
|
|||
|
||||
func TestLoad(t *testing.T) {
|
||||
defer Unload()
|
||||
err := Load(config.Conf, LoadOption{})
|
||||
_, err := Load(config.Conf, LoadOption{})
|
||||
assert.Nil(t, err)
|
||||
assert.Greater(t, len(api.APIs), 0)
|
||||
}
|
||||
|
||||
func TestReload(t *testing.T) {
|
||||
defer Unload()
|
||||
err := Load(config.Conf, LoadOption{})
|
||||
_, err := Load(config.Conf, LoadOption{})
|
||||
assert.Nil(t, err)
|
||||
|
||||
Reload(config.Conf, LoadOption{})
|
||||
|
|
@ -41,7 +41,7 @@ func TestLoadYaz(t *testing.T) {
|
|||
|
||||
cfg := config.Conf
|
||||
cfg.AppSource = file
|
||||
err = Load(cfg, LoadOption{})
|
||||
_, err = Load(cfg, LoadOption{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
|
@ -62,7 +62,7 @@ func TestReoadYaz(t *testing.T) {
|
|||
|
||||
cfg := config.Conf
|
||||
cfg.AppSource = file
|
||||
err = Load(cfg, LoadOption{})
|
||||
_, err = Load(cfg, LoadOption{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ func processPing(process *process.Process) interface{} {
|
|||
func processInspect(process *process.Process) interface{} {
|
||||
return map[string]interface{}{
|
||||
"VERSION": fmt.Sprintf("%s %s", share.VERSION, share.PRVERSION),
|
||||
"CUI": fmt.Sprintf("%s %s", share.CUI, share.PRCUI),
|
||||
"BUILDNAME": share.BUILDNAME,
|
||||
"CONFIG": config.Conf,
|
||||
}
|
||||
|
|
|
|||
652
excel/README.md
Normal file
652
excel/README.md
Normal file
|
|
@ -0,0 +1,652 @@
|
|||
# Yao Excel Module
|
||||
|
||||
A Go module for manipulating Excel files with TypeScript API support.
|
||||
|
||||
## IMPORTANT: Always Close Resources
|
||||
|
||||
**IMPORTANT**: Always make sure to close Excel file handles using `excel.close` when done to prevent memory leaks and file locking issues. Failing to close handles may cause file corruption or application errors.
|
||||
|
||||
## Quick Example
|
||||
|
||||
Here's a simple but complete example showing proper resource management:
|
||||
|
||||
```typescript
|
||||
// Open an Excel file
|
||||
const h = Process("excel.Open", "data.xlsx", true);
|
||||
|
||||
// Perform operations
|
||||
const sheets = Process("excel.Sheets", h);
|
||||
Process("excel.write.Cell", h, sheets[0], "A1", "Hello World");
|
||||
Process("excel.Save", h);
|
||||
|
||||
// IMPORTANT: Always close the handle when done
|
||||
Process("excel.Close", h);
|
||||
```
|
||||
|
||||
## Usage in TypeScript
|
||||
|
||||
You can use the Excel module in TypeScript through the Process API. Below are examples of common operations with return type descriptions.
|
||||
|
||||
### Basic Operations
|
||||
|
||||
#### Open an Excel file
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Opens an Excel file
|
||||
* @param path - Path to the Excel file
|
||||
* @param writable - Whether to open in writable mode (true) or read-only mode (false)
|
||||
* @returns string - Handle ID used for subsequent operations
|
||||
*/
|
||||
const h: string = Process("excel.Open", "file.xlsx", true);
|
||||
|
||||
// Open in read-only mode (false parameter or not passed)
|
||||
const hRead: string = Process("excel.Open", "file.xlsx", false);
|
||||
// or simply
|
||||
const h2: string = Process("excel.Open", "file.xlsx");
|
||||
|
||||
// IMPORTANT: Don't forget to close the handle when done
|
||||
// Process("excel.Close", h);
|
||||
```
|
||||
|
||||
### Sheet Operations
|
||||
|
||||
#### Create a new sheet
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Creates a new sheet in the workbook
|
||||
* @param handle - Handle ID from excel.open
|
||||
* @param name - Name for the new sheet
|
||||
* @returns number - Index of the new sheet
|
||||
*/
|
||||
const idx: number = Process("excel.sheet.create", h, "NewSheet");
|
||||
```
|
||||
|
||||
#### List all sheets
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Lists all sheets in the workbook
|
||||
* @param handle - Handle ID from excel.open
|
||||
* @returns string[] - Array of sheet names
|
||||
*/
|
||||
const sheets: string[] = Process("excel.sheet.list", h);
|
||||
// Example output: ["Sheet1", "Sheet2", "NewSheet"]
|
||||
```
|
||||
|
||||
#### Read sheet data
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Reads all data from a sheet
|
||||
* @param handle - Handle ID from excel.open
|
||||
* @param name - Sheet name
|
||||
* @returns any[][] - Two-dimensional array of cell values
|
||||
*/
|
||||
const data: any[][] = Process("excel.sheet.read", h, "Sheet1");
|
||||
```
|
||||
|
||||
#### Update sheet data
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Updates data in a sheet. Creates the sheet if it doesn't exist.
|
||||
* @param handle - Handle ID from excel.open
|
||||
* @param name - Sheet name
|
||||
* @param data - Two-dimensional array of values to write
|
||||
* @returns null
|
||||
*/
|
||||
const data = [
|
||||
["Header1", "Header2", "Header3"],
|
||||
[1, "Data1", true],
|
||||
[2, "Data2", false],
|
||||
];
|
||||
Process("excel.sheet.update", h, "Sheet1", data);
|
||||
```
|
||||
|
||||
#### Copy a sheet
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Copies a sheet with all its content and formatting
|
||||
* @param handle - Handle ID from excel.open
|
||||
* @param source - Source sheet name
|
||||
* @param target - Target sheet name (must not exist)
|
||||
* @returns null
|
||||
*/
|
||||
Process("excel.sheet.copy", h, "Sheet1", "Sheet1Copy");
|
||||
```
|
||||
|
||||
#### Delete a sheet
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Deletes a sheet from the workbook
|
||||
* @param handle - Handle ID from excel.open
|
||||
* @param name - Sheet name to delete
|
||||
* @returns null
|
||||
*/
|
||||
Process("excel.sheet.delete", h, "Sheet1Copy");
|
||||
```
|
||||
|
||||
#### Check if a sheet exists
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Checks if a sheet exists in the workbook
|
||||
* @param handle - Handle ID from excel.open
|
||||
* @param name - Sheet name to check
|
||||
* @returns boolean - true if sheet exists, false otherwise
|
||||
*/
|
||||
const exists: boolean = Process("excel.sheet.exists", h, "Sheet1");
|
||||
```
|
||||
|
||||
#### Read sheet rows with pagination
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Reads rows from a sheet with pagination support
|
||||
* @param handle - Handle ID from excel.open
|
||||
* @param name - Sheet name
|
||||
* @param start - Starting row index (0-based)
|
||||
* @param size - Number of rows to read
|
||||
* @returns string[][] - Two-dimensional array of cell values
|
||||
*/
|
||||
const rows: string[][] = Process("excel.sheet.rows", h, "Sheet1", 0, 10); // Read first 10 rows
|
||||
```
|
||||
|
||||
#### Get sheet dimensions
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Gets the dimensions (number of rows and columns) of a sheet
|
||||
* @param handle - Handle ID from excel.open
|
||||
* @param name - Sheet name
|
||||
* @returns {rows: number, cols: number} - Object containing row and column counts
|
||||
*/
|
||||
const dim: { rows: number; cols: number } = Process(
|
||||
"excel.sheet.dimension",
|
||||
h,
|
||||
"Sheet1"
|
||||
);
|
||||
console.log(`Sheet has ${dim.rows} rows and ${dim.cols} columns`);
|
||||
```
|
||||
|
||||
### Example: Sheet Operations Workflow
|
||||
|
||||
```typescript
|
||||
// Open Excel file in writable mode
|
||||
const h: string = Process("excel.Open", "file.xlsx", true);
|
||||
|
||||
// Create a new sheet
|
||||
const idx: number = Process("excel.sheet.create", h, "DataSheet");
|
||||
|
||||
// Write some data to the new sheet
|
||||
const data = [
|
||||
["Name", "Age", "Active"],
|
||||
["John", 30, true],
|
||||
["Jane", 25, false],
|
||||
];
|
||||
Process("excel.sheet.update", h, "DataSheet", data);
|
||||
|
||||
// Make a backup copy of the sheet
|
||||
Process("excel.sheet.copy", h, "DataSheet", "DataSheet_Backup");
|
||||
|
||||
// List all sheets to verify
|
||||
const sheets: string[] = Process("excel.sheet.list", h);
|
||||
console.log("Available sheets:", sheets);
|
||||
|
||||
// Read data from the backup sheet
|
||||
const backupData: any[][] = Process("excel.sheet.read", h, "DataSheet_Backup");
|
||||
console.log("Backup data:", backupData);
|
||||
|
||||
// Delete the backup sheet when no longer needed
|
||||
Process("excel.sheet.delete", h, "DataSheet_Backup");
|
||||
|
||||
// Save changes
|
||||
Process("excel.Save", h);
|
||||
|
||||
// IMPORTANT: Always close the handle when done
|
||||
Process("excel.Close", h);
|
||||
```
|
||||
|
||||
#### Get all sheets in the workbook
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Gets all sheet names in the workbook
|
||||
* @param handle - Handle ID from excel.open
|
||||
* @returns string[] - Array of sheet names
|
||||
*/
|
||||
const sheets: string[] = Process("excel.Sheets", h);
|
||||
// Example output: ["Sheet1", "Sheet2"]
|
||||
```
|
||||
|
||||
#### Close a file
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Closes an Excel file
|
||||
* @param handle - Handle ID from excel.open
|
||||
* @returns null
|
||||
*/
|
||||
Process("excel.Close", h);
|
||||
```
|
||||
|
||||
#### Save changes to file
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Saves changes to the Excel file
|
||||
* @param handle - Handle ID from excel.open
|
||||
* @returns null
|
||||
*/
|
||||
Process("excel.Save", h);
|
||||
```
|
||||
|
||||
### Reading Data
|
||||
|
||||
#### Read a cell's value
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Reads a cell's value
|
||||
* @param handle - Handle ID from excel.open
|
||||
* @param sheet - Sheet name
|
||||
* @param cell - Cell reference (e.g. "A1")
|
||||
* @returns string - Cell value
|
||||
*/
|
||||
const value: string = Process("excel.read.Cell", h, "SheetName", "A1");
|
||||
```
|
||||
|
||||
#### Read all rows
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Reads all rows in a sheet
|
||||
* @param handle - Handle ID from excel.open
|
||||
* @param sheet - Sheet name
|
||||
* @returns string[][] - Two-dimensional array of cell values
|
||||
*/
|
||||
const rows: string[][] = Process("excel.read.Row", h, "SheetName");
|
||||
```
|
||||
|
||||
#### Read all columns
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Reads all columns in a sheet
|
||||
* @param handle - Handle ID from excel.open
|
||||
* @param sheet - Sheet name
|
||||
* @returns string[][] - Two-dimensional array of cell values
|
||||
*/
|
||||
const columns: string[][] = Process("excel.read.Column", h, "SheetName");
|
||||
```
|
||||
|
||||
### Writing Data
|
||||
|
||||
#### Write to a cell
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Writes a value to a cell
|
||||
* @param handle - Handle ID from excel.open
|
||||
* @param sheet - Sheet name
|
||||
* @param cell - Cell reference (e.g. "A1")
|
||||
* @param value - Value to write (string, number, boolean, etc.)
|
||||
* @returns null
|
||||
*/
|
||||
Process("excel.write.Cell", h, "SheetName", "A1", "Hello World");
|
||||
// Can write different types of values
|
||||
Process("excel.write.Cell", h, "SheetName", "A2", 123.45);
|
||||
Process("excel.write.Cell", h, "SheetName", "A3", true);
|
||||
```
|
||||
|
||||
#### Write a row
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Writes values to a row starting at the specified cell
|
||||
* @param handle - Handle ID from excel.open
|
||||
* @param sheet - Sheet name
|
||||
* @param startCell - Starting cell reference (e.g. "A1")
|
||||
* @param values - Array of values to write
|
||||
* @returns null
|
||||
*/
|
||||
Process("excel.write.Row", h, "SheetName", "A1", ["Cell1", "Cell2", "Cell3"]);
|
||||
```
|
||||
|
||||
#### Write a column
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Writes values to a column starting at the specified cell
|
||||
* @param handle - Handle ID from excel.open
|
||||
* @param sheet - Sheet name
|
||||
* @param startCell - Starting cell reference (e.g. "A1")
|
||||
* @param values - Array of values to write
|
||||
* @returns null
|
||||
*/
|
||||
Process("excel.write.Column", h, "SheetName", "A1", ["Row1", "Row2", "Row3"]);
|
||||
```
|
||||
|
||||
#### Write multiple rows
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Writes a two-dimensional array of values starting at the specified cell
|
||||
* @param handle - Handle ID from excel.open
|
||||
* @param sheet - Sheet name
|
||||
* @param startCell - Starting cell reference (e.g. "A1")
|
||||
* @param values - Two-dimensional array of values to write
|
||||
* @returns null
|
||||
*/
|
||||
Process("excel.write.All", h, "SheetName", "A1", [
|
||||
["Row1Cell1", "Row1Cell2", "Row1Cell3"],
|
||||
["Row2Cell1", "Row2Cell2", "Row2Cell3"],
|
||||
]);
|
||||
```
|
||||
|
||||
### Formatting and Styling
|
||||
|
||||
#### Set cell style
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Sets a cell's style
|
||||
* @param handle - Handle ID from excel.open
|
||||
* @param sheet - Sheet name
|
||||
* @param cell - Cell reference (e.g. "A1")
|
||||
* @param styleID - Style ID
|
||||
* @returns null
|
||||
*/
|
||||
Process("excel.set.Style", h, "SheetName", "A1", 1);
|
||||
```
|
||||
|
||||
#### Style ID Constants
|
||||
|
||||
When using `excel.set.Style`, you need to provide a style ID. The following style IDs are supported:
|
||||
|
||||
```typescript
|
||||
// Border styles
|
||||
const BORDER_NONE = 0; // No border
|
||||
const BORDER_CONTINUOUS = 1; // Continuous border (thin)
|
||||
const BORDER_CONTINUOUS_2 = 2; // Continuous border (medium)
|
||||
const BORDER_DASH = 3; // Dashed border
|
||||
const BORDER_DOT = 4; // Dotted border
|
||||
const BORDER_CONTINUOUS_3 = 5; // Continuous border (thick)
|
||||
const BORDER_DOUBLE = 6; // Double line border
|
||||
const BORDER_CONTINUOUS_0 = 7; // Continuous border (hair)
|
||||
const BORDER_DASH_2 = 8; // Dashed border (medium)
|
||||
const BORDER_DASH_DOT = 9; // Dash-dot border
|
||||
const BORDER_DASH_DOT_2 = 10; // Dash-dot border (medium)
|
||||
const BORDER_DASH_DOT_DOT = 11; // Dash-dot-dot border
|
||||
const BORDER_DASH_DOT_DOT_2 = 12; // Dash-dot-dot border (medium)
|
||||
const BORDER_SLANT_DASH_DOT = 13; // Slanted dash-dot border
|
||||
|
||||
// Fill patterns
|
||||
const FILL_NONE = 0; // No fill
|
||||
const FILL_SOLID = 1; // Solid fill
|
||||
const FILL_MEDIUM_GRAY = 2; // Medium gray fill
|
||||
const FILL_DARK_GRAY = 3; // Dark gray fill
|
||||
const FILL_LIGHT_GRAY = 4; // Light gray fill
|
||||
const FILL_DARK_HORIZONTAL = 5; // Dark horizontal line pattern
|
||||
const FILL_DARK_VERTICAL = 6; // Dark vertical line pattern
|
||||
const FILL_DARK_DOWN = 7; // Dark diagonal down pattern
|
||||
const FILL_DARK_UP = 8; // Dark diagonal up pattern
|
||||
const FILL_DARK_GRID = 9; // Dark grid pattern
|
||||
const FILL_DARK_TRELLIS = 10; // Dark trellis pattern
|
||||
const FILL_LIGHT_HORIZONTAL = 11; // Light horizontal line pattern
|
||||
const FILL_LIGHT_VERTICAL = 12; // Light vertical line pattern
|
||||
const FILL_LIGHT_DOWN = 13; // Light diagonal down pattern
|
||||
const FILL_LIGHT_UP = 14; // Light diagonal up pattern
|
||||
const FILL_LIGHT_GRID = 15; // Light grid pattern
|
||||
const FILL_LIGHT_TRELLIS = 16; // Light trellis pattern
|
||||
const FILL_GRAY_125 = 17; // 12.5% gray fill
|
||||
const FILL_GRAY_0625 = 18; // 6.25% gray fill
|
||||
```
|
||||
|
||||
Example of creating a custom style with borders and fill:
|
||||
|
||||
```typescript
|
||||
// Create style with thick border and light gray fill
|
||||
const styleID = 1; // This would typically be a custom style ID created via the NewStyle API
|
||||
|
||||
// Apply the style to cell A1
|
||||
Process("excel.set.Style", h, "SheetName", "A1", styleID);
|
||||
```
|
||||
|
||||
Note: The excelize library supports creating custom styles through the `NewStyle` function. Currently, in the Yao Excel module, only predefined style IDs are supported. For more complex styling needs, consider creating a custom style in the future versions of the API.
|
||||
|
||||
#### Set row height
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Sets a row's height
|
||||
* @param handle - Handle ID from excel.open
|
||||
* @param sheet - Sheet name
|
||||
* @param row - Row number
|
||||
* @param height - Height in points
|
||||
* @returns null
|
||||
*/
|
||||
Process("excel.set.RowHeight", h, "SheetName", 1, 30); // Set row 1 to 30 pts height
|
||||
```
|
||||
|
||||
#### Set column width
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Sets column width for a range of columns
|
||||
* @param handle - Handle ID from excel.open
|
||||
* @param sheet - Sheet name
|
||||
* @param startCol - Starting column letter
|
||||
* @param endCol - Ending column letter
|
||||
* @param width - Width in points
|
||||
* @returns null
|
||||
*/
|
||||
Process("excel.set.ColumnWidth", h, "SheetName", "A", "B", 20);
|
||||
```
|
||||
|
||||
#### Merge cells
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Merges cells in a range
|
||||
* @param handle - Handle ID from excel.open
|
||||
* @param sheet - Sheet name
|
||||
* @param startCell - Starting cell reference (e.g. "A1")
|
||||
* @param endCell - Ending cell reference (e.g. "B2")
|
||||
* @returns null
|
||||
*/
|
||||
Process("excel.set.MergeCell", h, "SheetName", "A1", "B2");
|
||||
```
|
||||
|
||||
#### Unmerge cells
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Unmerges previously merged cells
|
||||
* @param handle - Handle ID from excel.open
|
||||
* @param sheet - Sheet name
|
||||
* @param startCell - Starting cell reference (e.g. "A1")
|
||||
* @param endCell - Ending cell reference (e.g. "B2")
|
||||
* @returns null
|
||||
*/
|
||||
Process("excel.set.UnmergeCell", h, "SheetName", "A1", "B2");
|
||||
```
|
||||
|
||||
#### Set a formula
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Sets a formula in a cell
|
||||
* @param handle - Handle ID from excel.open
|
||||
* @param sheet - Sheet name
|
||||
* @param cell - Cell reference (e.g. "C1")
|
||||
* @param formula - Excel formula without the leading equals sign
|
||||
* @returns null
|
||||
*/
|
||||
Process("excel.set.Formula", h, "SheetName", "C1", "SUM(A1:B1)");
|
||||
```
|
||||
|
||||
#### Add a hyperlink
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Adds a hyperlink to a cell
|
||||
* @param handle - Handle ID from excel.open
|
||||
* @param sheet - Sheet name
|
||||
* @param cell - Cell reference (e.g. "A1")
|
||||
* @param url - URL for the hyperlink
|
||||
* @param text - Display text for the hyperlink
|
||||
* @returns null
|
||||
*/
|
||||
Process(
|
||||
"excel.set.Link",
|
||||
h,
|
||||
"SheetName",
|
||||
"A1",
|
||||
"https://example.com",
|
||||
"Visit Example"
|
||||
);
|
||||
```
|
||||
|
||||
### Iterating Through Data
|
||||
|
||||
#### Row Iterator
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Opens a row iterator
|
||||
* @param handle - Handle ID from excel.open
|
||||
* @param sheet - Sheet name
|
||||
* @returns string - Row iterator ID
|
||||
*/
|
||||
const rid: string = Process("excel.each.OpenRow", h, "SheetName");
|
||||
|
||||
/**
|
||||
* Gets the next row from the iterator
|
||||
* @param rowID - Row iterator ID from excel.each.openrow
|
||||
* @returns string[] | null - Array of cell values or null if no more rows
|
||||
*/
|
||||
let row: string[] | null;
|
||||
while ((row = Process("excel.each.NextRow", rid)) !== null) {
|
||||
// Process the row
|
||||
console.log(row);
|
||||
}
|
||||
|
||||
/**
|
||||
* IMPORTANT: Always close the row iterator when done
|
||||
* @param rowID - Row iterator ID from excel.each.openrow
|
||||
* @returns null
|
||||
*/
|
||||
Process("excel.each.CloseRow", rid);
|
||||
```
|
||||
|
||||
#### Column Iterator
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Opens a column iterator
|
||||
* @param handle - Handle ID from excel.open
|
||||
* @param sheet - Sheet name
|
||||
* @returns string - Column iterator ID
|
||||
*/
|
||||
const cid: string = Process("excel.each.OpenColumn", h, "SheetName");
|
||||
|
||||
/**
|
||||
* Gets the next column from the iterator
|
||||
* @param colID - Column iterator ID from excel.each.opencolumn
|
||||
* @returns string[] | null - Array of cell values or null if no more columns
|
||||
*/
|
||||
let col: string[] | null;
|
||||
while ((col = Process("excel.each.NextColumn", cid)) !== null) {
|
||||
// Process the column
|
||||
console.log(col);
|
||||
}
|
||||
|
||||
/**
|
||||
* IMPORTANT: Always close the column iterator when done
|
||||
* @param colID - Column iterator ID from excel.each.opencolumn
|
||||
* @returns null
|
||||
*/
|
||||
Process("excel.each.CloseColumn", cid);
|
||||
```
|
||||
|
||||
### Utility Functions
|
||||
|
||||
#### Convert between column names and indices
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Converts a column name to a column number
|
||||
* @param colName - Column name (e.g. "A", "AB")
|
||||
* @returns number - Column number (1-based)
|
||||
*/
|
||||
const colNum: number = Process("excel.convert.ColumnNameToNumber", "AK"); // Returns 37
|
||||
|
||||
/**
|
||||
* Converts a column number to a column name
|
||||
* @param colNum - Column number (1-based)
|
||||
* @returns string - Column name
|
||||
*/
|
||||
const colName: string = Process("excel.convert.ColumnNumberToName", 37); // Returns "AK"
|
||||
```
|
||||
|
||||
#### Convert between cell references and coordinates
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Converts a cell reference to coordinates
|
||||
* @param cell - Cell reference (e.g. "A1")
|
||||
* @returns number[] - Array with [columnNumber, rowNumber] (1-based)
|
||||
*/
|
||||
const coords: number[] = Process("excel.convert.CellNameToCoordinates", "A1"); // Returns [1, 1]
|
||||
|
||||
/**
|
||||
* Converts coordinates to a cell reference
|
||||
* @param col - Column number (1-based)
|
||||
* @param row - Row number (1-based)
|
||||
* @returns string - Cell reference
|
||||
*/
|
||||
const cellName: string = Process("excel.convert.CoordinatesToCellName", 1, 1); // Returns "A1"
|
||||
```
|
||||
|
||||
## Complete Workflow Example
|
||||
|
||||
```typescript
|
||||
// Open Excel file in writable mode
|
||||
const h: string = Process("excel.Open", "file.xlsx", true);
|
||||
|
||||
// Get available sheets
|
||||
const sheets: string[] = Process("excel.Sheets", h);
|
||||
const sheetName: string = sheets[0];
|
||||
|
||||
// Read some data
|
||||
const value: string = Process("excel.read.Cell", h, sheetName, "A1");
|
||||
console.log("Cell A1 contains:", value);
|
||||
|
||||
// Write data
|
||||
Process("excel.write.Cell", h, sheetName, "B1", "New Value");
|
||||
Process("excel.write.Row", h, sheetName, "A2", ["Data1", "Data2", "Data3"]);
|
||||
|
||||
// Add a formula
|
||||
Process("excel.set.Formula", h, sheetName, "D1", "SUM(A1:C1)");
|
||||
|
||||
// Format cells
|
||||
Process("excel.set.RowHeight", h, sheetName, 1, 30);
|
||||
Process("excel.set.ColumnWidth", h, sheetName, "A", "D", 15);
|
||||
|
||||
// Save changes
|
||||
Process("excel.Save", h);
|
||||
|
||||
// IMPORTANT: Always close the handle when done
|
||||
Process("excel.Close", h);
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Always make sure to close open file handles using `excel.close` when done to prevent resource leaks and file locking issues.
|
||||
- Remember to save changes with `excel.save` before closing to ensure all modifications are persisted.
|
||||
- For performance reasons, try to batch operations where possible instead of making many small changes.
|
||||
104
excel/each.go
Normal file
104
excel/each.go
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
package excel
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/xuri/excelize/v2"
|
||||
)
|
||||
|
||||
// Cols defines an iterator to a sheet
|
||||
type Cols struct {
|
||||
id string
|
||||
*excelize.Cols
|
||||
create int64
|
||||
}
|
||||
|
||||
// Rows defines an iterator to a sheet
|
||||
type Rows struct {
|
||||
id string
|
||||
*excelize.Rows
|
||||
create int64
|
||||
}
|
||||
|
||||
var openCols = sync.Map{}
|
||||
var openRows = sync.Map{}
|
||||
|
||||
// OpenRow each row of the sheet
|
||||
func (excel *Excel) OpenRow(sheet string) (string, error) {
|
||||
id := uuid.NewString()
|
||||
rows, err := excel.Rows(sheet)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
openRows.Store(id, &Rows{id: id, Rows: rows, create: time.Now().Unix()})
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// NextRow next row of the sheet
|
||||
func NextRow(id string) ([]string, error) {
|
||||
value, ok := openRows.Load(id)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("rows %s not found", id)
|
||||
}
|
||||
|
||||
if value.(*Rows).Next() {
|
||||
row, err := value.(*Rows).Columns()
|
||||
// fmt.Printf("DEBUG: %#v %v %v\n", row, err, row == nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if row == nil {
|
||||
return []string{}, nil
|
||||
}
|
||||
return row, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// CloseRow done the sheet
|
||||
func CloseRow(id string) {
|
||||
openRows.Delete(id)
|
||||
}
|
||||
|
||||
// OpenColumn each cols of the sheet
|
||||
func (excel *Excel) OpenColumn(sheet string) (string, error) {
|
||||
id := uuid.NewString()
|
||||
cols, err := excel.Cols(sheet)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
openCols.Store(id, &Cols{id: id, Cols: cols, create: time.Now().Unix()})
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// NextColumn next col of the sheet
|
||||
func NextColumn(id string) ([]string, error) {
|
||||
value, ok := openCols.Load(id)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("cols %s not found", id)
|
||||
}
|
||||
|
||||
if value.(*Cols).Next() {
|
||||
col, err := value.(*Cols).Rows()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if col == nil {
|
||||
return []string{}, nil
|
||||
}
|
||||
|
||||
return col, nil
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// CloseColumn done the sheet
|
||||
func CloseColumn(id string) {
|
||||
openCols.Delete(id)
|
||||
}
|
||||
64
excel/each_test.go
Normal file
64
excel/each_test.go
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
package excel
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestEachCols(t *testing.T) {
|
||||
files := testFiles(t)
|
||||
h1, err := Open(files["test-01"], false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer Close(h1)
|
||||
|
||||
xls, err := Get(h1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
id, err := xls.OpenColumn("供销存管理表格")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer CloseColumn(id)
|
||||
|
||||
res := []string{}
|
||||
for col, err := NextColumn(id); err == nil && col != nil; col, err = NextColumn(id) {
|
||||
res = append(res, col...)
|
||||
}
|
||||
|
||||
assert.Contains(t, strings.Join(res, ""), "供销存管理表格产品查询")
|
||||
assert.Contains(t, strings.Join(res, ""), "刘大大")
|
||||
}
|
||||
|
||||
func TestEachRows(t *testing.T) {
|
||||
files := testFiles(t)
|
||||
h1, err := Open(files["test-01"], false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer Close(h1)
|
||||
|
||||
xls, err := Get(h1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
id, err := xls.OpenRow("供销存管理表格")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer CloseRow(id)
|
||||
|
||||
res := []string{}
|
||||
for row, err := NextRow(id); err == nil && row != nil; row, err = NextRow(id) {
|
||||
res = append(res, row...)
|
||||
}
|
||||
|
||||
assert.Contains(t, strings.Join(res, ""), "供销存管理表格产品查询")
|
||||
assert.Contains(t, strings.Join(res, ""), "刘大大")
|
||||
}
|
||||
115
excel/excel.go
Normal file
115
excel/excel.go
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
package excel
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/xuri/excelize/v2"
|
||||
"github.com/yaoapp/yao/config"
|
||||
)
|
||||
|
||||
// Excel the excel file
|
||||
type Excel struct {
|
||||
id string
|
||||
path string
|
||||
create int64
|
||||
abs string
|
||||
*excelize.File
|
||||
}
|
||||
|
||||
// openFiles the open files
|
||||
var openFiles = sync.Map{}
|
||||
|
||||
// Open open the excel file
|
||||
func Open(path string, writable bool) (string, error) {
|
||||
|
||||
excel := &Excel{path: path}
|
||||
// GET DATA ROOT
|
||||
root := config.Conf.DataRoot
|
||||
absPath, err := filepath.Abs(filepath.Join(root, path))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if writable {
|
||||
|
||||
// if the file not exists, create it
|
||||
if _, err := os.Stat(absPath); os.IsNotExist(err) {
|
||||
|
||||
// Auto create dir
|
||||
dir := filepath.Dir(absPath)
|
||||
if _, err := os.Stat(dir); os.IsNotExist(err) {
|
||||
err := os.MkdirAll(dir, 0755) // 0755 is the default permission for directories
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
create := excelize.NewFile()
|
||||
err := create.SaveAs(absPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
create.Close()
|
||||
}
|
||||
|
||||
excelFile, err := excelize.OpenFile(absPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
id := uuid.NewString()
|
||||
excel.File = excelFile
|
||||
excel.id = id
|
||||
excel.abs = absPath
|
||||
excel.create = time.Now().Unix()
|
||||
openFiles.Store(id, excel)
|
||||
return id, nil
|
||||
}
|
||||
|
||||
file, err := os.Open(absPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("open file %s failed: %w", absPath, err)
|
||||
}
|
||||
|
||||
excelFile, err := excelize.OpenReader(file)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
id := uuid.NewString()
|
||||
excel.File = excelFile
|
||||
excel.id = id
|
||||
excel.abs = absPath
|
||||
excel.create = time.Now().Unix()
|
||||
openFiles.Store(id, excel)
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// Close close the excel file
|
||||
func Close(handler string) error {
|
||||
excel, ok := openFiles.Load(handler)
|
||||
if !ok {
|
||||
return fmt.Errorf("file not found")
|
||||
}
|
||||
|
||||
err := excel.(*Excel).Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
openFiles.Delete(handler)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get get the excel file
|
||||
func Get(handler string) (*Excel, error) {
|
||||
excel, ok := openFiles.Load(handler)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s not found", handler)
|
||||
}
|
||||
return excel.(*Excel), nil
|
||||
}
|
||||
252
excel/excel_test.go
Normal file
252
excel/excel_test.go
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
package excel
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/yaoapp/yao/config"
|
||||
"github.com/yaoapp/yao/test"
|
||||
)
|
||||
|
||||
func TestOpenClose(t *testing.T) {
|
||||
files := testFiles(t)
|
||||
|
||||
h1, err := Open(files["test-01"], false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, ok := openFiles.Load(h1); !ok {
|
||||
t.Fatal("open file failed")
|
||||
}
|
||||
|
||||
h2, err := Open(files["test-02"], true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := openFiles.Load(h2); !ok {
|
||||
t.Fatal("open file failed")
|
||||
}
|
||||
|
||||
h3, err := Open(files["test-03"], false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := openFiles.Load(h3); !ok {
|
||||
t.Fatal("open file failed")
|
||||
}
|
||||
|
||||
_, err = Open(files["test-04"], false)
|
||||
assert.Error(t, err)
|
||||
|
||||
err = Close(h1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := openFiles.Load(h1); ok {
|
||||
t.Fatal("close file failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSheetList(t *testing.T) {
|
||||
|
||||
files := testFiles(t)
|
||||
h1, err := Open(files["test-01"], false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer Close(h1)
|
||||
|
||||
xls, err := Get(h1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sheets := xls.GetSheetList()
|
||||
assert.Equal(t, []string{"供销存管理表格", "使用说明"}, sheets)
|
||||
|
||||
_, err = Get("not found")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestOpenInvalidFile(t *testing.T) {
|
||||
test.Prepare(t, config.Conf)
|
||||
defer test.Clean()
|
||||
|
||||
// Create an invalid excel file in the data root
|
||||
root := "excel"
|
||||
invalidFile := filepath.Join(root, "invalid.xlsx")
|
||||
|
||||
// Ensure cleanup after test
|
||||
defer func() {
|
||||
if err := os.Remove(filepath.Join(config.Conf.DataRoot, invalidFile)); err != nil {
|
||||
t.Logf("Failed to cleanup test file: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
err := os.WriteFile(filepath.Join(config.Conf.DataRoot, invalidFile), []byte("invalid content"), 0644)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err = Open(invalidFile, false)
|
||||
assert.Error(t, err, "should fail to open invalid excel file")
|
||||
}
|
||||
|
||||
func TestCloseErrors(t *testing.T) {
|
||||
// Test closing non-existent handler
|
||||
err := Close("non-existent-handler")
|
||||
assert.Error(t, err, "should fail to close non-existent file")
|
||||
|
||||
// Test double close
|
||||
files := testFiles(t)
|
||||
h1, err := Open(files["test-01"], false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// First close
|
||||
err = Close(h1)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Second close should fail
|
||||
err = Close(h1)
|
||||
assert.Error(t, err, "should fail on second close")
|
||||
}
|
||||
|
||||
func TestOpenWithInvalidPath(t *testing.T) {
|
||||
// Test with invalid path
|
||||
_, err := Open("../invalid/path/file.xlsx", false)
|
||||
assert.Error(t, err, "should fail with invalid path")
|
||||
|
||||
// Test with path trying to escape data root
|
||||
_, err = Open("../../../../etc/file.xlsx", false)
|
||||
assert.Error(t, err, "should fail with path trying to escape data root")
|
||||
}
|
||||
|
||||
func TestWrite(t *testing.T) {
|
||||
test.Prepare(t, config.Conf)
|
||||
defer test.Clean()
|
||||
|
||||
// Create a new writable file for testing
|
||||
root := "excel"
|
||||
testFile := filepath.Join(root, "write-test.xlsx")
|
||||
|
||||
// Ensure cleanup after test
|
||||
defer func() {
|
||||
if err := os.Remove(filepath.Join(config.Conf.DataRoot, testFile)); err != nil {
|
||||
t.Logf("Failed to cleanup test file: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
h1, err := Open(testFile, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer Close(h1)
|
||||
|
||||
xls, err := Get(h1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Test WriteCell
|
||||
err = xls.WriteCell("Sheet1", "A1", "Hello")
|
||||
assert.NoError(t, err)
|
||||
err = xls.WriteCell("Sheet1", "B1", 123)
|
||||
assert.NoError(t, err)
|
||||
err = xls.WriteCell("Sheet1", "C1", true)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Test WriteRow
|
||||
row := []interface{}{"Row1", 456, false}
|
||||
err = xls.WriteRow("Sheet1", "A2", row)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Test WriteColumn
|
||||
col := []interface{}{"Col1", 789, true}
|
||||
err = xls.WriteColumn("Sheet1", "D1", col)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Test WriteAll
|
||||
data := [][]interface{}{
|
||||
{"Name", "Age", "City"},
|
||||
{"John", 30, "New York"},
|
||||
{"Alice", 25, "London"},
|
||||
}
|
||||
err = xls.WriteAll("Sheet2", "A1", data)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Test error cases
|
||||
// Invalid cell reference
|
||||
err = xls.WriteCell("Sheet1", "invalid", "test")
|
||||
assert.Error(t, err)
|
||||
|
||||
// Save the file to verify changes
|
||||
err = xls.SaveAs(filepath.Join(config.Conf.DataRoot, testFile))
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify written data
|
||||
val, err := xls.GetCellValue("Sheet1", "A1")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "Hello", val)
|
||||
|
||||
val, err = xls.GetCellValue("Sheet2", "A1")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "Name", val)
|
||||
}
|
||||
|
||||
func TestSetSheet(t *testing.T) {
|
||||
test.Prepare(t, config.Conf)
|
||||
defer test.Clean()
|
||||
|
||||
root := "excel"
|
||||
testFile := filepath.Join(root, "sheet-test.xlsx")
|
||||
|
||||
// Ensure cleanup after test
|
||||
defer func() {
|
||||
if err := os.Remove(filepath.Join(config.Conf.DataRoot, testFile)); err != nil {
|
||||
t.Logf("Failed to cleanup test file: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
h1, err := Open(testFile, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer Close(h1)
|
||||
|
||||
xls, err := Get(h1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Test creating new sheet
|
||||
idx, err := xls.SetSheet("NewSheet")
|
||||
assert.NoError(t, err)
|
||||
assert.Greater(t, idx, 0)
|
||||
|
||||
// Test getting existing sheet
|
||||
idx2, err := xls.SetSheet("NewSheet")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, idx, idx2)
|
||||
|
||||
// Verify sheet exists
|
||||
sheets := xls.GetSheetList()
|
||||
assert.Contains(t, sheets, "NewSheet")
|
||||
}
|
||||
|
||||
func testFiles(t *testing.T) map[string]string {
|
||||
test.Prepare(t, config.Conf)
|
||||
defer test.Clean()
|
||||
|
||||
// test data root path
|
||||
root := "excel"
|
||||
return map[string]string{
|
||||
"test-01": filepath.Join(root, "test-01.xlsx"),
|
||||
"test-02": filepath.Join(root, "test-02.xlsx"),
|
||||
"test-03": filepath.Join(root, "test-03.xlsx"),
|
||||
}
|
||||
}
|
||||
782
excel/process.go
Normal file
782
excel/process.go
Normal file
|
|
@ -0,0 +1,782 @@
|
|||
package excel
|
||||
|
||||
import (
|
||||
"github.com/xuri/excelize/v2"
|
||||
"github.com/yaoapp/gou/process"
|
||||
"github.com/yaoapp/kun/exception"
|
||||
)
|
||||
|
||||
func init() {
|
||||
process.RegisterGroup("excel", map[string]process.Handler{
|
||||
"open": processOpen,
|
||||
"close": processClose,
|
||||
"save": processSave,
|
||||
"sheets": processSheets,
|
||||
|
||||
"sheet.create": processCreateSheet,
|
||||
"sheet.read": processReadSheet,
|
||||
"sheet.update": processUpdateSheet,
|
||||
"sheet.delete": processDeleteSheet,
|
||||
"sheet.copy": processCopySheet,
|
||||
"sheet.list": processListSheets,
|
||||
"sheet.exists": processSheetExists,
|
||||
"sheet.rows": processReadSheetRows,
|
||||
"sheet.dimension": processGetSheetDimension,
|
||||
|
||||
"read.cell": processReadCell,
|
||||
"read.row": processReadRow,
|
||||
"read.column": processReadColumn,
|
||||
|
||||
"write.cell": processWriteCell,
|
||||
"write.row": processWriteRow,
|
||||
"write.column": processWriteColumn,
|
||||
"write.all": processWriteAll,
|
||||
|
||||
"set.style": processSetStyle,
|
||||
"set.formula": processSetFormula,
|
||||
"set.link": processSetLink,
|
||||
"set.richtext": processSetRichText,
|
||||
"set.comment": processSetComment,
|
||||
"set.rowheight": processSetRowHeight,
|
||||
"set.columnwidth": processSetColumnWidth,
|
||||
"set.mergecell": processMergeCell,
|
||||
"set.unmergecell": processUnmergeCell,
|
||||
|
||||
"each.openrow": processOpenRow,
|
||||
"each.closerow": processCloseRow,
|
||||
"each.nextrow": processNextRow,
|
||||
"each.opencolumn": processOpenColumn,
|
||||
"each.closecolumn": processCloseColumn,
|
||||
"each.nextcolumn": processNextColumn,
|
||||
|
||||
"convert.columnnametonumber": processColumnNameToNumber,
|
||||
"convert.columnnumbertoname": processColumnNumberToName,
|
||||
"convert.cellnametocoordinates": processCellNameToCoordinates,
|
||||
"convert.coordinatestocellname": processCoordinatesToCellName,
|
||||
})
|
||||
}
|
||||
|
||||
// processOpen process the excel.open <file> <writable>
|
||||
func processOpen(process *process.Process) interface{} {
|
||||
process.ValidateArgNums(1)
|
||||
file := process.ArgsString(0)
|
||||
writable := false
|
||||
if len(process.Args) > 1 {
|
||||
writable = process.ArgsBool(1)
|
||||
}
|
||||
|
||||
handle, err := Open(file, writable)
|
||||
if err != nil {
|
||||
exception.New("excel.open %s error: %s", 500, file, err.Error()).Throw()
|
||||
}
|
||||
return handle
|
||||
}
|
||||
|
||||
// processClose process the excel.close <handle>
|
||||
func processClose(process *process.Process) interface{} {
|
||||
process.ValidateArgNums(1)
|
||||
handle := process.ArgsString(0)
|
||||
err := Close(handle)
|
||||
if err != nil {
|
||||
exception.New("excel.close %s error: %s", 500, handle, err.Error()).Throw()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// processSave process the excel.save <handle>
|
||||
func processSave(process *process.Process) interface{} {
|
||||
process.ValidateArgNums(1)
|
||||
handle := process.ArgsString(0)
|
||||
xls, err := Get(handle)
|
||||
if err != nil {
|
||||
exception.New("excel.save %s error: %s", 500, handle, err.Error()).Throw()
|
||||
}
|
||||
|
||||
// 使用 SaveAs 方法保存文件到原始路径
|
||||
err = xls.SaveAs(xls.abs)
|
||||
if err != nil {
|
||||
exception.New("excel.save %s error: %s", 500, handle, err.Error()).Throw()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// processSheets process the excel.sheets <handle>
|
||||
func processSheets(process *process.Process) interface{} {
|
||||
process.ValidateArgNums(1)
|
||||
handle := process.ArgsString(0)
|
||||
xls, err := Get(handle)
|
||||
if err != nil {
|
||||
exception.New("excel.sheets %s error: %s", 500, handle, err.Error()).Throw()
|
||||
}
|
||||
return xls.GetSheetList()
|
||||
}
|
||||
|
||||
// processReadCell process the excel.read.cell <handle> <sheet> <cell>
|
||||
func processReadCell(process *process.Process) interface{} {
|
||||
process.ValidateArgNums(3)
|
||||
handle := process.ArgsString(0)
|
||||
sheet := process.ArgsString(1)
|
||||
cell := process.ArgsString(2)
|
||||
|
||||
xls, err := Get(handle)
|
||||
if err != nil {
|
||||
exception.New("excel.read.cell %s error: %s", 500, handle, err.Error()).Throw()
|
||||
}
|
||||
value, err := xls.GetCellValue(sheet, cell)
|
||||
if err != nil {
|
||||
exception.New("excel.read.cell %s:%s:%s error: %s", 500, handle, sheet, cell, err.Error()).Throw()
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// processReadRow process the excel.read.row <handle> <sheet>
|
||||
func processReadRow(process *process.Process) interface{} {
|
||||
process.ValidateArgNums(2)
|
||||
handle := process.ArgsString(0)
|
||||
sheet := process.ArgsString(1)
|
||||
|
||||
xls, err := Get(handle)
|
||||
if err != nil {
|
||||
exception.New("excel.read.row %s error: %s", 500, handle, err.Error()).Throw()
|
||||
}
|
||||
rows, err := xls.GetRows(sheet)
|
||||
if err != nil {
|
||||
exception.New("excel.read.row %s:%s error: %s", 500, handle, sheet, err.Error()).Throw()
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// processReadColumn process the excel.read.column <handle> <sheet>
|
||||
func processReadColumn(process *process.Process) interface{} {
|
||||
process.ValidateArgNums(2)
|
||||
handle := process.ArgsString(0)
|
||||
sheet := process.ArgsString(1)
|
||||
|
||||
xls, err := Get(handle)
|
||||
if err != nil {
|
||||
exception.New("excel.read.column %s error: %s", 500, handle, err.Error()).Throw()
|
||||
}
|
||||
cols, err := xls.GetCols(sheet)
|
||||
if err != nil {
|
||||
exception.New("excel.read.column %s:%s error: %s", 500, handle, sheet, err.Error()).Throw()
|
||||
}
|
||||
return cols
|
||||
}
|
||||
|
||||
// processWriteCell process the excel.write.cell <handle> <sheet> <cell> <value>
|
||||
func processWriteCell(process *process.Process) interface{} {
|
||||
process.ValidateArgNums(4)
|
||||
handle := process.ArgsString(0)
|
||||
sheet := process.ArgsString(1)
|
||||
cell := process.ArgsString(2)
|
||||
value := process.Args[3]
|
||||
|
||||
xls, err := Get(handle)
|
||||
if err != nil {
|
||||
exception.New("excel.write.cell %s error: %s", 500, handle, err.Error()).Throw()
|
||||
}
|
||||
err = xls.SetCellValue(sheet, cell, value)
|
||||
if err != nil {
|
||||
exception.New("excel.write.cell %s:%s:%s error: %s", 500, handle, sheet, cell, err.Error()).Throw()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// processWriteRow process the excel.write.row <handle> <sheet> <cell> <values>
|
||||
func processWriteRow(process *process.Process) interface{} {
|
||||
process.ValidateArgNums(4)
|
||||
handle := process.ArgsString(0)
|
||||
sheet := process.ArgsString(1)
|
||||
cell := process.ArgsString(2)
|
||||
values := process.Args[3]
|
||||
|
||||
xls, err := Get(handle)
|
||||
if err != nil {
|
||||
exception.New("excel.write.row %s error: %s", 500, handle, err.Error()).Throw()
|
||||
}
|
||||
|
||||
// 处理切片值
|
||||
var rowValues []interface{}
|
||||
if arr, ok := values.([]interface{}); ok {
|
||||
rowValues = arr
|
||||
} else {
|
||||
rowValues = []interface{}{values}
|
||||
}
|
||||
|
||||
// 使用 xls.SetSheetRow 方法,它应该能处理 slice 指针
|
||||
err = xls.SetSheetRow(sheet, cell, &rowValues)
|
||||
if err != nil {
|
||||
exception.New("excel.write.row %s:%s:%s error: %s", 500, handle, sheet, cell, err.Error()).Throw()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// processWriteColumn process the excel.write.column <handle> <sheet> <cell> <values>
|
||||
func processWriteColumn(process *process.Process) interface{} {
|
||||
process.ValidateArgNums(4)
|
||||
handle := process.ArgsString(0)
|
||||
sheet := process.ArgsString(1)
|
||||
cell := process.ArgsString(2)
|
||||
values := process.Args[3]
|
||||
|
||||
xls, err := Get(handle)
|
||||
if err != nil {
|
||||
exception.New("excel.write.column %s error: %s", 500, handle, err.Error()).Throw()
|
||||
}
|
||||
|
||||
// 处理切片值
|
||||
var colValues []interface{}
|
||||
if arr, ok := values.([]interface{}); ok {
|
||||
colValues = arr
|
||||
} else {
|
||||
colValues = []interface{}{values}
|
||||
}
|
||||
|
||||
// 使用 xls.SetSheetCol 方法,它应该能处理 slice 指针
|
||||
err = xls.SetSheetCol(sheet, cell, &colValues)
|
||||
if err != nil {
|
||||
exception.New("excel.write.column %s:%s:%s error: %s", 500, handle, sheet, cell, err.Error()).Throw()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// processWriteAll process the excel.write.all <handle> <sheet> <cell> <values>
|
||||
func processWriteAll(process *process.Process) interface{} {
|
||||
process.ValidateArgNums(4)
|
||||
handle := process.ArgsString(0)
|
||||
sheet := process.ArgsString(1)
|
||||
cell := process.ArgsString(2)
|
||||
values := process.Args[3]
|
||||
|
||||
xls, err := Get(handle)
|
||||
if err != nil {
|
||||
exception.New("excel.write.all %s error: %s", 500, handle, err.Error()).Throw()
|
||||
}
|
||||
|
||||
// Convert data to [][]interface{}
|
||||
var sheetData [][]interface{}
|
||||
if arr, ok := values.([]interface{}); ok {
|
||||
for _, row := range arr {
|
||||
if rowArr, ok := row.([]interface{}); ok {
|
||||
sheetData = append(sheetData, rowArr)
|
||||
} else {
|
||||
sheetData = append(sheetData, []interface{}{row})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
sheetData = [][]interface{}{{values}}
|
||||
}
|
||||
|
||||
err = xls.WriteAll(sheet, cell, sheetData)
|
||||
if err != nil {
|
||||
exception.New("excel.write.all %s:%s error: %s", 500, handle, sheet, err.Error()).Throw()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// processSetStyle process the excel.set.style <handle> <sheet> <cell> <style>
|
||||
func processSetStyle(process *process.Process) interface{} {
|
||||
process.ValidateArgNums(4)
|
||||
handle := process.ArgsString(0)
|
||||
sheet := process.ArgsString(1)
|
||||
cell := process.ArgsString(2)
|
||||
styleID := process.ArgsInt(3)
|
||||
|
||||
xls, err := Get(handle)
|
||||
if err != nil {
|
||||
exception.New("excel.set.style %s error: %s", 500, handle, err.Error()).Throw()
|
||||
}
|
||||
err = xls.SetCellStyle(sheet, cell, cell, styleID)
|
||||
if err != nil {
|
||||
exception.New("excel.set.style %s:%s:%s error: %s", 500, handle, sheet, cell, err.Error()).Throw()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// processSetFormula process the excel.set.formula <handle> <sheet> <cell> <formula>
|
||||
func processSetFormula(process *process.Process) interface{} {
|
||||
process.ValidateArgNums(4)
|
||||
handle := process.ArgsString(0)
|
||||
sheet := process.ArgsString(1)
|
||||
cell := process.ArgsString(2)
|
||||
formula := process.ArgsString(3)
|
||||
|
||||
xls, err := Get(handle)
|
||||
if err != nil {
|
||||
exception.New("excel.set.formula %s error: %s", 500, handle, err.Error()).Throw()
|
||||
}
|
||||
err = xls.SetCellFormula(sheet, cell, formula)
|
||||
if err != nil {
|
||||
exception.New("excel.set.formula %s:%s:%s error: %s", 500, handle, sheet, cell, err.Error()).Throw()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// processSetLink process the excel.set.link <handle> <sheet> <cell> <link> <text>
|
||||
func processSetLink(process *process.Process) interface{} {
|
||||
process.ValidateArgNums(5)
|
||||
handle := process.ArgsString(0)
|
||||
sheet := process.ArgsString(1)
|
||||
cell := process.ArgsString(2)
|
||||
link := process.ArgsString(3)
|
||||
text := process.ArgsString(4)
|
||||
|
||||
xls, err := Get(handle)
|
||||
if err != nil {
|
||||
exception.New("excel.set.link %s error: %s", 500, handle, err.Error()).Throw()
|
||||
}
|
||||
err = xls.SetCellHyperLink(sheet, cell, link, text)
|
||||
if err != nil {
|
||||
exception.New("excel.set.link %s:%s:%s error: %s", 500, handle, sheet, cell, err.Error()).Throw()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// processSetRichText process the excel.set.richtext <handle> <sheet> <cell> <richText>
|
||||
func processSetRichText(process *process.Process) interface{} {
|
||||
process.ValidateArgNums(4)
|
||||
handle := process.ArgsString(0)
|
||||
sheet := process.ArgsString(1)
|
||||
cell := process.ArgsString(2)
|
||||
// Extract rich text from args
|
||||
richTextData := process.Args[3]
|
||||
var richText []excelize.RichTextRun
|
||||
|
||||
// Convert to rich text format expected by excelize
|
||||
// This is a simplification - the actual implementation would depend on the format of the input
|
||||
if rtArray, ok := richTextData.([]interface{}); ok {
|
||||
for _, item := range rtArray {
|
||||
if rtMap, ok := item.(map[string]interface{}); ok {
|
||||
run := excelize.RichTextRun{}
|
||||
if text, ok := rtMap["text"].(string); ok {
|
||||
run.Text = text
|
||||
}
|
||||
richText = append(richText, run)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
xls, err := Get(handle)
|
||||
if err != nil {
|
||||
exception.New("excel.set.richtext %s error: %s", 500, handle, err.Error()).Throw()
|
||||
}
|
||||
err = xls.SetCellRichText(sheet, cell, richText)
|
||||
if err != nil {
|
||||
exception.New("excel.set.richtext %s:%s:%s error: %s", 500, handle, sheet, cell, err.Error()).Throw()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// processSetComment process the excel.set.comment <handle> <sheet> <comment>
|
||||
func processSetComment(process *process.Process) interface{} {
|
||||
process.ValidateArgNums(3)
|
||||
handle := process.ArgsString(0)
|
||||
sheet := process.ArgsString(1)
|
||||
_ = process.Args[2] // Placeholder for comment data - future implementation
|
||||
|
||||
xls, err := Get(handle)
|
||||
if err != nil {
|
||||
exception.New("excel.set.comment %s error: %s", 500, handle, err.Error()).Throw()
|
||||
}
|
||||
|
||||
// We'll need to convert the comment data to the appropriate structure
|
||||
// This is simplified for now
|
||||
err = xls.SetSheetVisible(sheet, true) // Just a placeholder operation
|
||||
if err != nil {
|
||||
exception.New("excel.set.comment %s:%s error: %s", 500, handle, sheet, err.Error()).Throw()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// processSetRowHeight process the excel.set.rowheight <handle> <sheet> <row> <height>
|
||||
func processSetRowHeight(process *process.Process) interface{} {
|
||||
process.ValidateArgNums(4)
|
||||
handle := process.ArgsString(0)
|
||||
sheet := process.ArgsString(1)
|
||||
row := process.ArgsInt(2)
|
||||
// Convert string to float using standard process method
|
||||
height := float64(process.ArgsInt(3))
|
||||
|
||||
xls, err := Get(handle)
|
||||
if err != nil {
|
||||
exception.New("excel.set.rowheight %s error: %s", 500, handle, err.Error()).Throw()
|
||||
}
|
||||
err = xls.SetRowHeight(sheet, row, height)
|
||||
if err != nil {
|
||||
exception.New("excel.set.rowheight %s:%s:%d error: %s", 500, handle, sheet, row, err.Error()).Throw()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// processSetColumnWidth process the excel.set.columnwidth <handle> <sheet> <startCol> <endCol> <width>
|
||||
func processSetColumnWidth(process *process.Process) interface{} {
|
||||
process.ValidateArgNums(5)
|
||||
handle := process.ArgsString(0)
|
||||
sheet := process.ArgsString(1)
|
||||
startCol := process.ArgsString(2)
|
||||
endCol := process.ArgsString(3)
|
||||
// Convert string to float using standard process method
|
||||
width := float64(process.ArgsInt(4))
|
||||
|
||||
xls, err := Get(handle)
|
||||
if err != nil {
|
||||
exception.New("excel.set.columnwidth %s error: %s", 500, handle, err.Error()).Throw()
|
||||
}
|
||||
err = xls.SetColWidth(sheet, startCol, endCol, width)
|
||||
if err != nil {
|
||||
exception.New("excel.set.columnwidth %s:%s:%s error: %s", 500, handle, sheet, startCol, err.Error()).Throw()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// processMergeCell process the excel.set.mergecell <handle> <sheet> <start> <end>
|
||||
func processMergeCell(process *process.Process) interface{} {
|
||||
process.ValidateArgNums(4)
|
||||
handle := process.ArgsString(0)
|
||||
sheet := process.ArgsString(1)
|
||||
start := process.ArgsString(2)
|
||||
end := process.ArgsString(3)
|
||||
|
||||
xls, err := Get(handle)
|
||||
if err != nil {
|
||||
exception.New("excel.set.mergecell %s error: %s", 500, handle, err.Error()).Throw()
|
||||
}
|
||||
err = xls.MergeCell(sheet, start, end)
|
||||
if err != nil {
|
||||
exception.New("excel.set.mergecell %s:%s:%s:%s error: %s", 500, handle, sheet, start, end, err.Error()).Throw()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// processUnmergeCell process the excel.set.unmergecell <handle> <sheet> <start> <end>
|
||||
func processUnmergeCell(process *process.Process) interface{} {
|
||||
process.ValidateArgNums(4)
|
||||
handle := process.ArgsString(0)
|
||||
sheet := process.ArgsString(1)
|
||||
start := process.ArgsString(2)
|
||||
end := process.ArgsString(3)
|
||||
|
||||
xls, err := Get(handle)
|
||||
if err != nil {
|
||||
exception.New("excel.set.unmergecell %s error: %s", 500, handle, err.Error()).Throw()
|
||||
}
|
||||
err = xls.UnmergeCell(sheet, start, end)
|
||||
if err != nil {
|
||||
exception.New("excel.set.unmergecell %s:%s:%s:%s error: %s", 500, handle, sheet, start, end, err.Error()).Throw()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// processColumnNameToNumber process the excel.convert.columnnametonumber <name>
|
||||
func processColumnNameToNumber(process *process.Process) interface{} {
|
||||
process.ValidateArgNums(1)
|
||||
name := process.ArgsString(0)
|
||||
number, err := excelize.ColumnNameToNumber(name)
|
||||
if err != nil {
|
||||
exception.New("excel.convert.columnnametonumber %s error: %s", 500, name, err.Error()).Throw()
|
||||
}
|
||||
return number
|
||||
}
|
||||
|
||||
// processColumnNumberToName process the excel.convert.columnnumbertoname <number>
|
||||
func processColumnNumberToName(process *process.Process) interface{} {
|
||||
process.ValidateArgNums(1)
|
||||
number := process.ArgsInt(0)
|
||||
name, err := excelize.ColumnNumberToName(number)
|
||||
if err != nil {
|
||||
exception.New("excel.convert.columnnumbertoname %d error: %s", 500, number, err.Error()).Throw()
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// processCellNameToCoordinates process the excel.convert.cellnametocoordinates <cell>
|
||||
func processCellNameToCoordinates(process *process.Process) interface{} {
|
||||
process.ValidateArgNums(1)
|
||||
cell := process.ArgsString(0)
|
||||
x, y, err := excelize.CellNameToCoordinates(cell)
|
||||
if err != nil {
|
||||
exception.New("excel.convert.cellnametocoordinates %s error: %s", 500, cell, err.Error()).Throw()
|
||||
}
|
||||
return []int{x, y}
|
||||
}
|
||||
|
||||
// processCoordinatesToCellName process the excel.convert.coordinatestocellname <col> <row>
|
||||
func processCoordinatesToCellName(process *process.Process) interface{} {
|
||||
process.ValidateArgNums(2)
|
||||
col := process.ArgsInt(0)
|
||||
row := process.ArgsInt(1)
|
||||
cell, err := excelize.CoordinatesToCellName(col, row)
|
||||
if err != nil {
|
||||
exception.New("excel.convert.coordinatestocellname %d,%d error: %s", 500, col, row, err.Error()).Throw()
|
||||
}
|
||||
return cell
|
||||
}
|
||||
|
||||
// processOpenRow process the excel.each.openrow <handle> <sheet>
|
||||
func processOpenRow(process *process.Process) interface{} {
|
||||
process.ValidateArgNums(2)
|
||||
handle := process.ArgsString(0)
|
||||
sheet := process.ArgsString(1)
|
||||
|
||||
xls, err := Get(handle)
|
||||
if err != nil {
|
||||
exception.New("excel.each.openrow %s error: %s", 500, handle, err.Error()).Throw()
|
||||
}
|
||||
|
||||
id, err := xls.OpenRow(sheet)
|
||||
if err != nil {
|
||||
exception.New("excel.each.openrow %s:%s error: %s", 500, handle, sheet, err.Error()).Throw()
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// processCloseRow process the excel.each.closerow <id>
|
||||
func processCloseRow(process *process.Process) interface{} {
|
||||
process.ValidateArgNums(1)
|
||||
id := process.ArgsString(0)
|
||||
|
||||
// Don't use return value from CloseRow
|
||||
CloseRow(id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// processNextRow process the excel.each.nextrow <id>
|
||||
func processNextRow(process *process.Process) interface{} {
|
||||
process.ValidateArgNums(1)
|
||||
id := process.ArgsString(0)
|
||||
|
||||
row, err := NextRow(id)
|
||||
if err != nil {
|
||||
CloseRow(id) // Discard return value
|
||||
exception.New("excel.each.nextrow %s error: %s", 500, id, err.Error()).Throw()
|
||||
}
|
||||
|
||||
if row == nil {
|
||||
CloseRow(id) // Discard return value
|
||||
return nil
|
||||
}
|
||||
|
||||
return row
|
||||
}
|
||||
|
||||
// processOpenColumn process the excel.each.opencolumn <handle> <sheet>
|
||||
func processOpenColumn(process *process.Process) interface{} {
|
||||
process.ValidateArgNums(2)
|
||||
handle := process.ArgsString(0)
|
||||
sheet := process.ArgsString(1)
|
||||
|
||||
xls, err := Get(handle)
|
||||
if err != nil {
|
||||
exception.New("excel.each.opencolumn %s error: %s", 500, handle, err.Error()).Throw()
|
||||
}
|
||||
|
||||
id, err := xls.OpenColumn(sheet)
|
||||
if err != nil {
|
||||
exception.New("excel.each.opencolumn %s:%s error: %s", 500, handle, sheet, err.Error()).Throw()
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// processCloseColumn process the excel.each.closecolumn <id>
|
||||
func processCloseColumn(process *process.Process) interface{} {
|
||||
process.ValidateArgNums(1)
|
||||
id := process.ArgsString(0)
|
||||
|
||||
// Don't use return value from CloseColumn
|
||||
CloseColumn(id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// processNextColumn process the excel.each.nextcolumn <id>
|
||||
func processNextColumn(process *process.Process) interface{} {
|
||||
process.ValidateArgNums(1)
|
||||
id := process.ArgsString(0)
|
||||
|
||||
col, err := NextColumn(id)
|
||||
if err != nil {
|
||||
CloseColumn(id) // Discard return value
|
||||
exception.New("excel.each.nextcolumn %s error: %s", 500, id, err.Error()).Throw()
|
||||
}
|
||||
|
||||
if col == nil {
|
||||
CloseColumn(id) // Discard return value
|
||||
return nil
|
||||
}
|
||||
|
||||
return col
|
||||
}
|
||||
|
||||
// processCreateSheet process the excel.sheet.create <handle> <name>
|
||||
func processCreateSheet(process *process.Process) interface{} {
|
||||
process.ValidateArgNums(2)
|
||||
handle := process.ArgsString(0)
|
||||
name := process.ArgsString(1)
|
||||
|
||||
xls, err := Get(handle)
|
||||
if err != nil {
|
||||
exception.New("excel.sheet.create %s error: %s", 500, handle, err.Error()).Throw()
|
||||
}
|
||||
|
||||
idx, err := xls.CreateSheet(name)
|
||||
if err != nil {
|
||||
exception.New("excel.sheet.create %s:%s error: %s", 500, handle, name, err.Error()).Throw()
|
||||
}
|
||||
return idx
|
||||
}
|
||||
|
||||
// processReadSheet process the excel.sheet.read <handle> <name>
|
||||
func processReadSheet(process *process.Process) interface{} {
|
||||
process.ValidateArgNums(2)
|
||||
handle := process.ArgsString(0)
|
||||
name := process.ArgsString(1)
|
||||
|
||||
xls, err := Get(handle)
|
||||
if err != nil {
|
||||
exception.New("excel.sheet.read %s error: %s", 500, handle, err.Error()).Throw()
|
||||
}
|
||||
|
||||
data, err := xls.ReadSheet(name)
|
||||
if err != nil {
|
||||
exception.New("excel.sheet.read %s:%s error: %s", 500, handle, name, err.Error()).Throw()
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// processUpdateSheet process the excel.sheet.update <handle> <name> <data>
|
||||
func processUpdateSheet(process *process.Process) interface{} {
|
||||
process.ValidateArgNums(3)
|
||||
handle := process.ArgsString(0)
|
||||
name := process.ArgsString(1)
|
||||
data := process.Args[2]
|
||||
|
||||
xls, err := Get(handle)
|
||||
if err != nil {
|
||||
exception.New("excel.sheet.update %s error: %s", 500, handle, err.Error()).Throw()
|
||||
}
|
||||
|
||||
// Convert data to [][]interface{}
|
||||
var sheetData [][]interface{}
|
||||
if arr, ok := data.([]interface{}); ok {
|
||||
for _, row := range arr {
|
||||
if rowArr, ok := row.([]interface{}); ok {
|
||||
sheetData = append(sheetData, rowArr)
|
||||
} else {
|
||||
sheetData = append(sheetData, []interface{}{row})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
sheetData = [][]interface{}{{data}}
|
||||
}
|
||||
|
||||
err = xls.UpdateSheet(name, sheetData)
|
||||
if err != nil {
|
||||
exception.New("excel.sheet.update %s:%s error: %s", 500, handle, name, err.Error()).Throw()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// processDeleteSheet process the excel.sheet.delete <handle> <name>
|
||||
func processDeleteSheet(process *process.Process) interface{} {
|
||||
process.ValidateArgNums(2)
|
||||
handle := process.ArgsString(0)
|
||||
name := process.ArgsString(1)
|
||||
|
||||
xls, err := Get(handle)
|
||||
if err != nil {
|
||||
exception.New("excel.sheet.delete %s error: %s", 500, handle, err.Error()).Throw()
|
||||
}
|
||||
|
||||
err = xls.DeleteSheet(name)
|
||||
if err != nil {
|
||||
exception.New("excel.sheet.delete %s:%s error: %s", 500, handle, name, err.Error()).Throw()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// processCopySheet process the excel.sheet.copy <handle> <source> <target>
|
||||
func processCopySheet(process *process.Process) interface{} {
|
||||
process.ValidateArgNums(3)
|
||||
handle := process.ArgsString(0)
|
||||
source := process.ArgsString(1)
|
||||
target := process.ArgsString(2)
|
||||
|
||||
xls, err := Get(handle)
|
||||
if err != nil {
|
||||
exception.New("excel.sheet.copy %s error: %s", 500, handle, err.Error()).Throw()
|
||||
}
|
||||
|
||||
err = xls.CopySheet(source, target)
|
||||
if err != nil {
|
||||
exception.New("excel.sheet.copy %s:%s:%s error: %s", 500, handle, source, target, err.Error()).Throw()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// processListSheets process the excel.sheet.list <handle>
|
||||
func processListSheets(process *process.Process) interface{} {
|
||||
process.ValidateArgNums(1)
|
||||
handle := process.ArgsString(0)
|
||||
|
||||
xls, err := Get(handle)
|
||||
if err != nil {
|
||||
exception.New("excel.sheet.list %s error: %s", 500, handle, err.Error()).Throw()
|
||||
}
|
||||
|
||||
return xls.ListSheets()
|
||||
}
|
||||
|
||||
// processSheetExists process the excel.sheet.exists <handle> <name>
|
||||
func processSheetExists(process *process.Process) interface{} {
|
||||
process.ValidateArgNums(2)
|
||||
handle := process.ArgsString(0)
|
||||
name := process.ArgsString(1)
|
||||
|
||||
xls, err := Get(handle)
|
||||
if err != nil {
|
||||
exception.New("excel.sheet.exists %s error: %s", 500, handle, err.Error()).Throw()
|
||||
}
|
||||
|
||||
return xls.SheetExists(name)
|
||||
}
|
||||
|
||||
// processReadSheetRows process the excel.sheet.rows <handle> <name> <start> <size>
|
||||
func processReadSheetRows(process *process.Process) interface{} {
|
||||
process.ValidateArgNums(4)
|
||||
handle := process.ArgsString(0)
|
||||
name := process.ArgsString(1)
|
||||
start := process.ArgsInt(2)
|
||||
size := process.ArgsInt(3)
|
||||
|
||||
xls, err := Get(handle)
|
||||
if err != nil {
|
||||
exception.New("excel.sheet.rows %s error: %s", 500, handle, err.Error()).Throw()
|
||||
}
|
||||
|
||||
data, err := xls.ReadSheetRows(name, start, size)
|
||||
if err != nil {
|
||||
exception.New("excel.sheet.rows %s:%s error: %s", 500, handle, name, err.Error()).Throw()
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// processGetSheetDimension process the excel.sheet.dimension <handle> <name>
|
||||
func processGetSheetDimension(process *process.Process) interface{} {
|
||||
process.ValidateArgNums(2)
|
||||
handle := process.ArgsString(0)
|
||||
name := process.ArgsString(1)
|
||||
|
||||
xls, err := Get(handle)
|
||||
if err != nil {
|
||||
exception.New("excel.sheet.dimension %s error: %s", 500, handle, err.Error()).Throw()
|
||||
}
|
||||
|
||||
rows, cols, err := xls.GetSheetDimension(name)
|
||||
if err != nil {
|
||||
exception.New("excel.sheet.dimension %s:%s error: %s", 500, handle, name, err.Error()).Throw()
|
||||
}
|
||||
|
||||
return map[string]int{
|
||||
"rows": rows,
|
||||
"cols": cols,
|
||||
}
|
||||
}
|
||||
1238
excel/process_test.go
Normal file
1238
excel/process_test.go
Normal file
File diff suppressed because it is too large
Load diff
255
excel/sheet.go
Normal file
255
excel/sheet.go
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
package excel
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/xuri/excelize/v2"
|
||||
)
|
||||
|
||||
// New creates a new Excel workbook
|
||||
func New() (*Excel, error) {
|
||||
f := excelize.NewFile()
|
||||
return &Excel{
|
||||
File: f,
|
||||
id: "",
|
||||
path: "",
|
||||
create: 0,
|
||||
abs: "",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// validateSheetName checks if the sheet name contains invalid characters
|
||||
func (excel *Excel) validateSheetName(name string) error {
|
||||
invalidChars := []string{":", "\\", "/", "?", "*", "[", "]"}
|
||||
for _, char := range invalidChars {
|
||||
if strings.Contains(name, char) {
|
||||
return fmt.Errorf("sheet name cannot contain any of these characters: :/?*[\\]")
|
||||
}
|
||||
}
|
||||
if len(name) == 0 {
|
||||
return fmt.Errorf("sheet name cannot be empty")
|
||||
}
|
||||
if len(name) > 31 {
|
||||
return fmt.Errorf("sheet name cannot be longer than 31 characters")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateSheet creates a new sheet with the given name
|
||||
// Returns the index of the new sheet and any error encountered
|
||||
func (excel *Excel) CreateSheet(name string) (int, error) {
|
||||
// Validate sheet name
|
||||
if err := excel.validateSheetName(name); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// Check if sheet already exists
|
||||
if idx, _ := excel.GetSheetIndex(name); idx != -1 {
|
||||
return 0, fmt.Errorf("sheet %s already exists", name)
|
||||
}
|
||||
|
||||
return excel.NewSheet(name)
|
||||
}
|
||||
|
||||
// ReadSheet reads all data from a sheet
|
||||
// Returns the data as a 2D array of interfaces and any error encountered
|
||||
func (excel *Excel) ReadSheet(name string) ([][]interface{}, error) {
|
||||
// Check if sheet exists
|
||||
if idx, _ := excel.GetSheetIndex(name); idx == -1 {
|
||||
return nil, fmt.Errorf("sheet %s does not exist", name)
|
||||
}
|
||||
|
||||
rows, err := excel.GetRows(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Convert [][]string to [][]interface{}
|
||||
result := make([][]interface{}, len(rows))
|
||||
for i, row := range rows {
|
||||
result[i] = make([]interface{}, len(row))
|
||||
for j, cell := range row {
|
||||
result[i][j] = cell
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetSheetDimension returns the number of rows and columns in a sheet
|
||||
func (excel *Excel) GetSheetDimension(name string) (rows int, cols int, err error) {
|
||||
// Check if sheet exists
|
||||
if idx, _ := excel.GetSheetIndex(name); idx == -1 {
|
||||
return 0, 0, fmt.Errorf("sheet %s does not exist", name)
|
||||
}
|
||||
rows = 0
|
||||
cols = 0
|
||||
ri, err := excel.File.Rows(name)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
defer ri.Close()
|
||||
for ri.Next() {
|
||||
rows++
|
||||
}
|
||||
|
||||
// Get column count
|
||||
ci, err := excel.File.Cols(name)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
for ci.Next() {
|
||||
cols++
|
||||
}
|
||||
return rows, cols, nil
|
||||
|
||||
}
|
||||
|
||||
// ReadSheetRows reads all data from a sheet by rows
|
||||
func (excel *Excel) ReadSheetRows(name string, start int, size int) ([][]string, error) {
|
||||
// Validate parameters
|
||||
if start < 0 {
|
||||
return nil, fmt.Errorf("start position cannot be negative")
|
||||
}
|
||||
if size < 0 {
|
||||
return nil, fmt.Errorf("size cannot be negative")
|
||||
}
|
||||
|
||||
// Check if sheet exists
|
||||
if idx, _ := excel.GetSheetIndex(name); idx == -1 {
|
||||
return nil, fmt.Errorf("sheet %s does not exist", name)
|
||||
}
|
||||
|
||||
// If size is 0, return empty slice
|
||||
if size == 0 {
|
||||
return [][]string{}, nil
|
||||
}
|
||||
|
||||
// Get rows iterator
|
||||
rows, err := excel.File.Rows(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
// Skip to start position
|
||||
currentRow := -1
|
||||
for rows.Next() {
|
||||
currentRow++
|
||||
if currentRow >= start {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Read requested number of rows
|
||||
result := make([][]string, 0, size)
|
||||
if currentRow == start {
|
||||
row, err := rows.Columns()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, row)
|
||||
}
|
||||
|
||||
for i := 1; i < size && rows.Next(); i++ {
|
||||
row, err := rows.Columns()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, row)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// UpdateSheet updates an existing sheet with new data
|
||||
// If the sheet doesn't exist, it will be created
|
||||
func (excel *Excel) UpdateSheet(name string, data [][]interface{}) error {
|
||||
// Validate sheet name
|
||||
if err := excel.validateSheetName(name); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Ensure sheet exists
|
||||
_, err := excel.SetSheet(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Clear existing content by deleting the sheet
|
||||
err = excel.DeleteSheet(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create new sheet with same name
|
||||
_, err = excel.NewSheet(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Write new data
|
||||
return excel.WriteAll(name, "A1", data)
|
||||
}
|
||||
|
||||
// DeleteSheet removes a sheet by name
|
||||
func (excel *Excel) DeleteSheet(name string) error {
|
||||
// Check if sheet exists
|
||||
if idx, _ := excel.GetSheetIndex(name); idx == -1 {
|
||||
return fmt.Errorf("sheet %s does not exist", name)
|
||||
}
|
||||
|
||||
return excel.File.DeleteSheet(name)
|
||||
}
|
||||
|
||||
// ListSheets returns a list of all sheet names in the workbook
|
||||
func (excel *Excel) ListSheets() []string {
|
||||
return excel.GetSheetList()
|
||||
}
|
||||
|
||||
// SheetExists checks if a sheet exists in the workbook
|
||||
func (excel *Excel) SheetExists(name string) bool {
|
||||
idx, _ := excel.GetSheetIndex(name)
|
||||
return idx != -1
|
||||
}
|
||||
|
||||
// CopySheet copies a sheet to a new name
|
||||
func (excel *Excel) CopySheet(source, destination string) error {
|
||||
// Validate destination sheet name
|
||||
if err := excel.validateSheetName(destination); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if source exists
|
||||
if idx, _ := excel.GetSheetIndex(source); idx == -1 {
|
||||
return fmt.Errorf("source sheet %s does not exist", source)
|
||||
}
|
||||
|
||||
// Check if destination already exists
|
||||
if idx, _ := excel.GetSheetIndex(destination); idx != -1 {
|
||||
return fmt.Errorf("destination sheet %s already exists", destination)
|
||||
}
|
||||
|
||||
// Create new sheet
|
||||
_, err := excel.NewSheet(destination)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Copy content
|
||||
rows, err := excel.GetRows(source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Convert [][]string to [][]interface{}
|
||||
data := make([][]interface{}, len(rows))
|
||||
for i, row := range rows {
|
||||
data[i] = make([]interface{}, len(row))
|
||||
for j, cell := range row {
|
||||
data[i][j] = cell
|
||||
}
|
||||
}
|
||||
|
||||
return excel.WriteAll(destination, "A1", data)
|
||||
}
|
||||
299
excel/sheet_test.go
Normal file
299
excel/sheet_test.go
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
package excel
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestSheetOperations(t *testing.T) {
|
||||
// Get test files and open the test file
|
||||
files := testFiles(t)
|
||||
handler, err := Open(files["test-01"], true) // Open in writable mode
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer Close(handler)
|
||||
|
||||
excel, err := Get(handler)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Test CreateSheet
|
||||
t.Run("CreateSheet", func(t *testing.T) {
|
||||
// Create a new sheet
|
||||
idx, err := excel.CreateSheet("TestSheet1")
|
||||
assert.NoError(t, err)
|
||||
assert.Greater(t, idx, 0)
|
||||
|
||||
// Try to create a sheet with the same name (should fail)
|
||||
_, err = excel.CreateSheet("TestSheet1")
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
// Test ReadSheet
|
||||
t.Run("ReadSheet", func(t *testing.T) {
|
||||
// Create test data
|
||||
testData := [][]interface{}{
|
||||
{"Header1", "Header2"},
|
||||
{1, "Data1"},
|
||||
{2, "Data2"},
|
||||
}
|
||||
|
||||
// Write test data
|
||||
err := excel.WriteAll("TestSheet1", "A1", testData)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Read the data back
|
||||
data, err := excel.ReadSheet("TestSheet1")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, len(testData), len(data))
|
||||
|
||||
// Try to read non-existent sheet
|
||||
_, err = excel.ReadSheet("NonExistentSheet")
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
// Test UpdateSheet
|
||||
t.Run("UpdateSheet", func(t *testing.T) {
|
||||
newData := [][]interface{}{
|
||||
{"NewHeader1", "NewHeader2"},
|
||||
{3, "NewData1"},
|
||||
{4, "NewData2"},
|
||||
}
|
||||
|
||||
// Update existing sheet
|
||||
err := excel.UpdateSheet("TestSheet1", newData)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Read back and verify
|
||||
data, err := excel.ReadSheet("TestSheet1")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, len(newData), len(data))
|
||||
|
||||
// Update non-existent sheet (should create new)
|
||||
err = excel.UpdateSheet("NewSheet", newData)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
// Test ListSheets
|
||||
t.Run("ListSheets", func(t *testing.T) {
|
||||
sheets := excel.ListSheets()
|
||||
assert.Contains(t, sheets, "TestSheet1")
|
||||
assert.Contains(t, sheets, "NewSheet")
|
||||
})
|
||||
|
||||
// Test SheetExists
|
||||
t.Run("SheetExists", func(t *testing.T) {
|
||||
// Check existing sheet
|
||||
exists := excel.SheetExists("TestSheet1")
|
||||
assert.True(t, exists)
|
||||
|
||||
// Check non-existent sheet
|
||||
exists = excel.SheetExists("NonExistentSheet")
|
||||
assert.False(t, exists)
|
||||
})
|
||||
|
||||
// Test CopySheet
|
||||
t.Run("CopySheet", func(t *testing.T) {
|
||||
// Copy existing sheet
|
||||
err := excel.CopySheet("TestSheet1", "CopiedSheet")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify the copy
|
||||
originalData, err := excel.ReadSheet("TestSheet1")
|
||||
assert.NoError(t, err)
|
||||
copiedData, err := excel.ReadSheet("CopiedSheet")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, originalData, copiedData)
|
||||
|
||||
// Try to copy to existing sheet name (should fail)
|
||||
err = excel.CopySheet("TestSheet1", "CopiedSheet")
|
||||
assert.Error(t, err)
|
||||
|
||||
// Try to copy non-existent sheet (should fail)
|
||||
err = excel.CopySheet("NonExistentSheet", "NewSheet2")
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
// Test DeleteSheet
|
||||
t.Run("DeleteSheet", func(t *testing.T) {
|
||||
// Delete existing sheet
|
||||
err := excel.DeleteSheet("CopiedSheet")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify sheet is deleted
|
||||
sheets := excel.ListSheets()
|
||||
assert.NotContains(t, sheets, "CopiedSheet")
|
||||
|
||||
// Try to delete non-existent sheet
|
||||
err = excel.DeleteSheet("NonExistentSheet")
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
// Test ReadSheetRows
|
||||
t.Run("ReadSheetRows", func(t *testing.T) {
|
||||
// Create test data with 10 rows
|
||||
testData := [][]interface{}{
|
||||
{"Header1", "Header2", "Header3"},
|
||||
{1, "Row1", true},
|
||||
{2, "Row2", false},
|
||||
{3, "Row3", true},
|
||||
{4, "Row4", false},
|
||||
{5, "Row5", true},
|
||||
{6, "Row6", false},
|
||||
{7, "Row7", true},
|
||||
{8, "Row8", false},
|
||||
{9, "Row9", true},
|
||||
}
|
||||
|
||||
// Expected string data
|
||||
expectedData := [][]string{
|
||||
{"Header1", "Header2", "Header3"},
|
||||
{"1", "Row1", "TRUE"},
|
||||
{"2", "Row2", "FALSE"},
|
||||
{"3", "Row3", "TRUE"},
|
||||
{"4", "Row4", "FALSE"},
|
||||
{"5", "Row5", "TRUE"},
|
||||
{"6", "Row6", "FALSE"},
|
||||
{"7", "Row7", "TRUE"},
|
||||
{"8", "Row8", "FALSE"},
|
||||
{"9", "Row9", "TRUE"},
|
||||
}
|
||||
|
||||
// Create a new sheet for testing
|
||||
_, err := excel.CreateSheet("RowTestSheet")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Write test data
|
||||
err = excel.WriteAll("RowTestSheet", "A1", testData)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Test 1: Read from middle (start at row 2, read 4 rows)
|
||||
data, err := excel.ReadSheetRows("RowTestSheet", 2, 4)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 4, len(data))
|
||||
assert.Equal(t, expectedData[2:6], data)
|
||||
|
||||
// Test 2: Read from beginning (start at row 0, read 3 rows)
|
||||
data, err = excel.ReadSheetRows("RowTestSheet", 0, 3)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 3, len(data))
|
||||
assert.Equal(t, expectedData[0:3], data)
|
||||
|
||||
// Test 3: Read beyond available rows (should return remaining rows)
|
||||
data, err = excel.ReadSheetRows("RowTestSheet", 8, 5)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 2, len(data)) // Only 2 rows remain
|
||||
assert.Equal(t, expectedData[8:], data)
|
||||
|
||||
// Test 4: Read from non-existent sheet
|
||||
_, err = excel.ReadSheetRows("NonExistentSheet", 0, 5)
|
||||
assert.Error(t, err)
|
||||
|
||||
// Test 5: Read with size 0 (should return empty slice)
|
||||
data, err = excel.ReadSheetRows("RowTestSheet", 0, 0)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 0, len(data))
|
||||
|
||||
// Test 6: Read with negative start (should return error)
|
||||
_, err = excel.ReadSheetRows("RowTestSheet", -1, 5)
|
||||
assert.Error(t, err)
|
||||
|
||||
// Test 7: Read with negative size (should return error)
|
||||
_, err = excel.ReadSheetRows("RowTestSheet", 0, -1)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
// Test GetSheetDimension
|
||||
t.Run("GetSheetDimension", TestGetSheetDimension)
|
||||
}
|
||||
|
||||
// TestGetSheetDimension tests the GetSheetDimension function
|
||||
func TestGetSheetDimension(t *testing.T) {
|
||||
// Get test files and open the test file
|
||||
files := testFiles(t)
|
||||
filename := filepath.Dir(files["test-01"]) + "/test-dimension.xlsx"
|
||||
handler, err := Open(filename, true) // Open in writable mode
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer Close(handler)
|
||||
|
||||
excel, err := Get(handler)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Clean up existing sheets
|
||||
sheets := excel.ListSheets()
|
||||
for _, sheet := range sheets {
|
||||
if sheet != "Sheet1" { // Keep the default sheet
|
||||
err = excel.DeleteSheet(sheet)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Test 1: Create a large sheet (100x100)
|
||||
_, err = excel.CreateSheet("LargeSheet")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Create test data (100x100)
|
||||
largeData := make([][]interface{}, 100)
|
||||
for i := 0; i < 100; i++ {
|
||||
largeData[i] = make([]interface{}, 100)
|
||||
for j := 0; j < 100; j++ {
|
||||
largeData[i][j] = fmt.Sprintf("Cell_%d_%d", i, j)
|
||||
}
|
||||
}
|
||||
err = excel.WriteAll("LargeSheet", "A1", largeData)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Save file to ensure dimensions are updated
|
||||
err = excel.Save()
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Test large sheet dimensions
|
||||
rows, cols, err := excel.GetSheetDimension("LargeSheet")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 100, rows)
|
||||
assert.Equal(t, 100, cols)
|
||||
|
||||
// Test 2: Empty sheet
|
||||
if excel.SheetExists("EmptySheet") {
|
||||
excel.DeleteSheet("EmptySheet")
|
||||
}
|
||||
_, err = excel.CreateSheet("EmptySheet")
|
||||
assert.NoError(t, err)
|
||||
rows, cols, err = excel.GetSheetDimension("EmptySheet")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 0, rows)
|
||||
assert.Equal(t, 0, cols)
|
||||
|
||||
// Test 3: Regular sheet with data
|
||||
testData := [][]interface{}{
|
||||
{"A1", "B1", "C1"},
|
||||
{"A2", "B2", "C2"},
|
||||
{"A3", "B3", "C3"},
|
||||
}
|
||||
err = excel.WriteAll("RegularSheet", "A1", testData)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Save file to ensure dimensions are updated
|
||||
err = excel.Save()
|
||||
assert.NoError(t, err)
|
||||
|
||||
rows, cols, err = excel.GetSheetDimension("RegularSheet")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 3, rows)
|
||||
assert.Equal(t, 3, cols)
|
||||
|
||||
// Test 4: Non-existent sheet
|
||||
rows, cols, err = excel.GetSheetDimension("NonExistentSheet")
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, 0, rows)
|
||||
assert.Equal(t, 0, cols)
|
||||
}
|
||||
95
excel/write.go
Normal file
95
excel/write.go
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
package excel
|
||||
|
||||
import (
|
||||
"github.com/xuri/excelize/v2"
|
||||
)
|
||||
|
||||
// WriteCell write the cell
|
||||
func (excel *Excel) WriteCell(sheet string, cell string, value interface{}) error {
|
||||
|
||||
_, err := excel.SetSheet(sheet)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return excel.SetCellValue(sheet, cell, value)
|
||||
}
|
||||
|
||||
// WriteRow write the row
|
||||
func (excel *Excel) WriteRow(sheet string, cell string, value []interface{}) error {
|
||||
|
||||
_, err := excel.SetSheet(sheet)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return excel.SetSheetRow(sheet, cell, &value)
|
||||
}
|
||||
|
||||
// WriteColumn write the column
|
||||
func (excel *Excel) WriteColumn(sheet string, cell string, value []interface{}) error {
|
||||
|
||||
_, err := excel.SetSheet(sheet)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return excel.SetSheetCol(sheet, cell, &value)
|
||||
}
|
||||
|
||||
// WriteAll write all the sheet
|
||||
func (excel *Excel) WriteAll(sheet string, cell string, rows [][]interface{}) error {
|
||||
|
||||
// Check if sheet exists
|
||||
idx, err := excel.GetSheetIndex(sheet)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if idx == -1 {
|
||||
// Create new sheet if it doesn't exist
|
||||
idx, err = excel.NewSheet(sheet)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// If no data to write, return
|
||||
if len(rows) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Write each row
|
||||
currentCell := cell
|
||||
for _, row := range rows {
|
||||
if err := excel.SetSheetRow(sheet, currentCell, &row); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Move to next row
|
||||
colIndex, rowIndex, err := excelize.CellNameToCoordinates(currentCell)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
currentCell, err = excelize.CoordinatesToCellName(colIndex, rowIndex+1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetSheet set the sheet
|
||||
func (excel *Excel) SetSheet(name string) (int, error) {
|
||||
|
||||
idx, err := excel.GetSheetIndex(name)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if idx == -1 {
|
||||
idx, err = excel.NewSheet(name)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
return idx, nil
|
||||
}
|
||||
164
excel/write_test.go
Normal file
164
excel/write_test.go
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
package excel
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/xuri/excelize/v2"
|
||||
"github.com/yaoapp/yao/config"
|
||||
"github.com/yaoapp/yao/test"
|
||||
)
|
||||
|
||||
func TestWriteAll(t *testing.T) {
|
||||
test.Prepare(t, config.Conf)
|
||||
defer test.Clean()
|
||||
|
||||
// Create a new Excel file
|
||||
xls := excelize.NewFile()
|
||||
defer func() {
|
||||
if err := xls.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Create Excel instance
|
||||
excel := &Excel{
|
||||
File: xls,
|
||||
abs: "test.xlsx",
|
||||
}
|
||||
|
||||
t.Run("Write to default sheet", func(t *testing.T) {
|
||||
data := [][]interface{}{
|
||||
{"Header1", "Header2", "Header3"},
|
||||
{1, "Data1", true},
|
||||
{2, "Data2", false},
|
||||
}
|
||||
|
||||
err := excel.WriteAll("Sheet1", "A1", data)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify data was written
|
||||
rows, err := excel.GetRows("Sheet1")
|
||||
assert.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, len(rows), 3)
|
||||
assert.Equal(t, "Header1", rows[0][0])
|
||||
assert.Equal(t, "Header2", rows[0][1])
|
||||
assert.Equal(t, "Header3", rows[0][2])
|
||||
})
|
||||
|
||||
t.Run("Write to new sheet", func(t *testing.T) {
|
||||
data := [][]interface{}{
|
||||
{"Name", "Age", "Active"},
|
||||
{"John", 30, true},
|
||||
{"Jane", 25, false},
|
||||
}
|
||||
|
||||
// Verify sheet doesn't exist before writing
|
||||
sheets := excel.ListSheets()
|
||||
assert.NotContains(t, sheets, "NewSheet")
|
||||
|
||||
err := excel.WriteAll("NewSheet", "B2", data)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify sheet was created
|
||||
sheets = excel.ListSheets()
|
||||
assert.Contains(t, sheets, "NewSheet")
|
||||
|
||||
// Verify data was written
|
||||
rows, err := excel.GetRows("NewSheet")
|
||||
assert.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, len(rows), 4) // Account for B2 start position
|
||||
assert.Equal(t, "Name", rows[1][1]) // B2 position
|
||||
assert.Equal(t, "Age", rows[1][2])
|
||||
assert.Equal(t, "Active", rows[1][3])
|
||||
})
|
||||
|
||||
t.Run("Write empty data to new sheet", func(t *testing.T) {
|
||||
var data [][]interface{}
|
||||
|
||||
// Verify sheet doesn't exist before writing
|
||||
sheets := excel.ListSheets()
|
||||
assert.NotContains(t, sheets, "EmptySheet")
|
||||
|
||||
err := excel.WriteAll("EmptySheet", "A1", data)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify sheet was created but is empty
|
||||
sheets = excel.ListSheets()
|
||||
assert.Contains(t, sheets, "EmptySheet")
|
||||
|
||||
rows, err := excel.GetRows("EmptySheet")
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, rows)
|
||||
})
|
||||
|
||||
t.Run("Write empty data to existing sheet", func(t *testing.T) {
|
||||
// First write some data
|
||||
data := [][]interface{}{
|
||||
{"Test"},
|
||||
}
|
||||
err := excel.WriteAll("ExistingSheet", "A1", data)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Then write empty data
|
||||
var emptyData [][]interface{}
|
||||
err = excel.WriteAll("ExistingSheet", "A1", emptyData)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify original data remains
|
||||
rows, err := excel.GetRows("ExistingSheet")
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, rows)
|
||||
assert.Equal(t, "Test", rows[0][0])
|
||||
})
|
||||
|
||||
t.Run("Write with invalid cell reference", func(t *testing.T) {
|
||||
data := [][]interface{}{
|
||||
{"Test"},
|
||||
}
|
||||
err := excel.WriteAll("InvalidCell", "INVALID", data)
|
||||
assert.Error(t, err)
|
||||
|
||||
// Verify sheet was still created despite error
|
||||
sheets := excel.ListSheets()
|
||||
assert.Contains(t, sheets, "InvalidCell")
|
||||
})
|
||||
|
||||
t.Run("Write to sheet with special characters", func(t *testing.T) {
|
||||
// Valid sheet name with allowed special characters
|
||||
data := [][]interface{}{
|
||||
{"Special"},
|
||||
}
|
||||
err := excel.WriteAll("Sheet-123_中文", "A1", data)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify sheet was created and data written
|
||||
sheets := excel.ListSheets()
|
||||
assert.Contains(t, sheets, "Sheet-123_中文")
|
||||
|
||||
rows, err := excel.GetRows("Sheet-123_中文")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "Special", rows[0][0])
|
||||
|
||||
// Invalid sheet names
|
||||
invalidNames := []string{
|
||||
"Sheet:1",
|
||||
"Sheet/2",
|
||||
"Sheet\\3",
|
||||
"Sheet?4",
|
||||
"Sheet*5",
|
||||
"Sheet[6]",
|
||||
"", // Empty name
|
||||
"ThisSheetNameIsWayTooLongAndShouldFailBecauseExcelHasALimitOf31Characters", // Too long
|
||||
}
|
||||
|
||||
for _, name := range invalidNames {
|
||||
err := excel.WriteAll(name, "A1", data)
|
||||
assert.Error(t, err, "Should fail for invalid sheet name: %s", name)
|
||||
}
|
||||
})
|
||||
|
||||
// Optional: Save the file for manual inspection
|
||||
// err := excel.SaveAs("test_output.xlsx")
|
||||
// assert.NoError(t, err)
|
||||
}
|
||||
10
flow/flow.go
10
flow/flow.go
|
|
@ -9,6 +9,16 @@ import (
|
|||
|
||||
// Load 加载业务逻辑编排
|
||||
func Load(cfg config.Config) error {
|
||||
|
||||
// Ignore if the flows directory does not exist
|
||||
exists, err := application.App.Exists("flows")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
return nil
|
||||
}
|
||||
|
||||
exts := []string{"*.flow.yao", "*.flow.json", "*.flow.jsonc"}
|
||||
return application.App.Walk("flows", func(root, file string, isdir bool) error {
|
||||
if isdir {
|
||||
|
|
|
|||
146
go.mod
146
go.mod
|
|
@ -1,39 +1,45 @@
|
|||
module github.com/yaoapp/yao
|
||||
|
||||
go 1.23
|
||||
go 1.23.0
|
||||
|
||||
toolchain go1.23.4
|
||||
|
||||
require (
|
||||
github.com/PuerkitoBio/goquery v1.10.1
|
||||
github.com/aws/aws-sdk-go-v2 v1.32.7
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.48
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.71.1
|
||||
github.com/PuerkitoBio/goquery v1.10.3
|
||||
github.com/aws/aws-sdk-go-v2 v1.36.3
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.67
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.79.3
|
||||
github.com/blang/semver v3.5.1+incompatible
|
||||
github.com/caarlos0/env/v6 v6.10.1
|
||||
github.com/dchest/captcha v1.1.0
|
||||
github.com/elazarl/go-bindata-assetfs v1.0.1
|
||||
github.com/evanw/esbuild v0.24.2
|
||||
github.com/expr-lang/expr v1.16.9
|
||||
github.com/evanw/esbuild v0.25.4
|
||||
github.com/expr-lang/expr v1.17.3
|
||||
github.com/fatih/color v1.18.0
|
||||
github.com/fsnotify/fsnotify v1.8.0
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible
|
||||
github.com/fsnotify/fsnotify v1.9.0
|
||||
github.com/gin-gonic/gin v1.10.1
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/hashicorp/go-multierror v1.1.1
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/json-iterator/go v1.1.12
|
||||
github.com/kaptinlin/jsonrepair v0.1.1
|
||||
github.com/matoous/go-nanoid/v2 v2.1.0
|
||||
github.com/mozillazg/go-pinyin v0.20.0
|
||||
github.com/pkoukk/tiktoken-go v0.1.7
|
||||
github.com/pquerna/otp v1.5.0
|
||||
github.com/rhysd/go-github-selfupdate v1.2.3
|
||||
github.com/spf13/cast v1.7.1
|
||||
github.com/spf13/cobra v1.8.1
|
||||
github.com/spf13/cast v1.9.2
|
||||
github.com/spf13/cobra v1.9.1
|
||||
github.com/stretchr/testify v1.10.0
|
||||
github.com/watchfultele/jsonrepair v0.0.0-20250207052432-e4397ed42611
|
||||
github.com/xuri/excelize/v2 v2.9.0
|
||||
github.com/xuri/excelize/v2 v2.9.1
|
||||
github.com/yaoapp/gou v0.10.3
|
||||
github.com/yaoapp/kun v0.9.0
|
||||
github.com/yaoapp/xun v0.9.0
|
||||
golang.org/x/crypto v0.31.0
|
||||
golang.org/x/net v0.33.0
|
||||
golang.org/x/text v0.21.0
|
||||
go.mongodb.org/mongo-driver v1.17.3
|
||||
golang.org/x/crypto v0.39.0
|
||||
golang.org/x/net v0.41.0
|
||||
golang.org/x/text v0.27.0
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
rogchap.com/v8go v0.9.0
|
||||
|
|
@ -43,69 +49,83 @@ require (
|
|||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2 // indirect
|
||||
github.com/andybalholm/cascadia v1.3.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.7 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.26 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.26 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.26 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.7 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.7 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.7 // indirect
|
||||
github.com/aws/smithy-go v1.22.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15 // indirect
|
||||
github.com/aws/smithy-go v1.22.3 // indirect
|
||||
github.com/blang/semver/v4 v4.0.0 // indirect
|
||||
github.com/bytedance/sonic v1.12.6 // indirect
|
||||
github.com/bytedance/sonic/loader v0.2.1 // indirect
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
||||
github.com/bytedance/sonic v1.13.2 // indirect
|
||||
github.com/bytedance/sonic/loader v0.2.4 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.5 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dgraph-io/badger/v4 v4.7.0 // indirect
|
||||
github.com/dgraph-io/ristretto/v2 v2.2.0 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/dlclark/regexp2 v1.11.4 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.7 // indirect
|
||||
github.com/gin-contrib/sse v1.0.0 // indirect
|
||||
github.com/dlclark/regexp2 v1.11.5 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-errors/errors v1.5.1 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.23.0 // indirect
|
||||
github.com/go-playground/validator/v10 v10.26.0 // indirect
|
||||
github.com/go-redis/redis/v8 v8.11.5 // indirect
|
||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
|
||||
github.com/go-sql-driver/mysql v1.8.1 // indirect
|
||||
github.com/goccy/go-json v0.10.4 // indirect
|
||||
github.com/go-sql-driver/mysql v1.9.2 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/golang/protobuf v1.5.4 // indirect
|
||||
github.com/golang/snappy v0.0.4 // indirect
|
||||
github.com/golang/snappy v1.0.0 // indirect
|
||||
github.com/google/flatbuffers v25.2.10+incompatible // indirect
|
||||
github.com/google/go-github/v30 v30.1.0 // indirect
|
||||
github.com/google/go-querystring v1.1.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
github.com/hashicorp/go-hclog v1.6.3 // indirect
|
||||
github.com/hashicorp/go-plugin v1.6.2 // indirect
|
||||
github.com/hashicorp/go-plugin v1.6.3 // indirect
|
||||
github.com/hashicorp/golang-lru v1.0.2 // indirect
|
||||
github.com/hashicorp/yamux v0.1.2 // indirect
|
||||
github.com/hhrutter/lzw v1.0.0 // indirect
|
||||
github.com/hhrutter/pkcs7 v0.2.0 // indirect
|
||||
github.com/hhrutter/tiff v1.0.2 // indirect
|
||||
github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jmoiron/sqlx v1.4.0 // indirect
|
||||
github.com/klauspost/compress v1.17.11 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/lib/pq v1.10.9 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mark3labs/mcp-go v0.32.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.24 // indirect
|
||||
github.com/miekg/dns v1.1.62 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.28 // indirect
|
||||
github.com/miekg/dns v1.1.66 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
|
||||
github.com/montanaflynn/stats v0.7.1 // indirect
|
||||
github.com/neo4j/neo4j-go-driver/v5 v5.28.1 // indirect
|
||||
github.com/oklog/run v1.1.0 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
|
||||
github.com/pdfcpu/pdfcpu v0.11.0 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/qdrant/go-client v1.12.0 // indirect
|
||||
github.com/qdrant/go-client v1.14.0 // indirect
|
||||
github.com/richardlehane/mscfb v1.0.4 // indirect
|
||||
github.com/richardlehane/msoleps v1.0.4 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||
github.com/sergi/go-diff v1.3.1 // indirect
|
||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
github.com/spf13/pflag v1.0.6 // indirect
|
||||
github.com/tcnksm/go-gitconfig v0.1.2 // indirect
|
||||
github.com/tidwall/btree v1.7.0 // indirect
|
||||
github.com/tidwall/buntdb v1.3.2 // indirect
|
||||
|
|
@ -115,26 +135,32 @@ require (
|
|||
github.com/tidwall/pretty v1.2.1 // indirect
|
||||
github.com/tidwall/rtred v0.1.2 // indirect
|
||||
github.com/tidwall/tinyqueue v0.1.1 // indirect
|
||||
github.com/tiendc/go-deepcopy v1.6.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
github.com/ulikunitz/xz v0.5.12 // indirect
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||
github.com/xdg-go/scram v1.1.2 // indirect
|
||||
github.com/xdg-go/stringprep v1.0.4 // indirect
|
||||
github.com/xuri/efp v0.0.0-20241211021726-c4e992084aa6 // indirect
|
||||
github.com/xuri/nfp v0.0.0-20240318013403-ab9948c2c4a7 // indirect
|
||||
github.com/xuri/efp v0.0.1 // indirect
|
||||
github.com/xuri/nfp v0.0.1 // indirect
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||
go.mongodb.org/mongo-driver v1.17.1 // indirect
|
||||
golang.org/x/arch v0.12.0 // indirect
|
||||
golang.org/x/image v0.23.0 // indirect
|
||||
golang.org/x/mod v0.22.0 // indirect
|
||||
golang.org/x/oauth2 v0.24.0 // indirect
|
||||
golang.org/x/sync v0.10.0 // indirect
|
||||
golang.org/x/sys v0.28.0 // indirect
|
||||
golang.org/x/tools v0.28.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20241230172942-26aa7a208def // indirect
|
||||
google.golang.org/grpc v1.69.2 // indirect
|
||||
google.golang.org/protobuf v1.36.1 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||
go.opentelemetry.io/otel v1.37.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.37.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.37.0 // indirect
|
||||
golang.org/x/arch v0.17.0 // indirect
|
||||
golang.org/x/image v0.29.0 // indirect
|
||||
golang.org/x/mod v0.25.0 // indirect
|
||||
golang.org/x/oauth2 v0.30.0 // indirect
|
||||
golang.org/x/sync v0.16.0 // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
golang.org/x/tools v0.34.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237 // indirect
|
||||
google.golang.org/grpc v1.72.1 // indirect
|
||||
google.golang.org/protobuf v1.36.6 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
)
|
||||
|
||||
// go env -w GOPRIVATE=github.com/yaoapp/*
|
||||
|
|
|
|||
309
go.sum
309
go.sum
|
|
@ -1,88 +1,98 @@
|
|||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/PuerkitoBio/goquery v1.10.1 h1:Y8JGYUkXWTGRB6Ars3+j3kN0xg1YqqlwvdTV8WTFQcU=
|
||||
github.com/PuerkitoBio/goquery v1.10.1/go.mod h1:IYiHrOMps66ag56LEH7QYDDupKXyo5A8qrjIx3ZtujY=
|
||||
github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo=
|
||||
github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y=
|
||||
github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2 h1:ZBbLwSJqkHBuFDA6DUhhse0IGJ7T5bemHyNILUjvOq4=
|
||||
github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2/go.mod h1:VSw57q4QFiWDbRnjdX8Cb3Ow0SFncRw+bA/ofY6Q83w=
|
||||
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
||||
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
||||
github.com/aws/aws-sdk-go-v2 v1.32.7 h1:ky5o35oENWi0JYWUZkB7WYvVPP+bcRF5/Iq7JWSb5Rw=
|
||||
github.com/aws/aws-sdk-go-v2 v1.32.7/go.mod h1:P5WJBrYqqbWVaOxgH0X/FYYD47/nooaPOZPlQdmiN2U=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.7 h1:lL7IfaFzngfx0ZwUGOZdsFFnQ5uLvR0hWqqhyE7Q9M8=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.7/go.mod h1:QraP0UcVlQJsmHfioCrveWOC1nbiWUl3ej08h4mXWoc=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.48 h1:IYdLD1qTJ0zanRavulofmqut4afs45mOWEI+MzZtTfQ=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.48/go.mod h1:tOscxHN3CGmuX9idQ3+qbkzrjVIx32lqDSU1/0d/qXs=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.26 h1:I/5wmGMffY4happ8NOCuIUEWGUvvFp5NSeQcXl9RHcI=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.26/go.mod h1:FR8f4turZtNy6baO0KJ5FJUmXH/cSkI9fOngs0yl6mA=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.26 h1:zXFLuEuMMUOvEARXFUVJdfqZ4bvvSgdGRq/ATcrQxzM=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.26/go.mod h1:3o2Wpy0bogG1kyOPrgkXA8pgIfEEv0+m19O9D5+W8y8=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.26 h1:GeNJsIFHB+WW5ap2Tec4K6dzcVTsRbsT1Lra46Hv9ME=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.26/go.mod h1:zfgMpwHDXX2WGoG84xG2H+ZlPTkJUU4YUvx2svLQYWo=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1 h1:iXtILhvDxB6kPvEXgsDhGaZCSC6LQET5ZHSdJozeI0Y=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1/go.mod h1:9nu0fVANtYiAePIBh2/pFUSwtJ402hLnp854CNoDOeE=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.7 h1:tB4tNw83KcajNAzaIMhkhVI2Nt8fAZd5A5ro113FEMY=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.7/go.mod h1:lvpyBGkZ3tZ9iSsUIcC2EWp+0ywa7aK3BLT+FwZi+mQ=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.7 h1:8eUsivBQzZHqe/3FE+cqwfH+0p5Jo8PFM/QYQSmeZ+M=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.7/go.mod h1:kLPQvGUmxn/fqiCrDeohwG33bq2pQpGeY62yRO6Nrh0=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.7 h1:Hi0KGbrnr57bEHWM0bJ1QcBzxLrL/k2DHvGYhb8+W1w=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.7/go.mod h1:wKNgWgExdjjrm4qvfbTorkvocEstaoDl4WCvGfeCy9c=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.71.1 h1:aOVVZJgWbaH+EJYPvEgkNhCEbXXvH7+oML36oaPK3zE=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.71.1/go.mod h1:r+xl5yzMk9083rMR+sJ5TYj9Tihvf/l1oxzZXDgGj2Q=
|
||||
github.com/aws/smithy-go v1.22.1 h1:/HPHZQ0g7f4eUeK6HKglFz8uwVfZKgoI25rb/J+dnro=
|
||||
github.com/aws/smithy-go v1.22.1/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg=
|
||||
github.com/aws/aws-sdk-go-v2 v1.36.3 h1:mJoei2CxPutQVxaATCzDUjcZEjVRdpsiiXi2o38yqWM=
|
||||
github.com/aws/aws-sdk-go-v2 v1.36.3/go.mod h1:LLXuLpgzEbD766Z5ECcRmi8AzSwfZItDtmABVkRLGzg=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10 h1:zAybnyUQXIZ5mok5Jqwlf58/TFE7uvd3IAsa1aF9cXs=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10/go.mod h1:qqvMj6gHLR/EXWZw4ZbqlPbQUyenf4h82UQUlKc+l14=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.67 h1:9KxtdcIA/5xPNQyZRgUSpYOE6j9Bc4+D7nZua0KGYOM=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.67/go.mod h1:p3C44m+cfnbv763s52gCqrjaqyPikj9Sg47kUVaNZQQ=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 h1:ZK5jHhnrioRkUNOc+hOgQKlUL5JeC3S6JgLxtQ+Rm0Q=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34/go.mod h1:p4VfIceZokChbA9FzMbRGz5OV+lekcVtHlPKEO0gSZY=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 h1:SZwFm17ZUNNg5Np0ioo/gq8Mn6u9w19Mri8DnJ15Jf0=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34/go.mod h1:dFZsC0BLo346mvKQLWmoJxT+Sjp+qcVR1tRVHQGOH9Q=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34 h1:ZNTqv4nIdE/DiBfUUfXcLZ/Spcuz+RjeziUtNJackkM=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34/go.mod h1:zf7Vcd1ViW7cPqYWEHLHJkS50X0JS2IKz9Cgaj6ugrs=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 h1:eAh2A4b5IzM/lum78bZ590jy36+d/aFLgKF/4Vd1xPE=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3/go.mod h1:0yKJC/kb8sAnmlYa6Zs3QVYqaC8ug2AbnNChv5Ox3uA=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.1 h1:4nm2G6A4pV9rdlWzGMPv4BNtQp22v1hg3yrtkYpeLl8=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.1/go.mod h1:iu6FSzgt+M2/x3Dk8zhycdIcHjEFb36IS8HVUVFoMg0=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 h1:dM9/92u2F1JbDaGooxTq18wmmFzbJRfXfVfy96/1CXM=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15/go.mod h1:SwFBy2vjtA0vZbjjaFtfN045boopadnoVPhu4Fv66vY=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15 h1:moLQUoVq91LiqT1nbvzDukyqAlCv89ZmwaHw/ZFlFZg=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15/go.mod h1:ZH34PJUc8ApjBIfgQCFvkWcUDBtl/WTD+uiYHjd8igA=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.79.3 h1:BRXS0U76Z8wfF+bnkilA2QwpIch6URlm++yPUt9QPmQ=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.79.3/go.mod h1:bNXKFFyaiVvWuR6O16h/I1724+aXe/tAkA9/QS01t5k=
|
||||
github.com/aws/smithy-go v1.22.3 h1:Z//5NuZCSW6R4PhQ93hShNbyBbn8BWCmCVCt+Q8Io5k=
|
||||
github.com/aws/smithy-go v1.22.3/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI=
|
||||
github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ=
|
||||
github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
|
||||
github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM=
|
||||
github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ=
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/bufbuild/protocompile v0.4.0 h1:LbFKd2XowZvQ/kajzguUp2DC9UEIQhIq77fZZlaQsNA=
|
||||
github.com/bufbuild/protocompile v0.4.0/go.mod h1:3v93+mbWn/v3xzN+31nwkJfrEpAUwp+BagBSZWx+TP8=
|
||||
github.com/bytedance/sonic v1.12.6 h1:/isNmCUF2x3Sh8RAp/4mh4ZGkcFAX/hLrzrK3AvpRzk=
|
||||
github.com/bytedance/sonic v1.12.6/go.mod h1:B8Gt/XvtZ3Fqj+iSKMypzymZxw/FVwgIGKzMzT9r/rk=
|
||||
github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ=
|
||||
github.com/bytedance/sonic v1.13.2/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4=
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/bytedance/sonic/loader v0.2.1 h1:1GgorWTqf12TA8mma4DDSbaQigE2wOgQo7iCjjJv3+E=
|
||||
github.com/bytedance/sonic/loader v0.2.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY=
|
||||
github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||
github.com/caarlos0/env/v6 v6.10.1 h1:t1mPSxNpei6M5yAeu1qtRdPAK29Nbcf/n3G7x+b3/II=
|
||||
github.com/caarlos0/env/v6 v6.10.1/go.mod h1:hvp/ryKXKipEkcuYjs9mI4bBCg+UI0Yhgm5Zu0ddvwc=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
|
||||
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dchest/captcha v1.1.0 h1:2kt47EoYUUkaISobUdTbqwx55xvKOJxyScVfw25xzhQ=
|
||||
github.com/dchest/captcha v1.1.0/go.mod h1:7zoElIawLp7GUMLcj54K9kbw+jEyvz2K0FDdRRYhvWo=
|
||||
github.com/dgraph-io/badger/v4 v4.7.0 h1:Q+J8HApYAY7UMpL8d9owqiB+odzEc0zn/aqOD9jhc6Y=
|
||||
github.com/dgraph-io/badger/v4 v4.7.0/go.mod h1:He7TzG3YBy3j4f5baj5B7Zl2XyfNe5bl4Udl0aPemVA=
|
||||
github.com/dgraph-io/ristretto/v2 v2.2.0 h1:bkY3XzJcXoMuELV8F+vS8kzNgicwQFAaGINAEJdWGOM=
|
||||
github.com/dgraph-io/ristretto/v2 v2.2.0/go.mod h1:RZrm63UmcBAaYWC1DotLYBmTvgkrs0+XhBd7Npn7/zI=
|
||||
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa512G+w+Pxci9hJPB8oMnkcP3iZF38=
|
||||
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
|
||||
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
|
||||
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/elazarl/go-bindata-assetfs v1.0.1 h1:m0kkaHRKEu7tUIUFVwhGGGYClXvyl4RE03qmvRTNfbw=
|
||||
github.com/elazarl/go-bindata-assetfs v1.0.1/go.mod h1:v+YaWX3bdea5J/mo8dSETolEo7R71Vk1u8bnjau5yw4=
|
||||
github.com/evanw/esbuild v0.24.2 h1:PQExybVBrjHjN6/JJiShRGIXh1hWVm6NepVnhZhrt0A=
|
||||
github.com/evanw/esbuild v0.24.2/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48=
|
||||
github.com/expr-lang/expr v1.16.9 h1:WUAzmR0JNI9JCiF0/ewwHB1gmcGw5wW7nWt8gc6PpCI=
|
||||
github.com/expr-lang/expr v1.16.9/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4=
|
||||
github.com/evanw/esbuild v0.25.4 h1:k1bTSim+usBG27w7BfOCorhgx3tO+6bAfMj5pR+6SKg=
|
||||
github.com/evanw/esbuild v0.25.4/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48=
|
||||
github.com/expr-lang/expr v1.17.3 h1:myeTTuDFz7k6eFe/JPlep/UsiIjVhG61FMHFu63U7j0=
|
||||
github.com/expr-lang/expr v1.17.3/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4=
|
||||
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
|
||||
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
|
||||
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.7 h1:SKFKl7kD0RiPdbht0s7hFtjl489WcQ1VyPW8ZzUMYCA=
|
||||
github.com/gabriel-vasile/mimetype v1.4.7/go.mod h1:GDlAgAyIRT27BhFl53XNAFtfjzOkLaF35JdEG0P7LtU=
|
||||
github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E=
|
||||
github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0=
|
||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
|
||||
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
|
||||
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk=
|
||||
github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
|
||||
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
|
|
@ -91,27 +101,31 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
|
|||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL8sThn8IHr/sO+o=
|
||||
github.com/go-playground/validator/v10 v10.23.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
|
||||
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||
github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=
|
||||
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
|
||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q=
|
||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
|
||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||
github.com/goccy/go-json v0.10.4 h1:JSwxQzIqKfmFX1swYPpUThQZp/Ka4wzJdK0LWVytLPM=
|
||||
github.com/goccy/go-json v0.10.4/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
|
||||
github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRjiHuU=
|
||||
github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
|
||||
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
|
||||
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/google/flatbuffers v25.2.10+incompatible h1:F3vclr7C3HpB1k9mxCGRMXq6FdUalZ6H/pNX4FP1v0Q=
|
||||
github.com/google/flatbuffers v25.2.10+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
|
||||
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/go-github/v30 v30.1.0 h1:VLDx+UolQICEOKu2m4uAoMti1SxuEBAl7RSEG16L+Oo=
|
||||
github.com/google/go-github/v30 v30.1.0/go.mod h1:n8jBpHl45a/rlBUtRJMOG4GhNADUQFEufcolZ95JfU8=
|
||||
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
|
||||
|
|
@ -129,12 +143,18 @@ github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB1
|
|||
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
|
||||
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
|
||||
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
|
||||
github.com/hashicorp/go-plugin v1.6.2 h1:zdGAEd0V1lCaU0u+MxWQhtSDQmahpkwOun8U8EiRVog=
|
||||
github.com/hashicorp/go-plugin v1.6.2/go.mod h1:CkgLQ5CZqNmdL9U9JzM532t8ZiYQ35+pj3b1FD37R0Q=
|
||||
github.com/hashicorp/go-plugin v1.6.3 h1:xgHB+ZUSYeuJi96WtxEjzi23uh7YQpznjGh0U0UUrwg=
|
||||
github.com/hashicorp/go-plugin v1.6.3/go.mod h1:MRobyh+Wc/nYy1V4KAXUiYfzxoYhs7V1mlH1Z7iY2h0=
|
||||
github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c=
|
||||
github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
|
||||
github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8=
|
||||
github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns=
|
||||
github.com/hhrutter/lzw v1.0.0 h1:laL89Llp86W3rRs83LvKbwYRx6INE8gDn0XNb1oXtm0=
|
||||
github.com/hhrutter/lzw v1.0.0/go.mod h1:2HC6DJSn/n6iAZfgM3Pg+cP1KxeWc3ezG8bBqW5+WEo=
|
||||
github.com/hhrutter/pkcs7 v0.2.0 h1:i4HN2XMbGQpZRnKBLsUwO3dSckzgX142TNqY/KfXg+I=
|
||||
github.com/hhrutter/pkcs7 v0.2.0/go.mod h1:aEzKz0+ZAlz7YaEMY47jDHL14hVWD6iXt0AgqgAvWgE=
|
||||
github.com/hhrutter/tiff v1.0.2 h1:7H3FQQpKu/i5WaSChoD1nnJbGx4MxU5TlNqqpxw55z8=
|
||||
github.com/hhrutter/tiff v1.0.2/go.mod h1:pcOeuK5loFUE7Y/WnzGw20YxUdnqjY1P0Jlcieb/cCw=
|
||||
github.com/hokaccha/go-prettyjson v0.0.0-20210113012101-fb4e108d2519 h1:nqAlWFEdqI0ClbTDrhDvE/8LeQ4pftrqKUX9w5k0j3s=
|
||||
github.com/hokaccha/go-prettyjson v0.0.0-20210113012101-fb4e108d2519/go.mod h1:pFlLw2CfqZiIBOx6BuCeRLCrfxBJipTY0nIOF/VbGcI=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
|
|
@ -150,11 +170,13 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
|||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
|
||||
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
|
||||
github.com/kaptinlin/jsonrepair v0.1.1 h1:Ddn1sN1cZXuXeKA9vpaHAtBETnGSFBZFaaYfoN2Uo8c=
|
||||
github.com/kaptinlin/jsonrepair v0.1.1/go.mod h1:SivjE7np/GsSrk7UX/9mibH6VF8cVpD2aUmg7vceg2k=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY=
|
||||
github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
|
|
@ -167,29 +189,36 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
|||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/mark3labs/mcp-go v0.32.0 h1:fgwmbfL2gbd67obg57OfV2Dnrhs1HtSdlY/i5fn7MU8=
|
||||
github.com/mark3labs/mcp-go v0.32.0/go.mod h1:rXqOudj/djTORU/ThxYx8fqEVj/5pvTuuebQ2RC7uk4=
|
||||
github.com/matoous/go-nanoid/v2 v2.1.0 h1:P64+dmq21hhWdtvZfEAofnvJULaRR1Yib0+PnU669bE=
|
||||
github.com/matoous/go-nanoid/v2 v2.1.0/go.mod h1:KlbGNQ+FhrUNIHUxZdL63t7tl4LaPkZNpUULS8H4uVM=
|
||||
github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
|
||||
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
|
||||
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
|
||||
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/miekg/dns v1.1.62 h1:cN8OuEF1/x5Rq6Np+h1epln8OiyPWV+lROx9LxcGgIQ=
|
||||
github.com/miekg/dns v1.1.62/go.mod h1:mvDlcItzm+br7MToIKqkglaGhlFMHJ9DTNNWONWXbNQ=
|
||||
github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A=
|
||||
github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/miekg/dns v1.1.66 h1:FeZXOS3VCVsKnEAd+wBkjMC3D2K+ww66Cq3VnCINuJE=
|
||||
github.com/miekg/dns v1.1.66/go.mod h1:jGFzBsSNbJw6z1HYut1RKBKHA9PBdxeHrZG8J+gC2WE=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
|
||||
github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE=
|
||||
github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
|
||||
github.com/mozillazg/go-pinyin v0.20.0 h1:BtR3DsxpApHfKReaPO1fCqF4pThRwH9uwvXzm+GnMFQ=
|
||||
github.com/mozillazg/go-pinyin v0.20.0/go.mod h1:iR4EnMMRXkfpFVV5FMi4FNB6wGq9NV6uDWbUuPhP4Yc=
|
||||
github.com/neo4j/neo4j-go-driver/v5 v5.28.1 h1:RKWQW7wTgYAY2fU9S+9LaJ9OwRPbRc0I17tlT7nDmAY=
|
||||
github.com/neo4j/neo4j-go-driver/v5 v5.28.1/go.mod h1:Vff8OwT7QpLm7L2yYr85XNWe9Rbqlbeb9asNXJTHO4k=
|
||||
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
|
||||
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
|
||||
github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA=
|
||||
|
|
@ -200,14 +229,20 @@ github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042
|
|||
github.com/onsi/gomega v1.4.2/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
||||
github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE=
|
||||
github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs=
|
||||
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
|
||||
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
|
||||
github.com/pdfcpu/pdfcpu v0.11.0 h1:mL18Y3hSHzSezmnrzA21TqlayBOXuAx7BUzzZyroLGM=
|
||||
github.com/pdfcpu/pdfcpu v0.11.0/go.mod h1:F1ca4GIVFdPtmgvIdvXAycAm88noyNxZwzr9CpTy+Mw=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkoukk/tiktoken-go v0.1.7 h1:qOBHXX4PHtvIvmOtyg1EeKlwFRiMKAcoMp4Q+bLQDmw=
|
||||
github.com/pkoukk/tiktoken-go v0.1.7/go.mod h1:9NiV+i9mJKGj1rYOT+njbv+ZwA/zJxYdewGl6qVatpg=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/qdrant/go-client v1.12.0 h1:KqsIKDAw5iQmxDzRjbzRjhvQ+Igyr7Y84vDCinf1T4M=
|
||||
github.com/qdrant/go-client v1.12.0/go.mod h1:zFa6t5Y3Oqecoa0aSsGWhMqQWq3x3kTPvm0sMf5qplw=
|
||||
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
|
||||
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
||||
github.com/qdrant/go-client v1.14.0 h1:cyz9OOooAexudw5w69LRe9vKCQFYJvaFvt9icOciI1U=
|
||||
github.com/qdrant/go-client v1.14.0/go.mod h1:iO8ts78jL4x6LDHFOViyYWELVtIBDTjOykBmiOTHLnQ=
|
||||
github.com/rhysd/go-github-selfupdate v1.2.3 h1:iaa+J202f+Nc+A8zi75uccC8Wg3omaM7HDeimXA22Ag=
|
||||
github.com/rhysd/go-github-selfupdate v1.2.3/go.mod h1:mp/N8zj6jFfBQy/XMYoWsmfzxazpPAODuqarmPDe2Rg=
|
||||
github.com/richardlehane/mscfb v1.0.4 h1:WULscsljNPConisD5hR0+OyZjwK46Pfyr6mPu5ZawpM=
|
||||
|
|
@ -215,25 +250,27 @@ github.com/richardlehane/mscfb v1.0.4/go.mod h1:YzVpcZg9czvAuhk9T+a3avCpcFPMUWm7
|
|||
github.com/richardlehane/msoleps v1.0.1/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
|
||||
github.com/richardlehane/msoleps v1.0.4 h1:WuESlvhX3gH2IHcd8UqyCuFY5yiq/GR/yqaSM/9/g00=
|
||||
github.com/richardlehane/msoleps v1.0.4/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
||||
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
|
||||
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
|
||||
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
|
||||
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE=
|
||||
github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
|
||||
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
|
||||
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
||||
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
|
|
@ -241,7 +278,6 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
|||
github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/tcnksm/go-gitconfig v0.1.2 h1:iiDhRitByXAEyjgBqsKi9QU4o2TNtv9kPP3RgPgXBPw=
|
||||
|
|
@ -268,6 +304,8 @@ github.com/tidwall/rtred v0.1.2 h1:exmoQtOLvDoO8ud++6LwVsAMTu0KPzLTUrMln8u1yu8=
|
|||
github.com/tidwall/rtred v0.1.2/go.mod h1:hd69WNXQ5RP9vHd7dqekAz+RIdtfBogmglkZSRxCHFQ=
|
||||
github.com/tidwall/tinyqueue v0.1.1 h1:SpNEvEggbpyN5DIReaJ2/1ndroY8iyEGxPYxoSaymYE=
|
||||
github.com/tidwall/tinyqueue v0.1.1/go.mod h1:O/QNHwrnjqr6IHItYrzoHAKYhBkLI67Q096fQP5zMYw=
|
||||
github.com/tiendc/go-deepcopy v1.6.0 h1:0UtfV/imoCwlLxVsyfUd4hNHnB3drXsfle+wzSCA5Wo=
|
||||
github.com/tiendc/go-deepcopy v1.6.0/go.mod h1:toXoeQoUqXOOS/X4sKuiAoSk6elIdqc0pN7MTgOOo2I=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
|
|
@ -275,54 +313,57 @@ github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZ
|
|||
github.com/ulikunitz/xz v0.5.9/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||
github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc=
|
||||
github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||
github.com/watchfultele/jsonrepair v0.0.0-20250207052432-e4397ed42611 h1:CWCIUJ4cqhc3ct+HbV4EJtBdRFjUVOBne8mmIHXxU6A=
|
||||
github.com/watchfultele/jsonrepair v0.0.0-20250207052432-e4397ed42611/go.mod h1:63urp6bG6c9cw3DuFtNEr5g9tbjFXaPTn0sBiBDVB7g=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||
github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY=
|
||||
github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
|
||||
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
|
||||
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
|
||||
github.com/xuri/efp v0.0.0-20241211021726-c4e992084aa6 h1:8m6DWBG+dlFNbx5ynvrE7NgI+Y7OlZVMVTpayoW+rCc=
|
||||
github.com/xuri/efp v0.0.0-20241211021726-c4e992084aa6/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
|
||||
github.com/xuri/excelize/v2 v2.9.0 h1:1tgOaEq92IOEumR1/JfYS/eR0KHOCsRv/rYXXh6YJQE=
|
||||
github.com/xuri/excelize/v2 v2.9.0/go.mod h1:uqey4QBZ9gdMeWApPLdhm9x+9o2lq4iVmjiLfBS5hdE=
|
||||
github.com/xuri/nfp v0.0.0-20240318013403-ab9948c2c4a7 h1:hPVCafDV85blFTabnqKgNhDCkJX25eik94Si9cTER4A=
|
||||
github.com/xuri/nfp v0.0.0-20240318013403-ab9948c2c4a7/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
|
||||
github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
|
||||
github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
|
||||
github.com/xuri/excelize/v2 v2.9.1 h1:VdSGk+rraGmgLHGFaGG9/9IWu1nj4ufjJ7uwMDtj8Qw=
|
||||
github.com/xuri/excelize/v2 v2.9.1/go.mod h1:x7L6pKz2dvo9ejrRuD8Lnl98z4JLt0TGAwjhW+EiP8s=
|
||||
github.com/xuri/nfp v0.0.1 h1:MDamSGatIvp8uOmDP8FnmjuQpu90NzdJxo7242ANR9Q=
|
||||
github.com/xuri/nfp v0.0.1/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.mongodb.org/mongo-driver v1.17.1 h1:Wic5cJIwJgSpBhe3lx3+/RybR5PiYRMpVFgO7cOHyIM=
|
||||
go.mongodb.org/mongo-driver v1.17.1/go.mod h1:wwWm/+BuOddhcq3n68LKRmgk2wXzmF6s0SFOa0GINL4=
|
||||
go.opentelemetry.io/otel v1.31.0 h1:NsJcKPIW0D0H3NgzPDHmo0WW6SptzPdqg/L1zsIm2hY=
|
||||
go.opentelemetry.io/otel v1.31.0/go.mod h1:O0C14Yl9FgkjqcCZAsE053C13OaddMYr/hz6clDkEJE=
|
||||
go.opentelemetry.io/otel/metric v1.31.0 h1:FSErL0ATQAmYHUIzSezZibnyVlft1ybhy4ozRPcF2fE=
|
||||
go.opentelemetry.io/otel/metric v1.31.0/go.mod h1:C3dEloVbLuYoX41KpmAhOqNriGbA+qqH6PQ5E5mUfnY=
|
||||
go.opentelemetry.io/otel/sdk v1.31.0 h1:xLY3abVHYZ5HSfOg3l2E5LUj2Cwva5Y7yGxnSW9H5Gk=
|
||||
go.opentelemetry.io/otel/sdk v1.31.0/go.mod h1:TfRbMdhvxIIr/B2N2LQW2S5v9m3gOQ/08KsbbO5BPT0=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.31.0 h1:i9hxxLJF/9kkvfHppyLL55aW7iIJz4JjxTeYusH7zMc=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.31.0/go.mod h1:CRInTMVvNhUKgSAMbKyTMxqOBC0zgyxzW55lZzX43Y8=
|
||||
go.opentelemetry.io/otel/trace v1.31.0 h1:ffjsj1aRouKewfr85U2aGagJ46+MvodynlQ1HYdmJys=
|
||||
go.opentelemetry.io/otel/trace v1.31.0/go.mod h1:TXZkRk7SM2ZQLtR6eoAWQFIHPvzQ06FJAsO1tJg480A=
|
||||
golang.org/x/arch v0.12.0 h1:UsYJhbzPYGsT0HbEdmYcqtCv8UNGvnaL561NnIUvaKg=
|
||||
golang.org/x/arch v0.12.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
go.mongodb.org/mongo-driver v1.17.3 h1:TQyXhnsWfWtgAhMtOgtYHMTkZIfBTpMTsMnd9ZBeHxQ=
|
||||
go.mongodb.org/mongo-driver v1.17.3/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
|
||||
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
|
||||
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
|
||||
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
|
||||
go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY=
|
||||
go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w=
|
||||
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
|
||||
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||
golang.org/x/arch v0.17.0 h1:4O3dfLzd+lQewptAHqjewQZQDyEdejz3VwgeYwkZneU=
|
||||
golang.org/x/arch v0.17.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68=
|
||||
golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY=
|
||||
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
|
||||
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
|
||||
golang.org/x/image v0.29.0 h1:HcdsyR4Gsuys/Axh0rDEmlBmB68rW1U9BUdB3UVHsas=
|
||||
golang.org/x/image v0.29.0/go.mod h1:RVJROnf3SLK8d26OW91j4FrIHGbsJ8QnbEocVTOWQDA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4=
|
||||
golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
|
||||
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
|
||||
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
|
|
@ -335,12 +376,13 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
|||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
|
||||
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE=
|
||||
golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
||||
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
||||
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
|
|
@ -348,8 +390,9 @@ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
|||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
|
|
@ -363,15 +406,15 @@ golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBc
|
|||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
|
|
@ -392,30 +435,32 @@ golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
|||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
|
||||
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.28.0 h1:WuB6qZ4RPCQo5aP3WdKZS7i595EdWqWR8vqJTlwTVK8=
|
||||
golang.org/x/tools v0.28.0/go.mod h1:dcIOrVd3mfQKTgrDVQHqCPMWy6lnhfhtX3hLXYVLfRw=
|
||||
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
|
||||
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20241230172942-26aa7a208def h1:4P81qv5JXI/sDNae2ClVx88cgDDA6DPilADkG9tYKz8=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20241230172942-26aa7a208def/go.mod h1:bdAgzvd4kFrpykc5/AC2eLUiegK9T/qxZHD4hXYf/ho=
|
||||
google.golang.org/grpc v1.69.2 h1:U3S9QEtbXC0bYNvRtcoklF3xGtLViumSYxWykJS+7AU=
|
||||
google.golang.org/grpc v1.69.2/go.mod h1:vyjdE6jLBI76dgpDojsFGNaHlxdjXN9ghpnd2o7JGZ4=
|
||||
google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk=
|
||||
google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237 h1:cJfm9zPbe1e873mHJzmQ1nwVEeRDU/T1wXDK2kUSU34=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
|
||||
google.golang.org/grpc v1.72.1 h1:HR03wO6eyZ7lknl75XlxABNVLLFc2PAb6mHlYh756mA=
|
||||
google.golang.org/grpc v1.72.1/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM=
|
||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
|
||||
|
|
|
|||
|
|
@ -2,9 +2,10 @@ package helper
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt"
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
"github.com/yaoapp/gou/process"
|
||||
"github.com/yaoapp/gou/session"
|
||||
"github.com/yaoapp/kun/any"
|
||||
|
|
@ -13,12 +14,19 @@ import (
|
|||
"github.com/yaoapp/yao/config"
|
||||
)
|
||||
|
||||
const (
|
||||
// MaxTokenLength is the maximum allowed length for a JWT token
|
||||
MaxTokenLength = 4096
|
||||
// MaxTokenParts is the maximum allowed number of parts in a JWT token (header.payload.signature)
|
||||
MaxTokenParts = 3
|
||||
)
|
||||
|
||||
// JwtClaims 用户Token
|
||||
type JwtClaims struct {
|
||||
ID int `json:"id"`
|
||||
SID string `json:"sid"`
|
||||
Data map[string]interface{} `json:"data"`
|
||||
jwt.StandardClaims
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// JwtToken JWT令牌
|
||||
|
|
@ -29,6 +37,18 @@ type JwtToken struct {
|
|||
|
||||
// JwtValidate JWT 校验
|
||||
func JwtValidate(tokenString string, secret ...[]byte) *JwtClaims {
|
||||
// Check token length
|
||||
if len(tokenString) > MaxTokenLength {
|
||||
exception.New("Token too long", 401).Throw()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check number of parts
|
||||
parts := strings.Split(tokenString, ".")
|
||||
if len(parts) > MaxTokenParts {
|
||||
exception.New("Invalid token format", 401).Throw()
|
||||
return nil
|
||||
}
|
||||
|
||||
jwtSecret := []byte(config.Conf.JWTSecret)
|
||||
if len(secret) > 0 {
|
||||
|
|
@ -62,12 +82,12 @@ func JwtMake(id int, data map[string]interface{}, option map[string]interface{},
|
|||
jwtSecret = secret[0]
|
||||
}
|
||||
|
||||
now := time.Now().Unix()
|
||||
now := time.Now()
|
||||
sid := ""
|
||||
timeout := int64(36000)
|
||||
timeout := time.Hour
|
||||
uid := fmt.Sprintf("%d", id)
|
||||
subject := "User Token"
|
||||
audience := "Yao Process utils.jwt.Make"
|
||||
audience := []string{"Yao Process utils.jwt.Make"}
|
||||
issuer := fmt.Sprintf("xiang:%d", id)
|
||||
|
||||
if v, has := option["subject"]; has {
|
||||
|
|
@ -75,7 +95,7 @@ func JwtMake(id int, data map[string]interface{}, option map[string]interface{},
|
|||
}
|
||||
|
||||
if v, has := option["audience"]; has {
|
||||
audience = fmt.Sprintf("%v", v)
|
||||
audience = []string{fmt.Sprintf("%v", v)}
|
||||
}
|
||||
|
||||
if v, has := option["issuer"]; has {
|
||||
|
|
@ -87,45 +107,42 @@ func JwtMake(id int, data map[string]interface{}, option map[string]interface{},
|
|||
}
|
||||
|
||||
if v, has := option["timeout"]; has {
|
||||
timeout = int64(any.Of(v).CInt())
|
||||
timeout = time.Duration(any.Of(v).CInt()) * time.Second
|
||||
}
|
||||
|
||||
expiresAt := now + timeout
|
||||
expiresAt := now.Add(timeout)
|
||||
if v, has := option["expires_at"]; has {
|
||||
expiresAt = int64(any.Of(v).CInt())
|
||||
expiresAt = time.Unix(int64(any.Of(v).CInt()), 0)
|
||||
}
|
||||
|
||||
if sid == "" {
|
||||
sid = session.ID()
|
||||
}
|
||||
|
||||
// 设定会话过期时间 (并写需要加锁,这个逻辑需要优化)
|
||||
// session.Global().Expire(time.Duration(timeout) * time.Second)
|
||||
|
||||
claims := &JwtClaims{
|
||||
ID: id,
|
||||
SID: sid, // 会话ID
|
||||
Data: data,
|
||||
StandardClaims: jwt.StandardClaims{
|
||||
Id: uid, // 唯一ID
|
||||
Subject: subject, // 主题
|
||||
Audience: audience, // 接收人
|
||||
ExpiresAt: expiresAt, // 过期时间
|
||||
NotBefore: now, // 生效时间
|
||||
IssuedAt: now, // 签发时间
|
||||
Issuer: issuer, // 签发人
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ID: uid, // 唯一ID
|
||||
Subject: subject, // 主题
|
||||
Audience: audience, // 接收人
|
||||
ExpiresAt: jwt.NewNumericDate(expiresAt), // 过期时间
|
||||
NotBefore: jwt.NewNumericDate(now), // 生效时间
|
||||
IssuedAt: jwt.NewNumericDate(now), // 签发时间
|
||||
Issuer: issuer, // 签发人
|
||||
},
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
tokenString, err := token.SignedString([]byte(jwtSecret))
|
||||
tokenString, err := token.SignedString(jwtSecret)
|
||||
if err != nil {
|
||||
exception.New("JWT Make Error: %s", 500, err.Error()).Throw()
|
||||
}
|
||||
|
||||
return JwtToken{
|
||||
Token: tokenString,
|
||||
ExpiresAt: expiresAt,
|
||||
ExpiresAt: expiresAt.Unix(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
10
i18n/i18n.go
10
i18n/i18n.go
|
|
@ -7,6 +7,7 @@ import (
|
|||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/yaoapp/gou/application"
|
||||
"github.com/yaoapp/gou/lang"
|
||||
"github.com/yaoapp/kun/log"
|
||||
"github.com/yaoapp/yao/config"
|
||||
|
|
@ -43,6 +44,15 @@ func Load(cfg config.Config) error {
|
|||
return err
|
||||
}
|
||||
|
||||
// Ignore if the langs directory does not exist
|
||||
exists, err := application.App.Exists("langs")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Load langs
|
||||
err = lang.Load("langs")
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -20,6 +20,11 @@ import (
|
|||
"github.com/yaoapp/yao/share"
|
||||
)
|
||||
|
||||
// *********************************************************************************
|
||||
// !! Importer has been deprecated.
|
||||
// !! Do not use this in your project.
|
||||
// *********************************************************************************
|
||||
|
||||
// Importers 导入器
|
||||
var Importers = map[string]*Importer{}
|
||||
|
||||
|
|
|
|||
1
job/README.md
Normal file
1
job/README.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
# Job
|
||||
1
job/job.go
Normal file
1
job/job.go
Normal file
|
|
@ -0,0 +1 @@
|
|||
package job
|
||||
12
job/types/interfaces.go
Normal file
12
job/types/interfaces.go
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
package types
|
||||
|
||||
import "context"
|
||||
|
||||
// Job interface
|
||||
type Job interface {
|
||||
Run(ctx context.Context) error
|
||||
AddTask(ctx context.Context, task Task) error
|
||||
}
|
||||
|
||||
// Task interface
|
||||
type Task func(ctx context.Context, job Job) error
|
||||
1
job/types/types.go
Normal file
1
job/types/types.go
Normal file
|
|
@ -0,0 +1 @@
|
|||
package types
|
||||
1
kb/README.md
Normal file
1
kb/README.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
# Knowledge Base
|
||||
166
kb/kb.go
Normal file
166
kb/kb.go
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
package kb
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
|
||||
"github.com/yaoapp/gou/application"
|
||||
"github.com/yaoapp/gou/graphrag"
|
||||
"github.com/yaoapp/gou/graphrag/types"
|
||||
"github.com/yaoapp/kun/log"
|
||||
"github.com/yaoapp/yao/config"
|
||||
|
||||
// Register the built-in providers
|
||||
_ "github.com/yaoapp/yao/kb/providers"
|
||||
|
||||
// Import the kb types
|
||||
kbtypes "github.com/yaoapp/yao/kb/types"
|
||||
)
|
||||
|
||||
// Instance is the GraphRag instance
|
||||
var Instance types.GraphRag = nil
|
||||
|
||||
// KnowledgeBase is the Knowledge Base instance
|
||||
type KnowledgeBase struct {
|
||||
Config *kbtypes.Config // Knowledge Base configuration
|
||||
Providers *kbtypes.ProviderConfig // Multi-language provider configurations
|
||||
*graphrag.GraphRag
|
||||
}
|
||||
|
||||
// Load loads the GraphRag instance
|
||||
func Load(appConfig config.Config) (*KnowledgeBase, error) {
|
||||
|
||||
configPath := filepath.Join("kb", "kb.yao")
|
||||
exists, err := application.App.Exists(configPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !exists {
|
||||
log.Warn("[Knowledge Base] kb.yao file not found, skip loading knowledge base")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Load providers from directories first
|
||||
providers, err := kbtypes.LoadProviders("kb")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Parse the configuration
|
||||
var config kbtypes.Config
|
||||
raw, err := application.App.Read(filepath.Join("kb", "kb.yao"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = application.Parse("kb.yao", raw, &config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Assign providers to config
|
||||
config.Providers = providers
|
||||
|
||||
// Compute features after both config and providers are loaded
|
||||
config.Features = config.ComputeFeatures()
|
||||
|
||||
// Set global configurations for providers to use
|
||||
kbtypes.SetGlobalPDF(config.PDF)
|
||||
kbtypes.SetGlobalFFmpeg(config.FFmpeg)
|
||||
|
||||
// Create the GraphRag config
|
||||
graphRagConfig, err := config.GraphRagConfig()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Create the GraphRag instance
|
||||
graphRag, err := graphrag.New(graphRagConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Set the instance
|
||||
instance := &KnowledgeBase{Config: &config, Providers: providers, GraphRag: graphRag}
|
||||
|
||||
// Set the instance to the global variable
|
||||
Instance = instance
|
||||
return instance, nil
|
||||
}
|
||||
|
||||
// GetProviders returns all providers
|
||||
func GetProviders(typ string, ids []string, locale string) ([]kbtypes.Provider, error) {
|
||||
if Instance == nil {
|
||||
return nil, fmt.Errorf("knowledge base not initialized")
|
||||
}
|
||||
|
||||
// Get the providers from the instance
|
||||
knowledgeBase, ok := Instance.(*KnowledgeBase)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("knowledge base not initialized")
|
||||
}
|
||||
|
||||
// Default locale to "en" if empty
|
||||
if locale == "" {
|
||||
locale = "en"
|
||||
}
|
||||
|
||||
// Get providers for the requested type and language
|
||||
providers := knowledgeBase.Providers.GetProviders(typ, locale)
|
||||
|
||||
// Filter empty ids
|
||||
filteredIds := []string{}
|
||||
for _, id := range ids {
|
||||
if id != "" {
|
||||
filteredIds = append(filteredIds, id)
|
||||
}
|
||||
}
|
||||
|
||||
// Filter the providers by ids
|
||||
filteredProviders := []kbtypes.Provider{}
|
||||
for _, provider := range providers {
|
||||
if len(filteredIds) == 0 || slices.Contains(ids, provider.ID) {
|
||||
filteredProviders = append(filteredProviders, *provider)
|
||||
}
|
||||
}
|
||||
return filteredProviders, nil
|
||||
}
|
||||
|
||||
// GetProvider returns a provider by id with default language "en"
|
||||
func GetProvider(typ string, id string) (*kbtypes.Provider, error) {
|
||||
return GetProviderWithLanguage(typ, id, "en")
|
||||
}
|
||||
|
||||
// GetProviderWithLanguage returns a provider by id, type, and language
|
||||
func GetProviderWithLanguage(typ string, id string, locale string) (*kbtypes.Provider, error) {
|
||||
if Instance == nil {
|
||||
return nil, fmt.Errorf("knowledge base not initialized")
|
||||
}
|
||||
|
||||
knowledgeBase, ok := Instance.(*KnowledgeBase)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("knowledge base not initialized")
|
||||
}
|
||||
|
||||
// Default locale to "en" if empty
|
||||
if locale == "" {
|
||||
locale = "en"
|
||||
}
|
||||
|
||||
return knowledgeBase.Providers.GetProvider(typ, id, locale)
|
||||
}
|
||||
|
||||
// GetConfig returns the knowledge base configuration
|
||||
func GetConfig() (*kbtypes.Config, error) {
|
||||
if Instance == nil {
|
||||
return nil, fmt.Errorf("knowledge base not initialized")
|
||||
}
|
||||
|
||||
knowledgeBase, ok := Instance.(*KnowledgeBase)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("knowledge base not initialized")
|
||||
}
|
||||
|
||||
return knowledgeBase.Config, nil
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue