mirror of
https://github.com/mayswind/ezbookkeeping.git
synced 2026-05-18 16:54:25 +08:00
Compare commits
193 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1f7411a002 | |||
| a739498ef7 | |||
| e6bb128cda | |||
| b9855faf38 | |||
| ea9fce9bae | |||
| 7557c1014d | |||
| aee8757ad5 | |||
| d0f76fea22 | |||
| e6c6d02112 | |||
| 563bef69cf | |||
| 11f2c9fff7 | |||
| 9e0275a11a | |||
| 231d3210cb | |||
| 75d801f775 | |||
| de132dd7fd | |||
| 39ee47e05a | |||
| 0038321781 | |||
| 7c67d30222 | |||
| da2f1ef410 | |||
| 416e7cece1 | |||
| 1d5102a015 | |||
| 1e38d1b18b | |||
| bab7a0041b | |||
| e38ba2ea0a | |||
| 9d25914411 | |||
| bb2068f4db | |||
| e4e74304b6 | |||
| de885c963d | |||
| 1428ce921c | |||
| 60477f7f27 | |||
| 9dcbf1aa7e | |||
| 1d5a6562f3 | |||
| 0c427e9857 | |||
| 0b984b3c20 | |||
| b87a39464e | |||
| 0d2b3196e6 | |||
| e172e040f9 | |||
| 629dbeeaa4 | |||
| 53e515fb92 | |||
| 9c87436a36 | |||
| f56b5c471d | |||
| cd8f746745 | |||
| d39494a78e | |||
| e69df56874 | |||
| 808cb98002 | |||
| 892404924c | |||
| 374132fe7c | |||
| 5d8e709070 | |||
| 124962a4f4 | |||
| b9b210c591 | |||
| 6a4ab4c145 | |||
| e89aa10137 | |||
| d73704af66 | |||
| bf7fe0c583 | |||
| a6e252c30d | |||
| 1e4bb73874 | |||
| 8f01469a41 | |||
| 7a821abbb6 | |||
| 02d8b132f5 | |||
| c64c60c6a0 | |||
| 50472d437a | |||
| 53702e68d8 | |||
| 36d82254d6 | |||
| 36529abf08 | |||
| c0641b1db5 | |||
| c2d7bcc5f1 | |||
| 4af0797051 | |||
| 63ec0e4424 | |||
| c828db4988 | |||
| d7151bc7ab | |||
| 0222f61da6 | |||
| ff1158be00 | |||
| f214b7db88 | |||
| d605a8f4ec | |||
| 5d333a4e74 | |||
| fb35756601 | |||
| 721384b9cc | |||
| f2b633cc7b | |||
| 448fc760c0 | |||
| a604737c7c | |||
| d44798bf0f | |||
| fcedb3147d | |||
| cd59c4e6a5 | |||
| fe0187ac2c | |||
| b4c31fc9d0 | |||
| ae7ee274d5 | |||
| ee45d89730 | |||
| 5359e3c1fb | |||
| 97fb73ad43 | |||
| ce0c9ec65e | |||
| ed084e1ce0 | |||
| ec84065f73 | |||
| 2e8aedcfa6 | |||
| 422f18443a | |||
| 0fbf185223 | |||
| 91cdffa9a6 | |||
| 89199eed8b | |||
| 1a65bb9db6 | |||
| 9772d9ca62 | |||
| 5ee93a5db1 | |||
| 85c4f686da | |||
| 1f066b0d1e | |||
| 38ddb7aaa3 | |||
| a22931f96b | |||
| dcee067aea | |||
| 302d118ae0 | |||
| 09eea96cdc | |||
| 205dea9e58 | |||
| 089eabb806 | |||
| dd63500202 | |||
| 13488efdaf | |||
| edcf33f49c | |||
| d601e01029 | |||
| 4d7c3650b5 | |||
| a0fd468309 | |||
| 0b7471879d | |||
| 282b74c95e | |||
| 5ce1dc973c | |||
| 7ac1e0b69f | |||
| 127bed1026 | |||
| d517a1862b | |||
| 8e5202b375 | |||
| 301fb58917 | |||
| aedebb1461 | |||
| 1336377598 | |||
| 3b58dcbc4d | |||
| 23a5f0a96f | |||
| b81d2ec63c | |||
| cabe365907 | |||
| 54f61ecb18 | |||
| 404cd62d7b | |||
| f0f3143605 | |||
| b729fdedca | |||
| 973cec2c6a | |||
| 6e61aba050 | |||
| 40a8deba12 | |||
| 0ba762ba6e | |||
| 732c256db2 | |||
| d2ce801277 | |||
| 4845fdedfd | |||
| f5a7e2e2d6 | |||
| a84f48ae8a | |||
| c4c9503e31 | |||
| 8c1f499ed8 | |||
| c6eb3cfb74 | |||
| d7a0d253c4 | |||
| 9d275a3051 | |||
| 8192a48bc5 | |||
| 247181830c | |||
| d5dfdc8c05 | |||
| d95fcd8b00 | |||
| 40a366e68d | |||
| 593ae10783 | |||
| 75d9e11bab | |||
| 6d37d42e50 | |||
| f9e9c9285f | |||
| 314bf876f2 | |||
| 61c52cc888 | |||
| b42f226aba | |||
| 767b841866 | |||
| fd08666f49 | |||
| eb662681a1 | |||
| ef15eccc33 | |||
| e0286ff133 | |||
| 2baffe3f11 | |||
| 196657ee86 | |||
| b4c4aafc99 | |||
| b907a79223 | |||
| 0d213de580 | |||
| 2e97d699e7 | |||
| 22e4738b7a | |||
| 4b68641043 | |||
| 3a66a3d655 | |||
| 76d1d3aef3 | |||
| fe2aa5d28b | |||
| f474bbf09a | |||
| c4d02db879 | |||
| 75b36ec547 | |||
| 43b7aea76e | |||
| 13a4a47d40 | |||
| fd9f380922 | |||
| a5fdb9d6b7 | |||
| 983c65e4f8 | |||
| fa568056d3 | |||
| ea8b2812d4 | |||
| b6a2aea8fd | |||
| fa047bf303 | |||
| 4177ac3d46 | |||
| 7647f4f5b9 | |||
| bab03dbde1 | |||
| 85db6e96af | |||
| 548461ade0 | |||
| ecbf182173 |
@@ -10,11 +10,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: |
|
||||
${{ secrets.DOCKER_REPO }}/mayswind/ezbookkeeping
|
||||
@@ -24,10 +24,10 @@ jobs:
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Set up the environment
|
||||
id: setup
|
||||
|
||||
@@ -10,11 +10,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: |
|
||||
${{ secrets.DOCKER_REPO }}/mayswind/ezbookkeeping
|
||||
@@ -24,10 +24,10 @@ jobs:
|
||||
type=sha,format=short,prefix=SNAPSHOT-
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Set up the environment
|
||||
id: setup
|
||||
|
||||
@@ -3,81 +3,78 @@ name: Build docker image and package for linux
|
||||
inputs:
|
||||
release-build:
|
||||
required: false
|
||||
type: string
|
||||
description: "Whether to build the linux package in release mode. If set to '1', the package will be built in release mode. Otherwise, it will be built in development mode."
|
||||
build-unix-time:
|
||||
required: false
|
||||
type: string
|
||||
description: "The unix time to use for building the linux package. The value should be a string representing the unix time in seconds."
|
||||
build-date:
|
||||
required: false
|
||||
type: string
|
||||
description: "The date to use for building the linux package. The value should be a string representing the date in the format of 'YYYYMMDD'."
|
||||
check-3rd-api:
|
||||
required: false
|
||||
type: string
|
||||
description: "Whether to run integration tests that call third party APIs. If set to '1', the tests will be run. Otherwise, the tests will be skipped."
|
||||
skip-tests:
|
||||
required: false
|
||||
type: string
|
||||
description: "Whether to skip tests when building the linux package. If set to '1', the tests will be skipped. Otherwise, the tests will be run."
|
||||
platform:
|
||||
required: true
|
||||
type: string
|
||||
description: "The platform to build the linux package for. The value should be in the format of 'os/arch[/variant]'. For example, 'linux/amd64', 'linux/arm64/v8', 'linux/arm/v7', or 'linux/arm/v6'."
|
||||
platform-name:
|
||||
required: true
|
||||
type: string
|
||||
description: "The name of the platform to build the linux package for. The value should be a string that can be used in file names. For example, 'linux-amd64', 'linux-arm64', 'linux-armv7', or 'linux-armv6'."
|
||||
docker-push:
|
||||
required: true
|
||||
type: boolean
|
||||
description: "Whether to push the built docker image to the registry. If set to 'true', the image will be pushed. Otherwise, it will not be pushed."
|
||||
docker-image-name:
|
||||
required: true
|
||||
type: string
|
||||
required: false
|
||||
description: "The repository name of the docker image to build. This is required if 'docker-push' is set to 'true'."
|
||||
docker-username:
|
||||
required: false
|
||||
type: string
|
||||
description: "Username for logging in to the docker registry. This is required if 'docker-push' is set to 'true'."
|
||||
docker-password:
|
||||
required: false
|
||||
type: string
|
||||
description: "Password for logging in to the docker registry. This is required if 'docker-push' is set to 'true'."
|
||||
docker-bake-meta-file-path:
|
||||
required: true
|
||||
type: string
|
||||
description: "The file path to the docker bake meta file."
|
||||
docker-bake-meta-artifact-name:
|
||||
required: true
|
||||
type: string
|
||||
description: "The name of the artifact that contains the docker bake meta file."
|
||||
docker-bake-digests-file-path:
|
||||
required: true
|
||||
type: string
|
||||
description: "The file path to save the docker bake digests file. The file will be created with the name of the digest under this path."
|
||||
docker-bake-digests-artifact-name-prefix:
|
||||
required: true
|
||||
type: string
|
||||
description: "The prefix for the docker bake digests artifact name."
|
||||
package-file-name-prefix:
|
||||
required: true
|
||||
type: string
|
||||
package-artifact-name-prefix:
|
||||
required: true
|
||||
type: string
|
||||
description: "The prefix for the linux package file name."
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
- name: Download docker bake meta artifact
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: ${{ inputs.docker-bake-meta-artifact-name }}
|
||||
path: ${{ runner.temp }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to DockerHub
|
||||
if: ${{ inputs.docker-username != '' && inputs.docker-password != '' }}
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ inputs.docker-username }}
|
||||
password: ${{ inputs.docker-password }}
|
||||
|
||||
- name: Build docker for ${{ inputs.platform-name }}
|
||||
id: bake
|
||||
uses: docker/bake-action@v6
|
||||
uses: docker/bake-action@v7
|
||||
with:
|
||||
files: |
|
||||
./docker-bake.hcl
|
||||
@@ -110,15 +107,15 @@ runs:
|
||||
tar -czf ${{ runner.temp }}/${{ inputs.package-file-name-prefix }}-${{ inputs.platform-name }}.tar.gz *
|
||||
|
||||
- name: Upload ${{ inputs.platform-name }} digests artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ${{ inputs.docker-bake-digests-artifact-name-prefix }}-${{ inputs.platform-name }}
|
||||
path: ${{ inputs.docker-bake-digests-file-path }}/*
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload artifact for ${{ inputs.platform-name }}
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ${{ inputs.package-artifact-name-prefix }}-${{ inputs.platform-name }}
|
||||
archive: false
|
||||
path: ${{ runner.temp }}/${{ inputs.package-file-name-prefix }}-${{ inputs.platform-name }}.tar.gz
|
||||
if-no-files-found: error
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
name: Build backend file for macOS
|
||||
|
||||
inputs:
|
||||
go-version:
|
||||
required: false
|
||||
description: "The Go version to use for building the macOS backend. The version should be in the format of 'x.y.z'."
|
||||
default: "1.26.2"
|
||||
release-build:
|
||||
required: false
|
||||
description: "Whether to build the macOS backend in release mode. If set to '1', the backend will be built in release mode. Otherwise, it will be built in development mode."
|
||||
build-unix-time:
|
||||
required: false
|
||||
description: "The unix time to use for building the macOS backend. The value should be a string representing the unix time in seconds."
|
||||
build-date:
|
||||
required: false
|
||||
description: "The date to use for building the macOS backend. The value should be a string representing the date in the format of 'YYYYMMDD'."
|
||||
check-3rd-api:
|
||||
required: false
|
||||
description: "Whether to run integration tests that call third party APIs. If set to '1', the tests will be run. Otherwise, the tests will be skipped."
|
||||
skip-tests:
|
||||
required: false
|
||||
description: "Whether to skip tests when building the macOS backend. If set to '1', the tests will be skipped. Otherwise, the tests will be run."
|
||||
architecture:
|
||||
required: true
|
||||
description: "The name of the architecture to build the macOS package for."
|
||||
backend-artifact-name-prefix:
|
||||
required: true
|
||||
description: "The prefix for the macOS backend artifact name."
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: ${{ inputs.go-version }}
|
||||
|
||||
- name: Build backend for macOS-${{ inputs.architecture }}
|
||||
shell: bash
|
||||
env:
|
||||
RELEASE_BUILD: "${{ inputs.release-build }}"
|
||||
BUILD_PIPELINE: "1"
|
||||
BUILD_UNIXTIME: "${{ inputs.build-unix-time }}"
|
||||
BUILD_DATE: "${{ inputs.build-date }}"
|
||||
CHECK_3RD_API: "${{ inputs.check-3rd-api }}"
|
||||
SKIP_TESTS: "${{ inputs.skip-tests }}"
|
||||
run: |
|
||||
./build.sh backend
|
||||
|
||||
- name: Upload macOS-${{ inputs.architecture }} backend artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ${{ inputs.backend-artifact-name-prefix }}-macos-${{ inputs.architecture }}
|
||||
path: ezbookkeeping
|
||||
if-no-files-found: error
|
||||
@@ -0,0 +1,58 @@
|
||||
name: Build packages for macOS
|
||||
|
||||
inputs:
|
||||
architecture:
|
||||
required: true
|
||||
description: "The name of the architecture to build the macOS package for."
|
||||
package-file-name-prefix:
|
||||
required: true
|
||||
description: "The prefix for the macOS package file name."
|
||||
backend-artifact-name-prefix:
|
||||
required: true
|
||||
description: "The prefix for the macOS backend artifact name."
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Download macOS-${{ inputs.architecture }} backend file
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: ${{ inputs.backend-artifact-name-prefix }}-macos-${{ inputs.architecture }}
|
||||
path: ${{ runner.temp }}/backend
|
||||
|
||||
- name: Download linux-amd64 packaged files
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: ${{ inputs.package-file-name-prefix }}-linux-amd64.tar.gz
|
||||
path: ${{ runner.temp }}/package
|
||||
|
||||
- name: Extract frontend files from linux-amd64 package
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p package
|
||||
tar -xzf ${{ runner.temp }}/package/${{ inputs.package-file-name-prefix }}-linux-amd64.tar.gz -C package
|
||||
|
||||
- name: Package macOS-${{ inputs.architecture }} package
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p ezbookkeeping
|
||||
mkdir -p ezbookkeeping/data
|
||||
mkdir -p ezbookkeeping/storage
|
||||
mkdir -p ezbookkeeping/log
|
||||
cp ${{ runner.temp }}/backend/ezbookkeeping ezbookkeeping/
|
||||
cp -R package/public ezbookkeeping/public
|
||||
cp -R conf ezbookkeeping/conf
|
||||
cp -R templates ezbookkeeping/templates
|
||||
cp LICENSE ezbookkeeping/
|
||||
cd ezbookkeeping
|
||||
chmod +x ezbookkeeping
|
||||
tar -czf ${{ runner.temp }}/${{ inputs.package-file-name-prefix }}-macos-${{ inputs.architecture }}.tar.gz *
|
||||
cd ..
|
||||
rm -rf ezbookkeeping
|
||||
|
||||
- name: Upload macOS-${{ inputs.architecture }} artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
archive: false
|
||||
path: ${{ runner.temp }}/${{ inputs.package-file-name-prefix }}-macos-${{ inputs.architecture }}.tar.gz
|
||||
if-no-files-found: error
|
||||
@@ -3,31 +3,34 @@ name: Build backend file for windows
|
||||
inputs:
|
||||
go-version:
|
||||
required: false
|
||||
default: "1.25.5"
|
||||
description: "The Go version to use for building the windows backend. The version should be in the format of 'x.y.z'."
|
||||
default: "1.26.2"
|
||||
mingw-version:
|
||||
required: false
|
||||
description: "The MinGW version to use for building the windows backend. The version should be in the format of 'x.y.z'."
|
||||
default: "15.2.0"
|
||||
mingw-revison:
|
||||
required: false
|
||||
default: "v13-rev0"
|
||||
description: "The MinGW revision to use for building the windows backend. The revision should be in the format of 'vX-revY'."
|
||||
default: "v13-rev1"
|
||||
release-build:
|
||||
required: false
|
||||
type: string
|
||||
description: "Whether to build the windows backend in release mode. If set to '1', the backend will be built in release mode. Otherwise, it will be built in development mode."
|
||||
build-unix-time:
|
||||
required: false
|
||||
type: string
|
||||
description: "The unix time to use for building the windows backend. The value should be a string representing the unix time in seconds."
|
||||
build-date:
|
||||
required: false
|
||||
type: string
|
||||
description: "The date to use for building the windows backend. The value should be a string representing the date in the format of 'YYYYMMDD'."
|
||||
check-3rd-api:
|
||||
required: false
|
||||
type: string
|
||||
description: "Whether to run integration tests that call third party APIs. If set to '1', the tests will be run. Otherwise, the tests will be skipped."
|
||||
skip-tests:
|
||||
required: false
|
||||
type: string
|
||||
description: "Whether to skip tests when building the windows backend. If set to '1', the tests will be skipped. Otherwise, the tests will be run."
|
||||
backend-artifact-name-prefix:
|
||||
required: true
|
||||
type: string
|
||||
description: "The prefix for the windows backend artifact name."
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
@@ -69,7 +72,7 @@ runs:
|
||||
.\build.ps1 backend
|
||||
|
||||
- name: Upload windows backend artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ${{ inputs.backend-artifact-name-prefix }}-windows-x64
|
||||
path: ezbookkeeping.exe
|
||||
|
||||
@@ -3,27 +3,24 @@ name: Build packages for windows
|
||||
inputs:
|
||||
package-file-name-prefix:
|
||||
required: true
|
||||
type: string
|
||||
package-artifact-name-prefix:
|
||||
required: true
|
||||
type: string
|
||||
description: "The prefix for the windows package file name."
|
||||
backend-artifact-name-prefix:
|
||||
required: true
|
||||
type: string
|
||||
description: "The prefix for the windows backend artifact name."
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Download windows-x64 backend file
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: ${{ inputs.backend-artifact-name-prefix }}-windows-x64
|
||||
path: ${{ runner.temp }}\backend
|
||||
|
||||
- name: Download linux-amd64 packaged files
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: ${{ inputs.package-artifact-name-prefix }}-linux-amd64
|
||||
name: ${{ inputs.package-file-name-prefix }}-linux-amd64.tar.gz
|
||||
path: ${{ runner.temp }}\package
|
||||
|
||||
- name: Extract frontend files from linux-amd64 package
|
||||
@@ -50,8 +47,8 @@ runs:
|
||||
Remove-Item -Recurse -Force ezbookkeeping
|
||||
|
||||
- name: Upload windows artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ${{ inputs.package-artifact-name-prefix }}-windows-x64
|
||||
archive: false
|
||||
path: ${{ inputs.package-file-name-prefix }}-windows-x64.zip
|
||||
if-no-files-found: error
|
||||
|
||||
@@ -3,50 +3,50 @@ name: Push linux docker multi-arch image to registry
|
||||
inputs:
|
||||
docker-image-name:
|
||||
required: true
|
||||
type: string
|
||||
description: "The repository name of the docker image to build."
|
||||
docker-username:
|
||||
required: true
|
||||
type: string
|
||||
description: "Username for logging in to the docker registry."
|
||||
docker-password:
|
||||
required: true
|
||||
type: string
|
||||
description: "Password for logging in to the docker registry."
|
||||
docker-bake-meta-file-path:
|
||||
required: true
|
||||
type: string
|
||||
description: "The file path to the docker bake meta file."
|
||||
docker-bake-meta-artifact-name:
|
||||
required: true
|
||||
type: string
|
||||
description: "The name of the artifact that contains the docker bake meta file."
|
||||
docker-bake-digests-file-path:
|
||||
required: true
|
||||
type: string
|
||||
description: "The file path to save the docker bake digests file. The file will be created with the name of the digest under this path."
|
||||
docker-bake-digests-artifact-name-prefix:
|
||||
required: true
|
||||
type: string
|
||||
description: "The prefix for the docker bake digests artifact name."
|
||||
docker-image-tags:
|
||||
required: true
|
||||
type: string
|
||||
description: "The tags for the docker image to push."
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Download docker bake meta artifact
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: ${{ inputs.docker-bake-meta-artifact-name }}
|
||||
path: ${{ runner.temp }}
|
||||
|
||||
- name: Download digests artifact
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: ${{ inputs.docker-bake-digests-artifact-name-prefix }}-*
|
||||
merge-multiple: true
|
||||
path: ${{ inputs.docker-bake-digests-file-path }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ inputs.docker-username }}
|
||||
password: ${{ inputs.docker-password }}
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const FRONTEND_LOCALES_DIR = path.join(__dirname, '..', '..', 'src', 'locales');
|
||||
const BACKEND_LOCALES_DIR = path.join(__dirname, '..', '..', 'pkg', 'locales');
|
||||
const OUTPUT_DIR = process.argv[2] || path.join(__dirname, '..', '..', 'i18n-badge');
|
||||
|
||||
const DEFAULT_LANGUAGE_TAG = 'en';
|
||||
|
||||
const BACKEND_SKIP_STRUCTS = new Set([
|
||||
'GlobalTextItems',
|
||||
'DefaultTypes',
|
||||
'DataConverterTextItems',
|
||||
]);
|
||||
|
||||
function discoverFrontendLanguages() {
|
||||
const indexPath = path.join(FRONTEND_LOCALES_DIR, 'index.ts');
|
||||
const content = fs.readFileSync(indexPath, 'utf-8');
|
||||
|
||||
const importMap = {};
|
||||
const importRegex = /import\s+(\w+)\s+from\s+['"]\.\/([\w_]+\.json)['"]/g;
|
||||
let match;
|
||||
|
||||
while ((match = importRegex.exec(content)) !== null) {
|
||||
importMap[match[1]] = match[2];
|
||||
}
|
||||
|
||||
const result = {};
|
||||
const langRegex = /['"]([^'"]+)['"]\s*:\s*\{[^}]*content\s*:\s*(\w+)/g;
|
||||
|
||||
while ((match = langRegex.exec(content)) !== null) {
|
||||
const tag = match[1];
|
||||
const varName = match[2];
|
||||
|
||||
if (importMap[varName]) {
|
||||
result[tag] = importMap[varName];
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function discoverBackendLanguages() {
|
||||
const allLocalesPath = path.join(BACKEND_LOCALES_DIR, 'all_locales.go');
|
||||
const content = fs.readFileSync(allLocalesPath, 'utf-8');
|
||||
|
||||
const result = {};
|
||||
const entryRegex = /"([^"]+)"\s*:\s*\{[^}]*Content\s*:\s*(\w+)/g;
|
||||
let match;
|
||||
|
||||
while ((match = entryRegex.exec(content)) !== null) {
|
||||
const tag = match[1];
|
||||
const fileName = tag.toLowerCase().replace(/-/g, '_') + '.go';
|
||||
const filePath = path.join(BACKEND_LOCALES_DIR, fileName);
|
||||
|
||||
if (fs.existsSync(filePath)) {
|
||||
result[tag] = fileName;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function flattenJSON(obj, prefix) {
|
||||
const result = {};
|
||||
|
||||
for (const key of Object.keys(obj)) {
|
||||
const fullKey = prefix ? prefix + '.' + key : key;
|
||||
|
||||
if (typeof obj[key] === 'object' && obj[key] !== null) {
|
||||
Object.assign(result, flattenJSON(obj[key], fullKey));
|
||||
} else {
|
||||
result[fullKey] = obj[key];
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function shouldSkipFrontendKey(key) {
|
||||
if (key.startsWith('global.')) {
|
||||
return true;
|
||||
} else if (key.startsWith('default.')) {
|
||||
return true;
|
||||
} else if (key.startsWith('currency.')) {
|
||||
if (key.startsWith('currency.unit.')) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
} else if (key.startsWith('mapprovider.')) {
|
||||
return true;
|
||||
} else if (key.startsWith('encoding.')) {
|
||||
return true;
|
||||
} else if (key.startsWith('document.')) {
|
||||
if (key.startsWith('document.anchor.')) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isFrontendAlwaysTranslatedKey(key) {
|
||||
if (key.startsWith('language.')) {
|
||||
return true;
|
||||
} else if (key.startsWith('format.')) {
|
||||
if (key.startsWith('format.misc.')) {
|
||||
if (key === 'format.misc.multiTextJoinSeparator') {
|
||||
return true;
|
||||
} else if (key === 'format.misc.eachMonthDayInMonthDays') {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
} else if (key.startsWith('datetime.')) {
|
||||
return true;
|
||||
} else if (key.startsWith('timezone.')) {
|
||||
return true;
|
||||
} else if (key.startsWith('currency.')) {
|
||||
if (key === 'currency.name.EUR') {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
} else if (key.startsWith('parameter.')) {
|
||||
if (key === 'parameter.id') {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
if (key === 'OK') {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function extractGoStringFields(content) {
|
||||
const fields = [];
|
||||
const structBlockRegex = /(\w+):\s*&\w+\{([^}]*)\}/gs;
|
||||
let blockMatch;
|
||||
|
||||
while ((blockMatch = structBlockRegex.exec(content)) !== null) {
|
||||
const structName = blockMatch[1];
|
||||
const blockBody = blockMatch[2];
|
||||
const fieldRegex = /(\w+):\s+"((?:[^"\\]|\\.)*)"/g;
|
||||
let fieldMatch;
|
||||
|
||||
while ((fieldMatch = fieldRegex.exec(blockBody)) !== null) {
|
||||
fields.push({
|
||||
struct: structName,
|
||||
name: fieldMatch[1],
|
||||
value: fieldMatch[2],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
function getProgressColor(progress) {
|
||||
if (progress >= 95) {
|
||||
return 'brightgreen';
|
||||
} else if (progress >= 90) {
|
||||
return 'green';
|
||||
} else if (progress >= 70) {
|
||||
return 'yellowgreen';
|
||||
} else if (progress >= 50) {
|
||||
return 'yellow';
|
||||
} else if (progress >= 20) {
|
||||
return 'orange';
|
||||
} else {
|
||||
return 'red';
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
const frontendLangs = discoverFrontendLanguages();
|
||||
const backendLangs = discoverBackendLanguages();
|
||||
const allTags = new Set([...Object.keys(frontendLangs), ...Object.keys(backendLangs)]);
|
||||
|
||||
console.log('Discovered ' + allTags.size + ' languages: ' + [...allTags].sort().join(', '));
|
||||
|
||||
const defaultFrontendJSON = JSON.parse(fs.readFileSync(path.join(FRONTEND_LOCALES_DIR, `${DEFAULT_LANGUAGE_TAG}.json`), 'utf-8'));
|
||||
const defaultFrontendItemsMap = flattenJSON(defaultFrontendJSON, '');
|
||||
const defaultFrontendKeys = Object.keys(defaultFrontendItemsMap);
|
||||
const frontendTranslatableKeys = defaultFrontendKeys.filter(function (k) {
|
||||
return !shouldSkipFrontendKey(k);
|
||||
});
|
||||
const frontendSkippedCount = defaultFrontendKeys.length - frontendTranslatableKeys.length;
|
||||
const frontendTotal = frontendTranslatableKeys.length;
|
||||
|
||||
const defaultBackendContent = fs.readFileSync(path.join(BACKEND_LOCALES_DIR, `${DEFAULT_LANGUAGE_TAG}.go`), 'utf-8');
|
||||
const defaultBackendItems = extractGoStringFields(defaultBackendContent);
|
||||
const defaultBackendTranslatableItems = defaultBackendItems.filter(function (f) {
|
||||
return !BACKEND_SKIP_STRUCTS.has(f.struct);
|
||||
});
|
||||
const backendSkippedCount = defaultBackendItems.length - defaultBackendTranslatableItems.length;
|
||||
const backendTotal = defaultBackendTranslatableItems.length;
|
||||
|
||||
console.log('Frontend: ' + frontendTotal + ' translatable keys (' + frontendSkippedCount + ' excluded)');
|
||||
console.log('Backend: ' + backendTotal + ' translatable fields (' + backendSkippedCount + ' excluded)');
|
||||
|
||||
const results = {};
|
||||
const untranslatedKeys = {};
|
||||
|
||||
for (const tag of allTags) {
|
||||
results[tag] = {
|
||||
languageTag: tag,
|
||||
frontendTranslated: 0,
|
||||
frontendTotal: frontendTotal,
|
||||
backendTranslated: 0,
|
||||
backendTotal: backendTotal
|
||||
};
|
||||
untranslatedKeys[tag] = [];
|
||||
}
|
||||
|
||||
for (const tag of Object.keys(frontendLangs)) {
|
||||
if (tag === DEFAULT_LANGUAGE_TAG) {
|
||||
results[tag].frontendTranslated = frontendTotal;
|
||||
continue;
|
||||
}
|
||||
|
||||
const file = frontendLangs[tag];
|
||||
const filePath = path.join(FRONTEND_LOCALES_DIR, file);
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const json = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
const kv = flattenJSON(json, '');
|
||||
let translated = 0;
|
||||
|
||||
for (const key of frontendTranslatableKeys) {
|
||||
if (kv[key] !== undefined && kv[key] !== '' && (kv[key] !== defaultFrontendItemsMap[key] || isFrontendAlwaysTranslatedKey(key))) {
|
||||
translated++;
|
||||
} else {
|
||||
untranslatedKeys[tag].push({ source: path.join('src', 'locales', file), key: key, defaultValue: defaultFrontendItemsMap[key], value: kv[key] });
|
||||
}
|
||||
}
|
||||
|
||||
results[tag].frontendTranslated = translated;
|
||||
}
|
||||
|
||||
for (const tag of Object.keys(backendLangs)) {
|
||||
if (tag === DEFAULT_LANGUAGE_TAG) {
|
||||
results[tag].backendTranslated = backendTotal;
|
||||
continue;
|
||||
}
|
||||
|
||||
const file = backendLangs[tag];
|
||||
const filePath = path.join(BACKEND_LOCALES_DIR, file);
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const fields = extractGoStringFields(content).filter(function (f) {
|
||||
return !BACKEND_SKIP_STRUCTS.has(f.struct);
|
||||
});
|
||||
let translated = 0;
|
||||
|
||||
for (let i = 0; i < defaultBackendTranslatableItems.length; i++) {
|
||||
if (i < fields.length && fields[i].value !== defaultBackendTranslatableItems[i].value) {
|
||||
translated++;
|
||||
} else {
|
||||
untranslatedKeys[tag].push({ source: path.join('pkg', 'locales', file), key: defaultBackendTranslatableItems[i].struct + '.' + defaultBackendTranslatableItems[i].name, defaultValue: defaultBackendTranslatableItems[i].value, value: (i < fields.length) ? fields[i].value : null });
|
||||
}
|
||||
}
|
||||
|
||||
results[tag].backendTranslated = translated;
|
||||
}
|
||||
|
||||
for (const tag of Object.keys(results)) {
|
||||
const r = results[tag];
|
||||
const totalTranslated = r.frontendTranslated + r.backendTranslated;
|
||||
const totalItems = r.frontendTotal + r.backendTotal;
|
||||
r.totalProgress = Math.round((totalTranslated / totalItems) * 10000) / 100;
|
||||
}
|
||||
|
||||
const sortedResults = {};
|
||||
var sortedTags = Object.keys(results).sort();
|
||||
|
||||
for (const tag of sortedTags) {
|
||||
sortedResults[tag] = results[tag];
|
||||
}
|
||||
|
||||
if (!fs.existsSync(OUTPUT_DIR)) {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
var badgesDir = path.join(OUTPUT_DIR, 'badges');
|
||||
|
||||
if (!fs.existsSync(badgesDir)) {
|
||||
fs.mkdirSync(badgesDir, { recursive: true });
|
||||
}
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(OUTPUT_DIR, 'i18n-progress.json'),
|
||||
JSON.stringify(sortedResults, null, 4) + '\n'
|
||||
);
|
||||
|
||||
for (const tag of sortedTags) {
|
||||
const data = sortedResults[tag];
|
||||
const badge = {
|
||||
schemaVersion: 1,
|
||||
label: 'translation',
|
||||
message: data.totalProgress + '%',
|
||||
color: getProgressColor(data.totalProgress)
|
||||
};
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(badgesDir, tag + '.json'),
|
||||
JSON.stringify(badge, null, 4) + '\n'
|
||||
);
|
||||
}
|
||||
|
||||
var untranslatedDir = path.join(OUTPUT_DIR, 'untranslated');
|
||||
|
||||
if (!fs.existsSync(untranslatedDir)) {
|
||||
fs.mkdirSync(untranslatedDir, { recursive: true });
|
||||
}
|
||||
|
||||
for (const tag of sortedTags) {
|
||||
const items = untranslatedKeys[tag] || [];
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(untranslatedDir, tag + '.json'),
|
||||
JSON.stringify(items, null, 4) + '\n'
|
||||
);
|
||||
}
|
||||
|
||||
for (const tag of sortedTags) {
|
||||
const data = sortedResults[tag];
|
||||
const missingCount = (untranslatedKeys[tag] || []).length;
|
||||
console.log(tag + ': ' + data.totalProgress + '% (frontend: ' + data.frontendTranslated + '/' + data.frontendTotal + ', backend: ' + data.backendTranslated + '/' + data.backendTotal + ', untranslated: ' + missingCount + ')');
|
||||
}
|
||||
|
||||
console.log('\nResults written to ' + OUTPUT_DIR);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -18,15 +18,14 @@ jobs:
|
||||
ezbookkeeping-docker-digests-file-path: ${{ steps.variable.outputs.ezbookkeeping_docker_digests_file_path }}
|
||||
ezbookkeeping-docker-digests-artifact-name-prefix: ${{ steps.variable.outputs.ezbookkeeping_docker_digests_artifact_name_prefix }}
|
||||
ezbookkeeping-package-file-name-prefix: ${{ steps.variable.outputs.ezbookkeeping_package_file_name_prefix }}
|
||||
ezbookkeeping-package-artifact-name-prefix: ${{ steps.variable.outputs.ezbookkeeping_package_artifact_name_prefix }}
|
||||
ezbookkeeping-backend-artifact-name-prefix: ${{ steps.variable.outputs.ezbookkeeping_backend_artifact_name_prefix }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: |
|
||||
${{ vars.DOCKER_IMAGE_NAME }}
|
||||
@@ -43,7 +42,6 @@ jobs:
|
||||
echo "ezbookkeeping_docker_digests_file_path=${{ runner.temp }}/digests" >> "$GITHUB_OUTPUT"
|
||||
echo "ezbookkeeping_docker_digests_artifact_name_prefix=ezbookkeeping-build-dev-digests-${{ github.run_id }}" >> "$GITHUB_OUTPUT"
|
||||
echo "ezbookkeeping_package_file_name_prefix=ezbookkeeping-dev-package-${{ github.run_id }}" >> "$GITHUB_OUTPUT"
|
||||
echo "ezbookkeeping_package_artifact_name_prefix=ezbookkeeping-build-dev-package-${{ github.run_id }}" >> "$GITHUB_OUTPUT"
|
||||
echo "ezbookkeeping_backend_artifact_name_prefix=ezbookkeeping-build-dev-backend-${{ github.run_id }}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Rename docker bake meta file
|
||||
@@ -51,7 +49,7 @@ jobs:
|
||||
mv "${{ steps.meta.outputs.bake-file }}" "${{ steps.variable.outputs.ezbookkeeping_docker_bake_meta_file_path }}"
|
||||
|
||||
- name: Upload docker bake meta artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ${{ steps.variable.outputs.ezbookkeeping_docker_bake_meta_artifact_name }}
|
||||
path: ${{ steps.variable.outputs.ezbookkeeping_docker_bake_meta_file_path }}
|
||||
@@ -63,7 +61,7 @@ jobs:
|
||||
- setup
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- uses: ./.github/actions/build-linux-docker-and-package
|
||||
with:
|
||||
@@ -80,7 +78,6 @@ jobs:
|
||||
docker-bake-digests-file-path: ${{ needs.setup.outputs.ezbookkeeping-docker-digests-file-path }}
|
||||
docker-bake-digests-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-docker-digests-artifact-name-prefix }}
|
||||
package-file-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-file-name-prefix }}
|
||||
package-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-artifact-name-prefix }}
|
||||
|
||||
build-linux-docker-and-package-arm:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
@@ -97,7 +94,7 @@ jobs:
|
||||
platform-name: linux-armv6
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- uses: ./.github/actions/build-linux-docker-and-package
|
||||
with:
|
||||
@@ -114,7 +111,6 @@ jobs:
|
||||
docker-bake-digests-file-path: ${{ needs.setup.outputs.ezbookkeeping-docker-digests-file-path }}
|
||||
docker-bake-digests-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-docker-digests-artifact-name-prefix }}
|
||||
package-file-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-file-name-prefix }}
|
||||
package-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-artifact-name-prefix }}
|
||||
|
||||
build-windows-backend:
|
||||
needs:
|
||||
@@ -122,7 +118,7 @@ jobs:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- uses: ./.github/actions/build-windows-backend
|
||||
with:
|
||||
@@ -132,6 +128,23 @@ jobs:
|
||||
skip-tests: ${{ vars.SKIP_TESTS }}
|
||||
backend-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-backend-artifact-name-prefix }}
|
||||
|
||||
build-macos-backend:
|
||||
needs:
|
||||
- setup
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- uses: ./.github/actions/build-macos-backend
|
||||
with:
|
||||
architecture: arm64
|
||||
build-unix-time: ${{ needs.setup.outputs.build-unix-time }}
|
||||
build-date: ${{ needs.setup.outputs.build-date }}
|
||||
check-3rd-api: ${{ vars.CHECK_3RD_API }}
|
||||
skip-tests: ${{ vars.SKIP_TESTS }}
|
||||
backend-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-backend-artifact-name-prefix }}
|
||||
|
||||
build-windows-package:
|
||||
needs:
|
||||
- setup
|
||||
@@ -140,10 +153,25 @@ jobs:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- uses: ./.github/actions/build-windows-package
|
||||
with:
|
||||
package-file-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-file-name-prefix }}
|
||||
package-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-artifact-name-prefix }}
|
||||
backend-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-backend-artifact-name-prefix }}
|
||||
|
||||
build-macos-package:
|
||||
needs:
|
||||
- setup
|
||||
- build-macos-backend
|
||||
- build-linux-docker-and-package-x86
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- uses: ./.github/actions/build-macos-package
|
||||
with:
|
||||
architecture: arm64
|
||||
package-file-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-file-name-prefix }}
|
||||
backend-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-backend-artifact-name-prefix }}
|
||||
|
||||
@@ -9,6 +9,7 @@ jobs:
|
||||
setup:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
docker-push: ${{ steps.variable.outputs.docker_push }}
|
||||
build-unix-time: ${{ steps.variable.outputs.build_unix_time }}
|
||||
build-date: ${{ steps.variable.outputs.build_date }}
|
||||
docker-version: ${{ steps.meta.outputs.version }}
|
||||
@@ -19,15 +20,14 @@ jobs:
|
||||
ezbookkeeping-docker-digests-file-path: ${{ steps.variable.outputs.ezbookkeeping_docker_digests_file_path }}
|
||||
ezbookkeeping-docker-digests-artifact-name-prefix: ${{ steps.variable.outputs.ezbookkeeping_docker_digests_artifact_name_prefix }}
|
||||
ezbookkeeping-package-file-name-prefix: ${{ steps.variable.outputs.ezbookkeeping_package_file_name_prefix }}
|
||||
ezbookkeeping-package-artifact-name-prefix: ${{ steps.variable.outputs.ezbookkeeping_package_artifact_name_prefix }}
|
||||
ezbookkeeping-backend-artifact-name-prefix: ${{ steps.variable.outputs.ezbookkeeping_backend_artifact_name_prefix }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: |
|
||||
${{ vars.DOCKER_IMAGE_NAME }}
|
||||
@@ -39,6 +39,7 @@ jobs:
|
||||
- name: Set up variables
|
||||
id: variable
|
||||
run: |
|
||||
echo "docker_push=${{ vars.DOCKER_IMAGE_NAME != '' && vars.DOCKER_USERNAME != '' && secrets.DOCKER_PASSWORD != '' }}" >> "$GITHUB_OUTPUT"
|
||||
echo "build_unix_time=$(date '+%s')" >> "$GITHUB_OUTPUT"
|
||||
echo "build_date=$(date '+%Y%m%d')" >> "$GITHUB_OUTPUT"
|
||||
echo "ezbookkeeping_docker_bake_meta_file_path=${{ runner.temp }}/bake-meta.json" >> "$GITHUB_OUTPUT"
|
||||
@@ -46,7 +47,6 @@ jobs:
|
||||
echo "ezbookkeeping_docker_digests_file_path=${{ runner.temp }}/digests" >> "$GITHUB_OUTPUT"
|
||||
echo "ezbookkeeping_docker_digests_artifact_name_prefix=ezbookkeeping-build-release-digests-${{ github.run_id }}" >> "$GITHUB_OUTPUT"
|
||||
echo "ezbookkeeping_package_file_name_prefix=ezbookkeeping-${{ github.ref_name }}" >> "$GITHUB_OUTPUT"
|
||||
echo "ezbookkeeping_package_artifact_name_prefix=ezbookkeeping-build-release-package-${{ github.run_id }}" >> "$GITHUB_OUTPUT"
|
||||
echo "ezbookkeeping_backend_artifact_name_prefix=ezbookkeeping-build-release-backend-${{ github.run_id }}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Rename docker bake meta file
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
mv "${{ steps.meta.outputs.bake-file }}" "${{ steps.variable.outputs.ezbookkeeping_docker_bake_meta_file_path }}"
|
||||
|
||||
- name: Upload docker bake meta artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ${{ steps.variable.outputs.ezbookkeeping_docker_bake_meta_artifact_name }}
|
||||
path: ${{ steps.variable.outputs.ezbookkeeping_docker_bake_meta_file_path }}
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
- setup
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- uses: ./.github/actions/build-linux-docker-and-package
|
||||
with:
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
skip-tests: ${{ vars.SKIP_TESTS }}
|
||||
platform: linux/amd64
|
||||
platform-name: linux-amd64
|
||||
docker-push: true
|
||||
docker-push: ${{ needs.setup.outputs.docker-push }}
|
||||
docker-image-name: ${{ vars.DOCKER_IMAGE_NAME }}
|
||||
docker-username: ${{ vars.DOCKER_USERNAME }}
|
||||
docker-password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
@@ -86,7 +86,6 @@ jobs:
|
||||
docker-bake-digests-file-path: ${{ needs.setup.outputs.ezbookkeeping-docker-digests-file-path }}
|
||||
docker-bake-digests-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-docker-digests-artifact-name-prefix }}
|
||||
package-file-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-file-name-prefix }}
|
||||
package-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-artifact-name-prefix }}
|
||||
|
||||
build-linux-docker-and-package-arm:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
@@ -103,7 +102,7 @@ jobs:
|
||||
platform-name: linux-armv6
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- uses: ./.github/actions/build-linux-docker-and-package
|
||||
with:
|
||||
@@ -114,7 +113,7 @@ jobs:
|
||||
skip-tests: ${{ vars.SKIP_TESTS }}
|
||||
platform: ${{ matrix.platform }}
|
||||
platform-name: ${{ matrix.platform-name }}
|
||||
docker-push: true
|
||||
docker-push: ${{ needs.setup.outputs.docker-push }}
|
||||
docker-image-name: ${{ vars.DOCKER_IMAGE_NAME }}
|
||||
docker-username: ${{ vars.DOCKER_USERNAME }}
|
||||
docker-password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
@@ -123,9 +122,9 @@ jobs:
|
||||
docker-bake-digests-file-path: ${{ needs.setup.outputs.ezbookkeeping-docker-digests-file-path }}
|
||||
docker-bake-digests-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-docker-digests-artifact-name-prefix }}
|
||||
package-file-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-file-name-prefix }}
|
||||
package-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-artifact-name-prefix }}
|
||||
|
||||
push-linux-docker:
|
||||
if: ${{ needs.setup.outputs.docker-push == 'true' }}
|
||||
needs:
|
||||
- setup
|
||||
- build-linux-docker-and-package-x86
|
||||
@@ -133,7 +132,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- uses: ./.github/actions/push-linux-docker
|
||||
with:
|
||||
@@ -152,7 +151,7 @@ jobs:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- uses: ./.github/actions/build-windows-backend
|
||||
with:
|
||||
@@ -163,6 +162,24 @@ jobs:
|
||||
skip-tests: ${{ vars.SKIP_TESTS }}
|
||||
backend-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-backend-artifact-name-prefix }}
|
||||
|
||||
build-macos-backend:
|
||||
needs:
|
||||
- setup
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- uses: ./.github/actions/build-macos-backend
|
||||
with:
|
||||
architecture: arm64
|
||||
release-build: 1
|
||||
build-unix-time: ${{ needs.setup.outputs.build-unix-time }}
|
||||
build-date: ${{ needs.setup.outputs.build-date }}
|
||||
check-3rd-api: ${{ vars.CHECK_3RD_API }}
|
||||
skip-tests: ${{ vars.SKIP_TESTS }}
|
||||
backend-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-backend-artifact-name-prefix }}
|
||||
|
||||
build-windows-package:
|
||||
needs:
|
||||
- setup
|
||||
@@ -171,12 +188,27 @@ jobs:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- uses: ./.github/actions/build-windows-package
|
||||
with:
|
||||
package-file-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-file-name-prefix }}
|
||||
package-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-artifact-name-prefix }}
|
||||
backend-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-backend-artifact-name-prefix }}
|
||||
|
||||
build-macos-package:
|
||||
needs:
|
||||
- setup
|
||||
- build-macos-backend
|
||||
- build-linux-docker-and-package-x86
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- uses: ./.github/actions/build-macos-package
|
||||
with:
|
||||
architecture: arm64
|
||||
package-file-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-file-name-prefix }}
|
||||
backend-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-backend-artifact-name-prefix }}
|
||||
|
||||
publish-release:
|
||||
@@ -186,17 +218,19 @@ jobs:
|
||||
- build-linux-docker-and-package-x86
|
||||
- build-linux-docker-and-package-arm
|
||||
- build-windows-package
|
||||
- build-macos-package
|
||||
- push-linux-docker
|
||||
steps:
|
||||
- name: Download all packaged files
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: ${{ needs.setup.outputs.ezbookkeeping-package-artifact-name-prefix }}-*
|
||||
pattern: ${{ needs.setup.outputs.ezbookkeeping-package-file-name-prefix }}-*
|
||||
merge-multiple: true
|
||||
skip-decompress: true
|
||||
path: release-files
|
||||
|
||||
- name: Publish Release ${{ github.ref_name }}
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
name: ${{ github.ref_name }}
|
||||
tag_name: ${{ github.ref_name }}
|
||||
|
||||
@@ -9,6 +9,7 @@ jobs:
|
||||
setup:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
docker-push: ${{ steps.variable.outputs.docker_push }}
|
||||
build-unix-time: ${{ steps.variable.outputs.build_unix_time }}
|
||||
build-date: ${{ steps.variable.outputs.build_date }}
|
||||
docker-version: ${{ steps.meta.outputs.version }}
|
||||
@@ -19,15 +20,14 @@ jobs:
|
||||
ezbookkeeping-docker-digests-file-path: ${{ steps.variable.outputs.ezbookkeeping_docker_digests_file_path }}
|
||||
ezbookkeeping-docker-digests-artifact-name-prefix: ${{ steps.variable.outputs.ezbookkeeping_docker_digests_artifact_name_prefix }}
|
||||
ezbookkeeping-package-file-name-prefix: ${{ steps.variable.outputs.ezbookkeeping_package_file_name_prefix }}
|
||||
ezbookkeeping-package-artifact-name-prefix: ${{ steps.variable.outputs.ezbookkeeping_package_artifact_name_prefix }}
|
||||
ezbookkeeping-backend-artifact-name-prefix: ${{ steps.variable.outputs.ezbookkeeping_backend_artifact_name_prefix }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: |
|
||||
${{ vars.DOCKER_IMAGE_NAME }}
|
||||
@@ -39,6 +39,7 @@ jobs:
|
||||
- name: Set up variables
|
||||
id: variable
|
||||
run: |
|
||||
echo "docker_push=${{ vars.DOCKER_IMAGE_NAME != '' && vars.DOCKER_USERNAME != '' && secrets.DOCKER_PASSWORD != '' }}" >> "$GITHUB_OUTPUT"
|
||||
echo "build_unix_time=$(date '+%s')" >> "$GITHUB_OUTPUT"
|
||||
echo "build_date=$(date '+%Y%m%d')" >> "$GITHUB_OUTPUT"
|
||||
echo "ezbookkeeping_docker_bake_meta_file_path=${{ runner.temp }}/bake-meta.json" >> "$GITHUB_OUTPUT"
|
||||
@@ -46,7 +47,6 @@ jobs:
|
||||
echo "ezbookkeeping_docker_digests_file_path=${{ runner.temp }}/digests" >> "$GITHUB_OUTPUT"
|
||||
echo "ezbookkeeping_docker_digests_artifact_name_prefix=ezbookkeeping-build-dev-digests-${{ github.run_id }}" >> "$GITHUB_OUTPUT"
|
||||
echo "ezbookkeeping_package_file_name_prefix=ezbookkeeping-dev-package-${{ github.run_id }}" >> "$GITHUB_OUTPUT"
|
||||
echo "ezbookkeeping_package_artifact_name_prefix=ezbookkeeping-build-dev-package-${{ github.run_id }}" >> "$GITHUB_OUTPUT"
|
||||
echo "ezbookkeeping_backend_artifact_name_prefix=ezbookkeeping-build-dev-backend-${{ github.run_id }}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Rename docker bake meta file
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
mv "${{ steps.meta.outputs.bake-file }}" "${{ steps.variable.outputs.ezbookkeeping_docker_bake_meta_file_path }}"
|
||||
|
||||
- name: Upload docker bake meta artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ${{ steps.variable.outputs.ezbookkeeping_docker_bake_meta_artifact_name }}
|
||||
path: ${{ steps.variable.outputs.ezbookkeeping_docker_bake_meta_file_path }}
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
- setup
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- uses: ./.github/actions/build-linux-docker-and-package
|
||||
with:
|
||||
@@ -76,7 +76,7 @@ jobs:
|
||||
skip-tests: ${{ vars.SKIP_TESTS }}
|
||||
platform: linux/amd64
|
||||
platform-name: linux-amd64
|
||||
docker-push: true
|
||||
docker-push: ${{ needs.setup.outputs.docker-push }}
|
||||
docker-image-name: ${{ vars.DOCKER_IMAGE_NAME }}
|
||||
docker-username: ${{ vars.DOCKER_USERNAME }}
|
||||
docker-password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
@@ -85,7 +85,6 @@ jobs:
|
||||
docker-bake-digests-file-path: ${{ needs.setup.outputs.ezbookkeeping-docker-digests-file-path }}
|
||||
docker-bake-digests-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-docker-digests-artifact-name-prefix }}
|
||||
package-file-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-file-name-prefix }}
|
||||
package-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-artifact-name-prefix }}
|
||||
|
||||
build-linux-docker-and-package-arm:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
@@ -102,7 +101,7 @@ jobs:
|
||||
platform-name: linux-armv6
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- uses: ./.github/actions/build-linux-docker-and-package
|
||||
with:
|
||||
@@ -112,7 +111,7 @@ jobs:
|
||||
skip-tests: ${{ vars.SKIP_TESTS }}
|
||||
platform: ${{ matrix.platform }}
|
||||
platform-name: ${{ matrix.platform-name }}
|
||||
docker-push: true
|
||||
docker-push: ${{ needs.setup.outputs.docker-push }}
|
||||
docker-image-name: ${{ vars.DOCKER_IMAGE_NAME }}
|
||||
docker-username: ${{ vars.DOCKER_USERNAME }}
|
||||
docker-password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
@@ -121,9 +120,9 @@ jobs:
|
||||
docker-bake-digests-file-path: ${{ needs.setup.outputs.ezbookkeeping-docker-digests-file-path }}
|
||||
docker-bake-digests-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-docker-digests-artifact-name-prefix }}
|
||||
package-file-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-file-name-prefix }}
|
||||
package-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-artifact-name-prefix }}
|
||||
|
||||
push-linux-docker:
|
||||
if: ${{ needs.setup.outputs.docker-push == 'true' }}
|
||||
needs:
|
||||
- setup
|
||||
- build-linux-docker-and-package-x86
|
||||
@@ -131,7 +130,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- uses: ./.github/actions/push-linux-docker
|
||||
with:
|
||||
@@ -150,7 +149,7 @@ jobs:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- uses: ./.github/actions/build-windows-backend
|
||||
with:
|
||||
@@ -160,6 +159,23 @@ jobs:
|
||||
skip-tests: ${{ vars.SKIP_TESTS }}
|
||||
backend-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-backend-artifact-name-prefix }}
|
||||
|
||||
build-macos-backend:
|
||||
needs:
|
||||
- setup
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- uses: ./.github/actions/build-macos-backend
|
||||
with:
|
||||
architecture: arm64
|
||||
build-unix-time: ${{ needs.setup.outputs.build-unix-time }}
|
||||
build-date: ${{ needs.setup.outputs.build-date }}
|
||||
check-3rd-api: ${{ vars.CHECK_3RD_API }}
|
||||
skip-tests: ${{ vars.SKIP_TESTS }}
|
||||
backend-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-backend-artifact-name-prefix }}
|
||||
|
||||
build-windows-package:
|
||||
needs:
|
||||
- setup
|
||||
@@ -168,10 +184,25 @@ jobs:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- uses: ./.github/actions/build-windows-package
|
||||
with:
|
||||
package-file-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-file-name-prefix }}
|
||||
package-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-artifact-name-prefix }}
|
||||
backend-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-backend-artifact-name-prefix }}
|
||||
|
||||
build-macos-package:
|
||||
needs:
|
||||
- setup
|
||||
- build-macos-backend
|
||||
- build-linux-docker-and-package-x86
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- uses: ./.github/actions/build-macos-package
|
||||
with:
|
||||
architecture: arm64
|
||||
package-file-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-package-file-name-prefix }}
|
||||
backend-artifact-name-prefix: ${{ needs.setup.outputs.ezbookkeeping-backend-artifact-name-prefix }}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
name: Update i18n Translation Progress Badges
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'src/locales/**'
|
||||
- 'pkg/locales/**'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
update-i18n-progress:
|
||||
if: ${{ vars.UPDATE_I18N_BADGE_REPO == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24'
|
||||
|
||||
- name: Update translation progress data
|
||||
run: |
|
||||
node .github/scripts/update-i18n-progress.js ${{ runner.temp }}/i18n-badge
|
||||
|
||||
- name: Checkout badge repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
repository: mayswind/ezbookkeeping-i18n-badge
|
||||
token: ${{ secrets.I18N_BADGE_REPO_TOKEN }}
|
||||
path: ezbookkeeping-i18n-badge
|
||||
|
||||
- name: Update badge data
|
||||
run: |
|
||||
rm -rf ezbookkeeping-i18n-badge/i18n-progress.json
|
||||
cp ${{ runner.temp }}/i18n-badge/i18n-progress.json ezbookkeeping-i18n-badge/
|
||||
mkdir -p ezbookkeeping-i18n-badge/badges
|
||||
rm -rf ezbookkeeping-i18n-badge/badges/*
|
||||
cp ${{ runner.temp }}/i18n-badge/badges/*.json ezbookkeeping-i18n-badge/badges/
|
||||
mkdir -p ezbookkeeping-i18n-badge/untranslated
|
||||
rm -rf ezbookkeeping-i18n-badge/untranslated/*
|
||||
cp ${{ runner.temp }}/i18n-badge/untranslated/*.json ezbookkeeping-i18n-badge/untranslated/
|
||||
|
||||
- name: Commit and push
|
||||
run: |
|
||||
cd ezbookkeeping-i18n-badge
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add -A
|
||||
if git diff --cached --quiet; then
|
||||
echo "No changes to commit"
|
||||
else
|
||||
git commit -m "Update i18n progress data (${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }})"
|
||||
git push
|
||||
fi
|
||||
|
||||
- name: Purge GitHub camo image cache
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
CAMO_URLS=$(curl -s -H "Accept: application/vnd.github.html+json" -H "Authorization: token $GITHUB_TOKEN" "https://api.github.com/repos/${{ github.repository }}/readme" | grep -oP 'https://camo\.githubusercontent\.com/[^"]+' | sort -u)
|
||||
|
||||
if [ -z "$CAMO_URLS" ]; then
|
||||
echo "No camo URLs found, skipping cache purge"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
for url in $CAMO_URLS; do
|
||||
echo "Purging: $url"
|
||||
curl -s -X PURGE "$url" > /dev/null
|
||||
done
|
||||
|
||||
echo "Purged $(echo "$CAMO_URLS" | wc -l) camo URLs"
|
||||
+14
@@ -147,3 +147,17 @@ dist/
|
||||
|
||||
# Roo Code
|
||||
.roo/
|
||||
|
||||
# Binary and build files
|
||||
ezbookkeeping
|
||||
!**/ezbookkeeping/
|
||||
/package/
|
||||
|
||||
# Environment variable files
|
||||
.env
|
||||
**/.env
|
||||
|
||||
# Other directories
|
||||
/data/
|
||||
/storage/
|
||||
/log/
|
||||
|
||||
+3
-3
@@ -1,5 +1,5 @@
|
||||
# Build backend binary file
|
||||
FROM golang:1.25.5-alpine3.23 AS be-builder
|
||||
FROM golang:1.26.2-alpine3.23 AS be-builder
|
||||
ARG RELEASE_BUILD
|
||||
ARG BUILD_PIPELINE
|
||||
ARG BUILD_UNIXTIME
|
||||
@@ -19,7 +19,7 @@ RUN apk add git gcc g++ libc-dev
|
||||
RUN ./build.sh backend
|
||||
|
||||
# Build frontend files
|
||||
FROM --platform=$BUILDPLATFORM node:24.12.0-alpine3.23 AS fe-builder
|
||||
FROM --platform=$BUILDPLATFORM node:24.15.0-alpine3.23 AS fe-builder
|
||||
ARG RELEASE_BUILD
|
||||
ARG BUILD_PIPELINE
|
||||
ARG BUILD_UNIXTIME
|
||||
@@ -35,7 +35,7 @@ RUN apk add git
|
||||
RUN ./build.sh frontend
|
||||
|
||||
# Package docker image
|
||||
FROM alpine:3.23.2
|
||||
FROM alpine:3.23.4
|
||||
LABEL maintainer="MaysWind <i@mayswind.net>"
|
||||
RUN addgroup -S -g 1000 ezbookkeeping && adduser -S -G ezbookkeeping -u 1000 ezbookkeeping
|
||||
RUN apk --no-cache add tzdata
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
[](https://trendshift.io/repositories/12917)
|
||||
|
||||
## Introduction
|
||||
ezBookkeeping is a lightweight, self-hosted personal finance app with a user-friendly interface and powerful bookkeeping features. It's easy to deploy, and you can start it with just one single Docker command. Designed to be resource-efficient and highly scalable, it can run smoothly on devices as small as a Raspberry Pi, or scale up to NAS, MicroServers, and even large cluster environments.
|
||||
ezBookkeeping is a lightweight, self-hosted personal finance app with a user-friendly interface and powerful bookkeeping features. It helps you record daily transactions, import data from various sources, and quickly search and filter your bills. You can analyze historical data using built-in charts or perform custom queries with your own chart dimensions to better understand spending patterns and financial trends. ezBookkeeping is easy to deploy, and you can start it with just one single Docker command. Designed to be resource-efficient, it runs smoothly on devices such as Raspberry Pi, NAS, and MicroServers.
|
||||
|
||||
ezBookkeeping offers tailored interfaces for both mobile and desktop devices. With support for PWA (Progressive Web Apps), you can even [add it to your mobile home screen](https://raw.githubusercontent.com/wiki/mayswind/ezbookkeeping/img/mobile/add_to_home_screen.gif) and use it like a native app.
|
||||
|
||||
@@ -21,9 +21,9 @@ Live Demo: [https://ezbookkeeping-demo.mayswind.net](https://ezbookkeeping-demo.
|
||||
- **Open Source & Self-Hosted**
|
||||
- Built for privacy and control
|
||||
- **Lightweight & Fast**
|
||||
- Optimized for performance, runs smoothly even on low-resource environments
|
||||
- Minimal resource usage, runs smoothly even on low-resource devices
|
||||
- **Easy Installation**
|
||||
- Docker-ready
|
||||
- Docker support
|
||||
- Supports SQLite, MySQL, PostgreSQL
|
||||
- Cross-platform (Windows, macOS, Linux)
|
||||
- Works on x86, amd64, ARM architectures
|
||||
@@ -33,24 +33,28 @@ Live Demo: [https://ezbookkeeping-demo.mayswind.net](https://ezbookkeeping-demo.
|
||||
- Dark mode
|
||||
- **AI-Powered Features**
|
||||
- Receipt image recognition
|
||||
- Supports MCP (Model Context Protocol) for AI integration
|
||||
- MCP (Model Context Protocol) support for AI integration
|
||||
- Agent Skill and API command-line script tools support for AI integration
|
||||
- **Powerful Bookkeeping**
|
||||
- Two-level accounts and categories
|
||||
- Attach images to transactions
|
||||
- Image attachments for transactions
|
||||
- Location tracking with maps
|
||||
- Recurring transactions
|
||||
- Advanced filtering, search, visualization, and analysis
|
||||
- **Localization & Globalization**
|
||||
- Scheduled transactions
|
||||
- Advanced filtering, search, visualization and analysis
|
||||
- **Localization & Internationalization**
|
||||
- Multi-language and multi-currency support
|
||||
- Automatic exchange rates
|
||||
- Multi-timezone awareness
|
||||
- Custom formats for dates, numbers, and currencies
|
||||
- Multiple exchange rate sources with automatic updates
|
||||
- Multi-timezone support
|
||||
- Custom formats for dates, numbers and currencies
|
||||
- **Security**
|
||||
- Two-factor authentication (2FA)
|
||||
- OIDC external authentication
|
||||
- Login rate limiting
|
||||
- Application lock (PIN code / WebAuthn)
|
||||
- **Data Import/Export**
|
||||
- Supports CSV, OFX, QFX, QIF, IIF, Camt.053, MT940, GnuCash, Firefly III, Beancount, and more
|
||||
- **Data Import & Export**
|
||||
- Supports CSV, OFX, QFX, QIF, IIF, Camt.052, Camt.053, MT940, GnuCash, Firefly III, Beancount and more
|
||||
|
||||
For a full list of features, visit the [Full Feature List](https://ezbookkeeping.mayswind.net/features/).
|
||||
|
||||
## Screenshots
|
||||
### Desktop Version
|
||||
@@ -112,42 +116,40 @@ You can also build a Docker image. Make sure you have [Docker](https://www.docke
|
||||
## Contributing
|
||||
We welcome contributions of all kinds.
|
||||
|
||||
Found a bug? [Submit an issue](https://github.com/mayswind/ezbookkeeping/issues)
|
||||
If you find a bug, please [submit an issue](https://github.com/mayswind/ezbookkeeping/issues) on GitHub.
|
||||
|
||||
Want to contribute code? Feel free to fork and send a pull request.
|
||||
If you would like to contribute code, you can fork the repository and open a pull request.
|
||||
|
||||
Contributions of all kinds — bug reports, feature suggestions, documentation improvements, or code — are highly appreciated.
|
||||
Improvements to documentation, feature suggestions, and other forms of feedback are also appreciated.
|
||||
|
||||
Check out our [Contributor Graph](https://github.com/mayswind/ezbookkeeping/graphs/contributors) to see the amazing people who've already helped.
|
||||
You can view existing contributors on the [Contributor Graph](https://github.com/mayswind/ezbookkeeping/graphs/contributors).
|
||||
|
||||
## Translating
|
||||
Help make ezBookkeeping accessible to users around the world. If you want to contribute a translation, please refer to our [translation guide](https://ezbookkeeping.mayswind.net/translating).
|
||||
Help make ezBookkeeping accessible to users around the world. We welcome help to improve existing translations or add new ones. If you would like to contribute a translation, please refer to the [translation guide](https://ezbookkeeping.mayswind.net/translating).
|
||||
|
||||
Currently available translations:
|
||||
|
||||
| Tag | Language | Contributors |
|
||||
| --- | --- | --- |
|
||||
| de | Deutsch | [@chrgm](https://github.com/chrgm) |
|
||||
| en | English | / |
|
||||
| es | Español | [@Miguelonlonlon](https://github.com/Miguelonlonlon), [@abrugues](https://github.com/abrugues), [@AndresTeller](https://github.com/AndresTeller), [@diegofercri](https://github.com/diegofercri) |
|
||||
| fr | Français | [@brieucdlf](https://github.com/brieucdlf) |
|
||||
| it | Italiano | [@waron97](https://github.com/waron97) |
|
||||
| ja | 日本語 | [@tkymmm](https://github.com/tkymmm) |
|
||||
| kn | ಕನ್ನಡ | [@Darshanbm05](https://github.com/Darshanbm05) |
|
||||
| ko | 한국어 | [@overworks](https://github.com/overworks) |
|
||||
| nl | Nederlands | [@automagics](https://github.com/automagics) |
|
||||
| pt-BR | Português (Brasil) | [@thecodergus](https://github.com/thecodergus) |
|
||||
| ru | Русский | [@artegoser](https://github.com/artegoser) |
|
||||
| sl | Slovenščina | [@thehijacker](https://github.com/thehijacker) |
|
||||
| ta | தமிழ் | [@hhharsha36](https://github.com/hhharsha36) |
|
||||
| th | ไทย | [@natthavat28](https://github.com/natthavat28) |
|
||||
| tr | Türkçe | [@aydnykn](https://github.com/aydnykn) |
|
||||
| uk | Українська | [@nktlitvinenko](https://github.com/nktlitvinenko) |
|
||||
| vi | Tiếng Việt | [@f97](https://github.com/f97) |
|
||||
| zh-Hans | 中文 (简体) | / |
|
||||
| zh-Hant | 中文 (繁體) | / |
|
||||
|
||||
Don't see your language? Help us add it.
|
||||
| Tag | Language | Progress | Contributors |
|
||||
| --- | --- | --- | --- |
|
||||
| de | Deutsch | [](https://github.com/mayswind/ezBookkeeping-i18n-badge/blob/main/untranslated/de.json) | [@chrgm](https://github.com/chrgm), [@1270o1](https://github.com/1270o1) |
|
||||
| en | English | [](https://github.com/mayswind/ezBookkeeping-i18n-badge/blob/main/untranslated/en.json) | / |
|
||||
| es | Español | [](https://github.com/mayswind/ezBookkeeping-i18n-badge/blob/main/untranslated/es.json) | [@Miguelonlonlon](https://github.com/Miguelonlonlon), [@abrugues](https://github.com/abrugues), [@AndresTeller](https://github.com/AndresTeller), [@diegofercri](https://github.com/diegofercri) |
|
||||
| fr | Français | [](https://github.com/mayswind/ezBookkeeping-i18n-badge/blob/main/untranslated/fr.json) | [@brieucdlf](https://github.com/brieucdlf) |
|
||||
| it | Italiano | [](https://github.com/mayswind/ezBookkeeping-i18n-badge/blob/main/untranslated/it.json) | [@waron97](https://github.com/waron97) |
|
||||
| ja | 日本語 | [](https://github.com/mayswind/ezBookkeeping-i18n-badge/blob/main/untranslated/ja.json) | [@tkymmm](https://github.com/tkymmm) |
|
||||
| kn | ಕನ್ನಡ | [](https://github.com/mayswind/ezBookkeeping-i18n-badge/blob/main/untranslated/kn.json) | [@Darshanbm05](https://github.com/Darshanbm05) |
|
||||
| ko | 한국어 | [](https://github.com/mayswind/ezBookkeeping-i18n-badge/blob/main/untranslated/ko.json) | [@overworks](https://github.com/overworks) |
|
||||
| nl | Nederlands | [](https://github.com/mayswind/ezBookkeeping-i18n-badge/blob/main/untranslated/nl.json) | [@automagics](https://github.com/automagics) |
|
||||
| pt-BR | Português (Brasil) | [](https://github.com/mayswind/ezBookkeeping-i18n-badge/blob/main/untranslated/pt-BR.json) | [@thecodergus](https://github.com/thecodergus), [@balaios](https://github.com/balaios) |
|
||||
| ru | Русский | [](https://github.com/mayswind/ezBookkeeping-i18n-badge/blob/main/untranslated/ru.json) | [@artegoser](https://github.com/artegoser), [@dshemin](https://github.com/dshemin) |
|
||||
| sl | Slovenščina | [](https://github.com/mayswind/ezBookkeeping-i18n-badge/blob/main/untranslated/sl.json) | [@thehijacker](https://github.com/thehijacker) |
|
||||
| ta | தமிழ் | [](https://github.com/mayswind/ezBookkeeping-i18n-badge/blob/main/untranslated/ta.json) | [@hhharsha36](https://github.com/hhharsha36) |
|
||||
| th | ไทย | [](https://github.com/mayswind/ezBookkeeping-i18n-badge/blob/main/untranslated/th.json) | [@natthavat28](https://github.com/natthavat28) |
|
||||
| tr | Türkçe | [](https://github.com/mayswind/ezBookkeeping-i18n-badge/blob/main/untranslated/tr.json) | [@aydnykn](https://github.com/aydnykn) |
|
||||
| uk | Українська | [](https://github.com/mayswind/ezBookkeeping-i18n-badge/blob/main/untranslated/uk.json) | [@nktlitvinenko](https://github.com/nktlitvinenko) |
|
||||
| vi | Tiếng Việt | [](https://github.com/mayswind/ezBookkeeping-i18n-badge/blob/main/untranslated/vi.json) | [@f97](https://github.com/f97) |
|
||||
| zh-Hans | 中文 (简体) | [](https://github.com/mayswind/ezBookkeeping-i18n-badge/blob/main/untranslated/zh-Hans.json) | / |
|
||||
| zh-Hant | 中文 (繁體) | [](https://github.com/mayswind/ezBookkeeping-i18n-badge/blob/main/untranslated/zh-Hant.json) | / |
|
||||
|
||||
## Documentation
|
||||
1. [English](https://ezbookkeeping.mayswind.net)
|
||||
|
||||
@@ -160,6 +160,12 @@ build_backend() {
|
||||
fi
|
||||
fi
|
||||
|
||||
ld_static_link_flags=""
|
||||
|
||||
if [ "$(uname -s)" = "Linux" ]; then
|
||||
ld_static_link_flags="-linkmode external -extldflags '-static'"
|
||||
fi
|
||||
|
||||
backend_build_extra_arguments="-X main.Version=$VERSION"
|
||||
backend_build_extra_arguments="$backend_build_extra_arguments -X main.CommitHash=$COMMIT_HASH"
|
||||
|
||||
@@ -169,7 +175,7 @@ build_backend() {
|
||||
|
||||
echo "Building backend binary file ($RELEASE_TYPE)..."
|
||||
|
||||
CGO_ENABLED=1 go build -a -v -trimpath -ldflags "-w -s -linkmode external -extldflags '-static' $backend_build_extra_arguments" -o ezbookkeeping ezbookkeeping.go
|
||||
CGO_ENABLED=1 go build -a -v -trimpath -ldflags "-w -s $ld_static_link_flags $backend_build_extra_arguments" -o ezbookkeeping ezbookkeeping.go
|
||||
chmod +x ezbookkeeping
|
||||
}
|
||||
|
||||
|
||||
@@ -195,9 +195,25 @@ func getConfigWithoutSensitiveData(config *settings.Config) *settings.Config {
|
||||
clonedConfig.ReceiptImageRecognitionLLMConfig.OpenAICompatibleAPIKey = "****"
|
||||
}
|
||||
|
||||
if clonedConfig.ReceiptImageRecognitionLLMConfig.AnthropicCompatibleAPIKey != "" {
|
||||
clonedConfig.ReceiptImageRecognitionLLMConfig.AnthropicCompatibleAPIKey = "****"
|
||||
}
|
||||
|
||||
if clonedConfig.ReceiptImageRecognitionLLMConfig.AnthropicAPIKey != "" {
|
||||
clonedConfig.ReceiptImageRecognitionLLMConfig.AnthropicAPIKey = "****"
|
||||
}
|
||||
|
||||
if clonedConfig.ReceiptImageRecognitionLLMConfig.OpenRouterAPIKey != "" {
|
||||
clonedConfig.ReceiptImageRecognitionLLMConfig.OpenRouterAPIKey = "****"
|
||||
}
|
||||
|
||||
if clonedConfig.ReceiptImageRecognitionLLMConfig.LMStudioToken != "" {
|
||||
clonedConfig.ReceiptImageRecognitionLLMConfig.LMStudioToken = "****"
|
||||
}
|
||||
|
||||
if clonedConfig.ReceiptImageRecognitionLLMConfig.GoogleAIAPIKey != "" {
|
||||
clonedConfig.ReceiptImageRecognitionLLMConfig.GoogleAIAPIKey = "****"
|
||||
}
|
||||
}
|
||||
|
||||
if clonedConfig.OAuth2ClientSecret != "" {
|
||||
|
||||
@@ -953,6 +953,7 @@ func printUserInfo(user *models.User) {
|
||||
fmt.Printf("[Password] %s\n", user.Password)
|
||||
fmt.Printf("[Salt] %s\n", user.Salt)
|
||||
fmt.Printf("[DefaultAccountId] %d\n", user.DefaultAccountId)
|
||||
fmt.Printf("[UseLastReconciledTime] %t\n", user.UseLastReconciledTime)
|
||||
fmt.Printf("[TransactionEditScope] %s (%d)\n", user.TransactionEditScope, user.TransactionEditScope)
|
||||
fmt.Printf("[Language] %s\n", user.Language)
|
||||
fmt.Printf("[DefaultCurrency] %s\n", user.DefaultCurrency)
|
||||
|
||||
+9
-1
@@ -316,6 +316,7 @@ func startWebServer(c *core.CliContext) error {
|
||||
|
||||
apiV1Route := apiRoute.Group("/v1")
|
||||
apiV1Route.Use(bindMiddleware(middlewares.JWTAuthorization(config)))
|
||||
apiV1Route.Use(bindMiddleware(middlewares.APITokenIpLimit(config)))
|
||||
{
|
||||
// Tokens
|
||||
apiV1Route.GET("/tokens/list.json", bindApi(api.Tokens.TokenListHandler))
|
||||
@@ -374,6 +375,7 @@ func startWebServer(c *core.CliContext) error {
|
||||
apiV1Route.GET("/accounts/get.json", bindApi(api.Accounts.AccountGetHandler))
|
||||
apiV1Route.POST("/accounts/add.json", bindApi(api.Accounts.AccountCreateHandler))
|
||||
apiV1Route.POST("/accounts/modify.json", bindApi(api.Accounts.AccountModifyHandler))
|
||||
apiV1Route.POST("/accounts/update/last_reconciled_time.json", bindApi(api.Accounts.AccountUpdateLastReconciledTimeHandler))
|
||||
apiV1Route.POST("/accounts/hide.json", bindApi(api.Accounts.AccountHideHandler))
|
||||
apiV1Route.POST("/accounts/move.json", bindApi(api.Accounts.AccountMoveHandler))
|
||||
apiV1Route.POST("/accounts/delete.json", bindApi(api.Accounts.AccountDeleteHandler))
|
||||
@@ -392,11 +394,17 @@ func startWebServer(c *core.CliContext) error {
|
||||
apiV1Route.GET("/transactions/get.json", bindApi(api.Transactions.TransactionGetHandler))
|
||||
apiV1Route.POST("/transactions/add.json", bindApi(api.Transactions.TransactionCreateHandler))
|
||||
apiV1Route.POST("/transactions/modify.json", bindApi(api.Transactions.TransactionModifyHandler))
|
||||
apiV1Route.POST("/transactions/batch_update/category.json", bindApi(api.Transactions.TransactionBatchUpdateCategoriesHandler))
|
||||
apiV1Route.POST("/transactions/batch_update/account.json", bindApi(api.Transactions.TransactionBatchUpdateAccountsHandler))
|
||||
apiV1Route.POST("/transactions/batch_update/tag/add.json", bindApi(api.Transactions.TransactionBatchAddTagsHandler))
|
||||
apiV1Route.POST("/transactions/batch_update/tag/remove.json", bindApi(api.Transactions.TransactionBatchRemoveTagsHandler))
|
||||
apiV1Route.POST("/transactions/batch_update/tag/clear.json", bindApi(api.Transactions.TransactionBatchClearTagsHandler))
|
||||
apiV1Route.POST("/transactions/move/all.json", bindApi(api.Transactions.TransactionMoveAllBetweenAccountsHandler))
|
||||
apiV1Route.POST("/transactions/delete.json", bindApi(api.Transactions.TransactionDeleteHandler))
|
||||
apiV1Route.POST("/transactions/batch_delete.json", bindApi(api.Transactions.TransactionBatchDeleteHandler))
|
||||
|
||||
if config.EnableDataImport {
|
||||
apiV1Route.POST("/transactions/parse_dsv_file.json", bindApi(api.Transactions.TransactionParseImportDsvFileDataHandler))
|
||||
apiV1Route.POST("/transactions/parse_custom_file.json", bindApi(api.Transactions.TransactionParseImportCustomFileDataHandler))
|
||||
apiV1Route.POST("/transactions/parse_import.json", bindApi(api.Transactions.TransactionParseImportFileHandler))
|
||||
apiV1Route.POST("/transactions/import.json", bindApi(api.Transactions.TransactionImportHandler))
|
||||
apiV1Route.GET("/transactions/import/process.json", bindApi(api.Transactions.TransactionImportProcessHandler))
|
||||
|
||||
+38
-2
@@ -169,7 +169,7 @@ transaction_from_ai_image_recognition = false
|
||||
max_ai_recognition_picture_size = 10485760
|
||||
|
||||
[llm_image_recognition]
|
||||
# Large Language Model (LLM) provider for receipt image recognition, supports the following types: "openai", "openai_compatible", "openrouter", "ollama", "google_ai"
|
||||
# Large Language Model (LLM) provider for receipt image recognition, supports the following types: "openai", "openai_compatible", "anthropic", "anthropic_compatible", "openrouter", "ollama", "lm_studio", "google_ai"
|
||||
llm_provider =
|
||||
|
||||
# For "openai" llm provider only, OpenAI API secret key, please visit https://platform.openai.com/api-keys for more information
|
||||
@@ -187,6 +187,30 @@ openai_compatible_api_key =
|
||||
# For "openai_compatible" llm provider only, receipt image recognition model for creating transactions from images
|
||||
openai_compatible_model_id =
|
||||
|
||||
# For "anthropic" llm provider only, Anthropic API key, please visit https://platform.claude.com/settings/keys for more information
|
||||
anthropic_api_key =
|
||||
|
||||
# For "anthropic" llm provider only, receipt image recognition model for creating transactions from images
|
||||
anthropic_model_id =
|
||||
|
||||
# For "anthropic" llm provider only, maximum allowed number of generated tokens for creating transactions from images, default is 1024
|
||||
anthropic_max_tokens = 1024
|
||||
|
||||
# For "anthropic_compatible" llm provider only, Anthropic compatible API base url, e.g. "https://api.anthropic.com/v1/"
|
||||
anthropic_compatible_base_url =
|
||||
|
||||
# For "anthropic_compatible" llm provider only, Anthropic compatible API version, e.g. "2023-06-01". If the LLM service does not require API versioning, leave it blank
|
||||
anthropic_compatible_api_version =
|
||||
|
||||
# For "anthropic_compatible" llm provider only, Anthropic compatible API secret key
|
||||
anthropic_compatible_api_key =
|
||||
|
||||
# For "anthropic_compatible" llm provider only, receipt image recognition model for creating transactions from images
|
||||
anthropic_compatible_model_id =
|
||||
|
||||
# For "anthropic_compatible" llm provider only, maximum allowed number of generated tokens for creating transactions from images, default is 1024
|
||||
anthropic_compatible_max_tokens = 1024
|
||||
|
||||
# For "openrouter" llm provider only, OpenRouter API key, please visit https://openrouter.ai/settings/keys for more information
|
||||
openrouter_api_key =
|
||||
|
||||
@@ -199,6 +223,15 @@ ollama_server_url =
|
||||
# For "ollama" llm provider only, receipt image recognition model for creating transactions from images
|
||||
ollama_model_id =
|
||||
|
||||
# For "lm_studio" llm provider only, LM Studio server url, e.g. "http://127.0.0.1:1234/"
|
||||
lm_studio_server_url =
|
||||
|
||||
# For "lm_studio" llm provider only, LM Studio API token, if "require authentication" is not enabled in LM Studio, leave it blank
|
||||
lm_studio_token =
|
||||
|
||||
# For "lm_studio" llm provider only, receipt image recognition model for creating transactions from images
|
||||
lm_studio_model_id =
|
||||
|
||||
# For "google_ai" llm provider only, Google AI Studio API key, please visit https://aistudio.google.com/apikey for more information
|
||||
google_ai_api_key =
|
||||
|
||||
@@ -263,6 +296,9 @@ password_reset_token_expired_time = 3600
|
||||
# Set to true to enable API token generation
|
||||
enable_api_token = false
|
||||
|
||||
# Allowed remote IPs for using the API token, a comma-separated list of allowed remote IPs (asterisk * for any addresses, e.g. 192.168.1.* means any IPs in the 192.168.1.x subnet), leave blank to allow all remote IPs
|
||||
api_token_allowed_remote_ips =
|
||||
|
||||
# Maximum count of password / token check failures (0 - 4294967295) per IP per minute (use the above duplicate checker), default is 5, set to 0 to disable
|
||||
max_failures_per_ip_per_minute = 5
|
||||
|
||||
@@ -493,7 +529,6 @@ custom_map_tile_server_default_zoom_level = 14
|
||||
|
||||
[exchange_rates]
|
||||
# Exchange rates data source, supports the following types:
|
||||
# "reserve_bank_of_australia": https://www.rba.gov.au/statistics/frequency/exchange-rates.html
|
||||
# "bank_of_canada": https://www.bankofcanada.ca/rates/exchange/daily-exchange-rates/
|
||||
# "czech_national_bank": https://www.cnb.cz/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing/
|
||||
# "danmarks_national_bank": https://www.nationalbanken.dk/en/what-we-do/stable-prices-monetary-policy-and-the-danish-economy/exchange-rates
|
||||
@@ -501,6 +536,7 @@ custom_map_tile_server_default_zoom_level = 14
|
||||
# "national_bank_of_georgia": https://nbg.gov.ge/en/monetary-policy/currency
|
||||
# "central_bank_of_hungary": https://www.mnb.hu/en/arfolyamok
|
||||
# "bank_of_israel": https://www.boi.org.il/en/economic-roles/financial-markets/exchange-rates/
|
||||
# "national_bank_of_kazakhstan": https://nationalbank.kz/en/exchangerates/ezhednevnye-oficialnye-rynochnye-kursy-valyut
|
||||
# "central_bank_of_myanmar": https://forex.cbm.gov.mm/index.php/fxrate
|
||||
# "norges_bank": https://www.norges-bank.no/en/topics/Statistics/exchange_rates/
|
||||
# "national_bank_of_poland": https://nbp.pl/en/statistic-and-financial-reporting/rates/
|
||||
|
||||
+8
-4
@@ -9,11 +9,13 @@
|
||||
"lvdou-bing",
|
||||
"dshemin",
|
||||
"lucdsouza",
|
||||
"OuIChien"
|
||||
"OuIChien",
|
||||
"RasterCrow"
|
||||
],
|
||||
"translators": {
|
||||
"de": [
|
||||
"chrgm"
|
||||
"chrgm",
|
||||
"1270o1"
|
||||
],
|
||||
"en": [],
|
||||
"es": [
|
||||
@@ -41,10 +43,12 @@
|
||||
"automagics"
|
||||
],
|
||||
"pt-BR": [
|
||||
"thecodergus"
|
||||
"thecodergus",
|
||||
"balaios"
|
||||
],
|
||||
"ru": [
|
||||
"artegoser"
|
||||
"artegoser",
|
||||
"dshemin"
|
||||
],
|
||||
"sl": [
|
||||
"thehijacker"
|
||||
|
||||
+4
-4
@@ -10,7 +10,7 @@ import (
|
||||
"github.com/urfave/cli/v3"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/cmd"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||
)
|
||||
|
||||
@@ -26,9 +26,9 @@ var (
|
||||
)
|
||||
|
||||
func main() {
|
||||
settings.Version = Version
|
||||
settings.CommitHash = CommitHash
|
||||
settings.BuildTime = BuildUnixTime
|
||||
core.Version = Version
|
||||
core.CommitHash = CommitHash
|
||||
core.BuildTime = BuildUnixTime
|
||||
|
||||
cmd := &cli.Command{
|
||||
Name: "ezBookkeeping",
|
||||
|
||||
@@ -1,34 +1,34 @@
|
||||
module github.com/mayswind/ezbookkeeping
|
||||
|
||||
go 1.25
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
github.com/boombuler/barcode v1.1.0
|
||||
github.com/coreos/go-oidc/v3 v3.17.0
|
||||
github.com/coreos/go-oidc/v3 v3.18.0
|
||||
github.com/extrame/xls v0.0.2-0.20200426124601-4a6cf263071b
|
||||
github.com/gin-contrib/cache v1.4.1
|
||||
github.com/gin-contrib/gzip v1.2.5
|
||||
github.com/gin-gonic/gin v1.11.0
|
||||
github.com/go-co-op/gocron/v2 v2.18.2
|
||||
github.com/go-playground/validator/v10 v10.28.0
|
||||
github.com/gin-contrib/cache v1.4.4
|
||||
github.com/gin-contrib/gzip v1.2.6
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/go-co-op/gocron/v2 v2.21.1
|
||||
github.com/go-playground/validator/v10 v10.30.2
|
||||
github.com/go-sql-driver/mysql v1.9.3
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/invopop/jsonschema v0.13.0
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/mattn/go-sqlite3 v1.14.32
|
||||
github.com/minio/minio-go/v7 v7.0.97
|
||||
github.com/lib/pq v1.12.3
|
||||
github.com/mattn/go-sqlite3 v1.14.42
|
||||
github.com/minio/minio-go/v7 v7.0.100
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible
|
||||
github.com/pquerna/otp v1.5.0
|
||||
github.com/sirupsen/logrus v1.9.3
|
||||
github.com/sirupsen/logrus v1.9.4
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/urfave/cli/v3 v3.6.1
|
||||
github.com/urfave/cli/v3 v3.8.0
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8
|
||||
github.com/xuri/excelize/v2 v2.10.0
|
||||
golang.org/x/crypto v0.46.0
|
||||
golang.org/x/net v0.48.0
|
||||
golang.org/x/oauth2 v0.34.0
|
||||
golang.org/x/text v0.32.0
|
||||
gopkg.in/ini.v1 v1.67.0
|
||||
github.com/xuri/excelize/v2 v2.10.1
|
||||
golang.org/x/crypto v0.50.0
|
||||
golang.org/x/net v0.53.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
golang.org/x/text v0.36.0
|
||||
gopkg.in/ini.v1 v1.67.1
|
||||
gopkg.in/mail.v2 v2.3.1
|
||||
xorm.io/builder v0.3.13
|
||||
xorm.io/xorm v1.3.11
|
||||
@@ -40,8 +40,8 @@ require (
|
||||
github.com/bradfitz/gomemcache v0.0.0-20250403215159-8d39553ac7cf // indirect
|
||||
github.com/buger/jsonparser v1.1.1 // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.14.1 // indirect
|
||||
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect
|
||||
github.com/chenzhuoyu/iasm v0.9.1 // indirect
|
||||
@@ -52,27 +52,27 @@ require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/extrame/goyymmdd v0.0.0-20210114090516-7cc815f00d1a // indirect
|
||||
github.com/extrame/ole2 v0.0.0-20160812065207-d69429661ad7 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.10 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-ini/ini v1.67.0 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/golang/snappy v0.0.4 // indirect
|
||||
github.com/gomodule/redigo v1.9.2 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/jonboulle/clockwork v0.5.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/klauspost/compress v1.18.5 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/klauspost/crc32 v1.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/memcachier/mc/v3 v3.0.3 // indirect
|
||||
github.com/minio/crc64nvme v1.1.0 // indirect
|
||||
github.com/minio/crc64nvme v1.1.1 // indirect
|
||||
github.com/minio/md5-simd v1.1.2 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
@@ -80,29 +80,31 @@ require (
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/philhofer/fwd v1.2.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/quic-go/qpack v0.5.1 // indirect
|
||||
github.com/quic-go/quic-go v0.55.0 // indirect
|
||||
github.com/richardlehane/mscfb v1.0.4 // indirect
|
||||
github.com/richardlehane/msoleps v1.0.4 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||
github.com/richardlehane/mscfb v1.0.6 // indirect
|
||||
github.com/richardlehane/msoleps v1.0.6 // indirect
|
||||
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||
github.com/robfig/go-cache v0.0.0-20130306151617-9fc39e0dbf62 // indirect
|
||||
github.com/rs/xid v1.6.0 // indirect
|
||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
github.com/syndtr/goleveldb v1.0.0 // indirect
|
||||
github.com/tealeg/xlsx v1.0.5 // indirect
|
||||
github.com/tiendc/go-deepcopy v1.7.1 // indirect
|
||||
github.com/tinylib/msgp v1.3.0 // indirect
|
||||
github.com/tiendc/go-deepcopy v1.7.2 // indirect
|
||||
github.com/tinylib/msgp v1.6.1 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
|
||||
github.com/xuri/efp v0.0.1 // indirect
|
||||
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/arch v0.22.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 // indirect
|
||||
golang.org/x/mod v0.30.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.39.0 // indirect
|
||||
golang.org/x/tools v0.39.0 // indirect
|
||||
golang.org/x/mod v0.34.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/tools v0.43.0 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
|
||||
@@ -14,10 +14,10 @@ github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
|
||||
github.com/bytedance/sonic v1.10.0-rc/go.mod h1:ElCzW+ufi8qKqNW0FY314xriJhyJhuoJ3gFZdAHF7NM=
|
||||
github.com/bytedance/sonic v1.14.1 h1:FBMC0zVz5XUmE4z9wF4Jey0An5FueFvOsTKKKtwIl7w=
|
||||
github.com/bytedance/sonic v1.14.1/go.mod h1:gi6uhQLMbTdeP0muCnrjHLeCUPyb70ujhnNlhOylAFc=
|
||||
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
|
||||
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
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/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
|
||||
@@ -28,8 +28,8 @@ github.com/chenzhuoyu/iasm v0.9.1/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLI
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
|
||||
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
|
||||
github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A=
|
||||
github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
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=
|
||||
@@ -43,36 +43,36 @@ github.com/extrame/ole2 v0.0.0-20160812065207-d69429661ad7/go.mod h1:GPpMrAfHdb8
|
||||
github.com/extrame/xls v0.0.2-0.20200426124601-4a6cf263071b h1:jqW/h4gcXYEB6kVf6iuxjU9ONWA0ugUB94TP9UNmgdg=
|
||||
github.com/extrame/xls v0.0.2-0.20200426124601-4a6cf263071b/go.mod h1:iACcgahst7BboCpIMSpnFs4SKyU9ZjsvZBfNbUxZOJI=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/cache v1.4.1 h1:HcLwLfw7p+FasNp5VAnFbbBj9SzB4bDtswvon7wYSg4=
|
||||
github.com/gin-contrib/cache v1.4.1/go.mod h1:tykDV+FgItJHYEO0eCasuRYsZKPPyb4BYhAjuTlG6RM=
|
||||
github.com/gin-contrib/gzip v1.2.5 h1:fIZs0S+l17pIu1P5XRJOo/YNqfIuPCrZZ3TWB7pjckI=
|
||||
github.com/gin-contrib/gzip v1.2.5/go.mod h1:aomRgR7ftdZV3uWY0gW/m8rChfxau0n8YVvwlOHONzw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/cache v1.4.4 h1:4Sasrroa8CrbRYQ3aEMutRJGhz7ujyPlKvAPmJdIx9U=
|
||||
github.com/gin-contrib/cache v1.4.4/go.mod h1:OfwzOu0CcBcQYgvc+wg7moQWFzmJCKqmo0NU7Wx3xyQ=
|
||||
github.com/gin-contrib/gzip v1.2.6 h1:OtN8DplD5DNZCSLAnQ5HxRkD2qZ5VU+JhOrcfJrcRvg=
|
||||
github.com/gin-contrib/gzip v1.2.6/go.mod h1:BQy8/+JApnRjAVUplSGZiVtD2k8GmIE2e9rYu/hLzzU=
|
||||
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.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
||||
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
||||
github.com/go-co-op/gocron/v2 v2.18.2 h1:+5VU41FUXPWSPKLXZQ/77SGzUiPCcakU0v7ENc2H20Q=
|
||||
github.com/go-co-op/gocron/v2 v2.18.2/go.mod h1:Zii6he+Zfgy5W9B+JKk/KwejFOW0kZTFvHtwIpR4aBI=
|
||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||
github.com/go-co-op/gocron/v2 v2.21.1 h1:QYOK6iOQVCut+jDcs4zRdWRTBHRxRCEeeFi1TnAmgbU=
|
||||
github.com/go-co-op/gocron/v2 v2.21.1/go.mod h1:5lEiCKk1oVJV39Zg7/YG10OnaVrDAV5GGR6O0663k6U=
|
||||
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
|
||||
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
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.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
|
||||
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
|
||||
github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ=
|
||||
github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc=
|
||||
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
|
||||
github.com/go-sql-driver/mysql v1.9.3/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/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
|
||||
@@ -90,8 +90,8 @@ github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7X
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
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.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
||||
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
@@ -104,22 +104,22 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
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/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
|
||||
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
|
||||
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
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-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
|
||||
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.42 h1:MigqEP4ZmHw3aIdIT7T+9TLa90Z6smwcthx+Azv4Cgo=
|
||||
github.com/mattn/go-sqlite3 v1.14.42/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
|
||||
github.com/memcachier/mc/v3 v3.0.3 h1:qii+lDiPKi36O4Xg+HVKwHu6Oq+Gt17b+uEiA0Drwv4=
|
||||
github.com/memcachier/mc/v3 v3.0.3/go.mod h1:GzjocBahcXPxt2cmqzknrgqCOmMxiSzhVKPOe90Tpug=
|
||||
github.com/minio/crc64nvme v1.1.0 h1:e/tAguZ+4cw32D+IO/8GSf5UVr9y+3eJcxZI2WOO/7Q=
|
||||
github.com/minio/crc64nvme v1.1.0/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
|
||||
github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI=
|
||||
github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
|
||||
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
|
||||
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
|
||||
github.com/minio/minio-go/v7 v7.0.97 h1:lqhREPyfgHTB/ciX8k2r8k0D93WaFqxbJX36UZq5occ=
|
||||
github.com/minio/minio-go/v7 v7.0.97/go.mod h1:re5VXuo0pwEtoNLsNuSr0RrLfT/MBtohwdaSmPPSRSk=
|
||||
github.com/minio/minio-go/v7 v7.0.100 h1:ShkWi8Tyj9RtU57OQB2HIXKz4bFgtVib0bbT1sbtLI8=
|
||||
github.com/minio/minio-go/v7 v7.0.100/go.mod h1:EtGNKtlX20iL2yaYnxEigaIvj0G0GwSDnifnG8ClIdw=
|
||||
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=
|
||||
@@ -139,15 +139,14 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
|
||||
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
||||
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
||||
github.com/quic-go/quic-go v0.55.0 h1:zccPQIqYCXDt5NmcEabyYvOnomjs8Tlwl7tISjJh9Mk=
|
||||
github.com/quic-go/quic-go v0.55.0/go.mod h1:DR51ilwU1uE164KuWXhinFcKWGlEjzys2l8zUl5Ss1U=
|
||||
github.com/richardlehane/mscfb v1.0.4 h1:WULscsljNPConisD5hR0+OyZjwK46Pfyr6mPu5ZawpM=
|
||||
github.com/richardlehane/mscfb v1.0.4/go.mod h1:YzVpcZg9czvAuhk9T+a3avCpcFPMUWm7gK3DypaEsUk=
|
||||
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/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/richardlehane/mscfb v1.0.6 h1:eN3bvvZCp00bs7Zf52bxNwAx5lJDBK1tCuH19qq5aC8=
|
||||
github.com/richardlehane/mscfb v1.0.6/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo=
|
||||
github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg=
|
||||
github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
|
||||
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/robfig/go-cache v0.0.0-20130306151617-9fc39e0dbf62 h1:pyecQtsPmlkCsMkYhT5iZ+sUXuwee+OvfuJjinEA3ko=
|
||||
@@ -155,67 +154,70 @@ github.com/robfig/go-cache v0.0.0-20130306151617-9fc39e0dbf62/go.mod h1:65XQgovT
|
||||
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
||||
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
|
||||
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.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
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/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE=
|
||||
github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ=
|
||||
github.com/tealeg/xlsx v1.0.5 h1:+f8oFmvY8Gw1iUXzPk+kz+4GpbDZPK1FhPiQRd+ypgE=
|
||||
github.com/tealeg/xlsx v1.0.5/go.mod h1:btRS8dz54TDnvKNosuAqxrM1QgN1udgk9O34bDCnORM=
|
||||
github.com/tiendc/go-deepcopy v1.7.1 h1:LnubftI6nYaaMOcaz0LphzwraqN8jiWTwm416sitff4=
|
||||
github.com/tiendc/go-deepcopy v1.7.1/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=
|
||||
github.com/tinylib/msgp v1.3.0 h1:ULuf7GPooDaIlbyvgAxBV/FI7ynli6LZ1/nVUNu+0ww=
|
||||
github.com/tinylib/msgp v1.3.0/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0=
|
||||
github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44=
|
||||
github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=
|
||||
github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY=
|
||||
github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||
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.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
||||
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/urfave/cli/v3 v3.6.1 h1:j8Qq8NyUawj/7rTYdBGrxcH7A/j7/G8Q5LhWEW4G3Mo=
|
||||
github.com/urfave/cli/v3 v3.6.1/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso=
|
||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/urfave/cli/v3 v3.8.0 h1:XqKPrm0q4P0q5JpoclYoCAv0/MIvH/jZ2umzuf8pNTI=
|
||||
github.com/urfave/cli/v3 v3.8.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso=
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
|
||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
|
||||
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.10.0 h1:8aKsP7JD39iKLc6dH5Tw3dgV3sPRh8uRVXu/fMstfW4=
|
||||
github.com/xuri/excelize/v2 v2.10.0/go.mod h1:SC5TzhQkaOsTWpANfm+7bJCldzcnU/jrhqkTi/iBHBU=
|
||||
github.com/xuri/excelize/v2 v2.10.1 h1:V62UlqopMqha3kOpnlHy2CcRVw1V8E63jFoWUmMzxN0=
|
||||
github.com/xuri/excelize/v2 v2.10.1/go.mod h1:iG5tARpgaEeIhTqt3/fgXCGoBRt4hNXgCp3tfXKoOIc=
|
||||
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=
|
||||
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
||||
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI=
|
||||
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
|
||||
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
|
||||
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
||||
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
|
||||
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
|
||||
@@ -223,8 +225,8 @@ gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
||||
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gopkg.in/ini.v1 v1.67.1 h1:tVBILHy0R6e4wkYOn3XmiITt/hEVH4TFMYvAX2Ytz6k=
|
||||
gopkg.in/ini.v1 v1.67.1/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss=
|
||||
gopkg.in/mail.v2 v2.3.1 h1:WYFn/oANrAGP2C0dcV6/pbkPzv8yGzqTjPmTeO7qoXk=
|
||||
gopkg.in/mail.v2 v2.3.1/go.mod h1:htwXN1Qh09vZJ1NVKxQqHPBaCBbzKhp5GzuJEA4VJWw=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import { type JestConfigWithTsJest, createDefaultEsmPreset } from 'ts-jest';
|
||||
|
||||
const presetConfig = createDefaultEsmPreset({
|
||||
tsconfig: '<rootDir>/tsconfig.jest.json'
|
||||
});
|
||||
|
||||
const config: JestConfigWithTsJest = {
|
||||
...presetConfig,
|
||||
clearMocks: true,
|
||||
collectCoverage: false,
|
||||
moduleNameMapper: {
|
||||
"^@/(.*)$": "<rootDir>/src/$1"
|
||||
},
|
||||
testEnvironment: "node",
|
||||
testMatch: [
|
||||
"**/__tests__/**/*.[jt]s?(x)",
|
||||
"!**/__tests__/*_gen.[jt]s?(x)"
|
||||
]
|
||||
};
|
||||
|
||||
export default config;
|
||||
Generated
+2959
-4638
File diff suppressed because it is too large
Load Diff
+56
-58
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ezbookkeeping",
|
||||
"version": "1.3.2",
|
||||
"version": "1.5.0",
|
||||
"private": true,
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -16,67 +16,65 @@
|
||||
"build": "cross-env NODE_ENV=production vite build",
|
||||
"serve:dist": "vite preview",
|
||||
"lint": "vue-tsc --noEmit && eslint . --fix",
|
||||
"test": "cross-env TS_NODE_PROJECT=\"./tsconfig.jest.json\" jest"
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mdi/js": "^7.4.47",
|
||||
"@vuepic/vue-datepicker": "^12.1.0",
|
||||
"axios": "^1.13.2",
|
||||
"cbor-js": "^0.1.0",
|
||||
"chardet": "^2.1.1",
|
||||
"clipboard": "^2.0.11",
|
||||
"crypto-js": "^4.2.0",
|
||||
"dom7": "^4.0.6",
|
||||
"echarts": "^6.0.0",
|
||||
"framework7": "^9.0.2",
|
||||
"framework7-icons": "^5.0.5",
|
||||
"framework7-vue": "^9.0.2",
|
||||
"jalaali-js": "^1.2.8",
|
||||
"leaflet": "^1.9.4",
|
||||
"line-awesome": "^1.3.0",
|
||||
"moment": "^2.30.1",
|
||||
"moment-timezone": "^0.6.0",
|
||||
"pinia": "^3.0.4",
|
||||
"register-service-worker": "^1.7.2",
|
||||
"skeleton-elements": "^4.0.1",
|
||||
"swiper": "^12.0.3",
|
||||
"ua-parser-js": "^1.0.39",
|
||||
"vue": "^3.5.25",
|
||||
"vue-echarts": "^8.0.1",
|
||||
"vue-i18n": "^11.2.2",
|
||||
"vue-router": "^4.6.4",
|
||||
"vue3-perfect-scrollbar": "^2.0.0",
|
||||
"vuedraggable": "^4.1.0",
|
||||
"vuetify": "^3.11.3"
|
||||
"@mdi/js": "7.4.47",
|
||||
"@vuepic/vue-datepicker": "12.1.0",
|
||||
"axios": "1.15.2",
|
||||
"cbor-js": "0.1.0",
|
||||
"chardet": "2.1.1",
|
||||
"clipboard": "2.0.11",
|
||||
"crypto-js": "4.2.0",
|
||||
"dom7": "4.0.6",
|
||||
"echarts": "6.0.0",
|
||||
"framework7": "9.0.3",
|
||||
"framework7-icons": "5.0.5",
|
||||
"framework7-vue": "9.0.3",
|
||||
"jalaali-js": "1.2.8",
|
||||
"leaflet": "1.9.4",
|
||||
"line-awesome": "1.3.0",
|
||||
"moment": "2.30.1",
|
||||
"moment-timezone": "0.6.1",
|
||||
"pinia": "3.0.4",
|
||||
"register-service-worker": "1.7.2",
|
||||
"skeleton-elements": "4.0.1",
|
||||
"swiper": "12.1.3",
|
||||
"ua-parser-js": "1.0.39",
|
||||
"vue": "3.5.33",
|
||||
"vue-echarts": "8.0.1",
|
||||
"vue-i18n": "11.3.2",
|
||||
"vue-router": "5.0.6",
|
||||
"vue3-perfect-scrollbar": "2.0.0",
|
||||
"vuedraggable": "4.1.0",
|
||||
"vuetify": "3.12.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@jest/globals": "^30.2.0",
|
||||
"@tsconfig/node24": "^24.0.3",
|
||||
"@types/cbor-js": "^0.1.1",
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"@types/git-rev-sync": "^2.0.2",
|
||||
"@types/jalaali-js": "^1.2.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^24.1.0",
|
||||
"@types/ua-parser-js": "^0.7.39",
|
||||
"@vitejs/plugin-vue": "^6.0.3",
|
||||
"@vue/eslint-config-typescript": "^14.6.0",
|
||||
"@vue/tsconfig": "^0.8.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-vue": "^10.6.2",
|
||||
"git-rev-sync": "^3.0.2",
|
||||
"jest": "^30.2.0",
|
||||
"postcss-preset-env": "^10.5.0",
|
||||
"sass": "^1.96.0",
|
||||
"ts-jest": "^29.4.6",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.2.7",
|
||||
"vite-plugin-checker": "^0.12.0",
|
||||
"vite-plugin-pwa": "^1.2.0",
|
||||
"vite-plugin-vuetify": "^2.1.2",
|
||||
"vue-tsc": "^3.1.8"
|
||||
"@jest/globals": "30.3.0",
|
||||
"@tsconfig/node24": "24.0.4",
|
||||
"@types/cbor-js": "0.1.1",
|
||||
"@types/crypto-js": "4.2.2",
|
||||
"@types/git-rev-sync": "2.0.2",
|
||||
"@types/jalaali-js": "1.2.0",
|
||||
"@types/node": "25.6.0",
|
||||
"@types/ua-parser-js": "0.7.39",
|
||||
"@vitejs/plugin-vue": "6.0.6",
|
||||
"@vue/eslint-config-typescript": "14.7.0",
|
||||
"@vue/tsconfig": "0.9.1",
|
||||
"cross-env": "10.1.0",
|
||||
"eslint": "10.2.1",
|
||||
"eslint-plugin-vue": "10.9.0",
|
||||
"git-rev-sync": "3.0.2",
|
||||
"postcss-preset-env": "11.2.1",
|
||||
"sass": "1.99.0",
|
||||
"ts-node": "10.9.2",
|
||||
"typescript": "6.0.3",
|
||||
"vite": "7.3.2",
|
||||
"vite-plugin-checker": "0.13.0",
|
||||
"vite-plugin-pwa": "1.2.0",
|
||||
"vite-plugin-vuetify": "2.1.3",
|
||||
"vitest": "4.1.5",
|
||||
"vue-tsc": "3.2.7"
|
||||
},
|
||||
"browserslist": [
|
||||
"last 5 Chrome versions",
|
||||
|
||||
+104
-13
@@ -19,6 +19,7 @@ type AccountsApi struct {
|
||||
ApiUsingConfig
|
||||
ApiUsingDuplicateChecker
|
||||
accounts *services.AccountService
|
||||
users *services.UserService
|
||||
}
|
||||
|
||||
// Initialize an account api singleton instance
|
||||
@@ -34,6 +35,7 @@ var (
|
||||
container: duplicatechecker.Container,
|
||||
},
|
||||
accounts: services.Accounts,
|
||||
users: services.Users,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -333,6 +335,16 @@ func (a *AccountsApi) AccountModifyHandler(c *core.WebContext) (any, *errs.Error
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
user, err := a.users.GetUserById(c, uid)
|
||||
|
||||
if err != nil {
|
||||
if !errs.IsCustomError(err) {
|
||||
log.Errorf(c, "[accounts.AccountModifyHandler] failed to get user, because %s", err.Error())
|
||||
}
|
||||
|
||||
return nil, errs.ErrUserNotFound
|
||||
}
|
||||
|
||||
accountAndSubAccounts, err := a.accounts.GetAccountAndSubAccountsByAccountId(c, uid, accountModifyReq.Id)
|
||||
|
||||
if err != nil {
|
||||
@@ -434,7 +446,11 @@ func (a *AccountsApi) AccountModifyHandler(c *core.WebContext) (any, *errs.Error
|
||||
var toAddAccountBalanceTimes []int64
|
||||
var toDeleteAccountIds []int64
|
||||
|
||||
toUpdateAccount := a.getToUpdateAccount(uid, &accountModifyReq, mainAccount, false)
|
||||
toUpdateAccount, err := a.getToUpdateAccount(user, &accountModifyReq, mainAccount, false)
|
||||
|
||||
if err != nil {
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
if toUpdateAccount != nil {
|
||||
if toUpdateAccount.Category != mainAccount.Category {
|
||||
@@ -483,7 +499,11 @@ func (a *AccountsApi) AccountModifyHandler(c *core.WebContext) (any, *errs.Error
|
||||
toAddAccountBalanceTimes = append(toAddAccountBalanceTimes, 0)
|
||||
}
|
||||
} else {
|
||||
toUpdateSubAccount := a.getToUpdateAccount(uid, subAccountReq, accountMap[subAccountReq.Id], true)
|
||||
toUpdateSubAccount, err := a.getToUpdateAccount(user, subAccountReq, accountMap[subAccountReq.Id], true)
|
||||
|
||||
if err != nil {
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
if toUpdateSubAccount != nil {
|
||||
anythingUpdate = true
|
||||
@@ -607,6 +627,69 @@ func (a *AccountsApi) AccountModifyHandler(c *core.WebContext) (any, *errs.Error
|
||||
return accountResp, nil
|
||||
}
|
||||
|
||||
// AccountUpdateLastReconciledTimeHandler updates last reconciled time of an existed account by request parameters for current user
|
||||
func (a *AccountsApi) AccountUpdateLastReconciledTimeHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
var accountUpdateReq models.AccountUpdateLastReconciledTimeRequest
|
||||
err := c.ShouldBindJSON(&accountUpdateReq)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[accounts.AccountUpdateLastReconciledTimeHandler] parse request failed, because %s", err.Error())
|
||||
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||
}
|
||||
|
||||
if accountUpdateReq.Id <= 0 {
|
||||
return nil, errs.ErrAccountIdInvalid
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
user, err := a.users.GetUserById(c, uid)
|
||||
|
||||
if err != nil {
|
||||
if !errs.IsCustomError(err) {
|
||||
log.Errorf(c, "[accounts.AccountUpdateLastReconciledTimeHandler] failed to get user, because %s", err.Error())
|
||||
}
|
||||
|
||||
return nil, errs.ErrUserNotFound
|
||||
}
|
||||
|
||||
if !user.UseLastReconciledTime {
|
||||
return nil, errs.ErrLastReconciledTimeIsNotEnabled
|
||||
}
|
||||
|
||||
account, err := a.accounts.GetAccountByAccountId(c, uid, accountUpdateReq.Id)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[accounts.AccountUpdateLastReconciledTimeHandler] failed to get account \"id:%d\" for user \"uid:%d\", because %s", accountUpdateReq.Id, uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
if account.Type == models.ACCOUNT_TYPE_MULTI_SUB_ACCOUNTS {
|
||||
return nil, errs.ErrParentAccountCannotSetLastReconciledTime
|
||||
}
|
||||
|
||||
if account.Extend == nil {
|
||||
account.Extend = &models.AccountExtend{}
|
||||
}
|
||||
|
||||
if account.Extend.LastReconciledTime != nil && accountUpdateReq.LastReconciledTime < *account.Extend.LastReconciledTime {
|
||||
return nil, errs.ErrCannotSetLastReconciledTimeBeforeCurrent
|
||||
} else if account.Extend.LastReconciledTime != nil && accountUpdateReq.LastReconciledTime == *account.Extend.LastReconciledTime {
|
||||
return nil, errs.ErrNothingWillBeUpdated
|
||||
}
|
||||
|
||||
account.Extend.LastReconciledTime = &accountUpdateReq.LastReconciledTime
|
||||
|
||||
err = a.accounts.UpdateAccountExtend(c, uid, account)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[accounts.AccountUpdateLastReconciledTimeHandler] failed to update last reconciled time for account \"id:%d\" of user \"uid:%d\", because %s", account.AccountId, uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
log.Infof(c, "[accounts.AccountUpdateLastReconciledTimeHandler] user \"uid:%d\" has updated last reconciled time \"%d\" for account \"id:%d\"", uid, account.Extend.LastReconciledTime, account.AccountId)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// AccountHideHandler hides an existed account by request parameters for current user
|
||||
func (a *AccountsApi) AccountHideHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
var accountHideReq models.AccountHideRequest
|
||||
@@ -764,8 +847,9 @@ func (a *AccountsApi) createSubAccountModels(uid int64, accountCreateReq *models
|
||||
return childrenAccounts, childrenAccountBalanceTimes
|
||||
}
|
||||
|
||||
func (a *AccountsApi) getToUpdateAccount(uid int64, accountModifyReq *models.AccountModifyRequest, oldAccount *models.Account, isSubAccount bool) *models.Account {
|
||||
func (a *AccountsApi) getToUpdateAccount(user *models.User, accountModifyReq *models.AccountModifyRequest, oldAccount *models.Account, isSubAccount bool) (*models.Account, error) {
|
||||
newAccountExtend := &models.AccountExtend{}
|
||||
newAccountExtend.LastReconciledTime = accountModifyReq.LastReconciledTime
|
||||
|
||||
if !isSubAccount && accountModifyReq.Category == models.ACCOUNT_CATEGORY_CREDIT_CARD {
|
||||
newAccountExtend.CreditCardStatementDate = &accountModifyReq.CreditCardStatementDate
|
||||
@@ -773,7 +857,7 @@ func (a *AccountsApi) getToUpdateAccount(uid int64, accountModifyReq *models.Acc
|
||||
|
||||
newAccount := &models.Account{
|
||||
AccountId: oldAccount.AccountId,
|
||||
Uid: uid,
|
||||
Uid: user.Uid,
|
||||
Name: accountModifyReq.Name,
|
||||
DisplayOrder: oldAccount.DisplayOrder,
|
||||
Category: accountModifyReq.Category,
|
||||
@@ -790,21 +874,28 @@ func (a *AccountsApi) getToUpdateAccount(uid int64, accountModifyReq *models.Acc
|
||||
newAccount.Color != oldAccount.Color ||
|
||||
newAccount.Comment != oldAccount.Comment ||
|
||||
newAccount.Hidden != oldAccount.Hidden {
|
||||
return newAccount
|
||||
}
|
||||
|
||||
if (newAccount.Extend != nil && oldAccount.Extend == nil) ||
|
||||
(newAccount.Extend == nil && oldAccount.Extend != nil) {
|
||||
return newAccount
|
||||
return newAccount, nil
|
||||
}
|
||||
|
||||
oldAccountExtend := oldAccount.Extend
|
||||
|
||||
if newAccountExtend.CreditCardStatementDate != oldAccountExtend.CreditCardStatementDate {
|
||||
return newAccount
|
||||
if (newAccountExtend.LastReconciledTime != nil && (oldAccountExtend == nil || oldAccountExtend.LastReconciledTime == nil)) ||
|
||||
(newAccountExtend.LastReconciledTime == nil && oldAccountExtend != nil && oldAccountExtend.LastReconciledTime != nil) ||
|
||||
(newAccountExtend.LastReconciledTime != nil && oldAccountExtend != nil && oldAccountExtend.LastReconciledTime != nil && *newAccountExtend.LastReconciledTime != *oldAccountExtend.LastReconciledTime) {
|
||||
if !user.UseLastReconciledTime {
|
||||
return nil, errs.ErrLastReconciledTimeIsNotEnabled
|
||||
}
|
||||
|
||||
return newAccount, nil
|
||||
}
|
||||
|
||||
return nil
|
||||
if (newAccountExtend.CreditCardStatementDate != nil && (oldAccountExtend == nil || oldAccountExtend.CreditCardStatementDate == nil)) ||
|
||||
(newAccountExtend.CreditCardStatementDate == nil && oldAccountExtend != nil && oldAccountExtend.CreditCardStatementDate != nil) ||
|
||||
(newAccountExtend.CreditCardStatementDate != nil && oldAccountExtend != nil && oldAccountExtend.CreditCardStatementDate != nil && *newAccountExtend.CreditCardStatementDate != *oldAccountExtend.CreditCardStatementDate) {
|
||||
return newAccount, nil
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (a *AccountsApi) getToDeleteSubAccountIds(accountModifyReq *models.AccountModifyRequest, mainAccount *models.Account, accountAndSubAccounts []*models.Account) []int64 {
|
||||
|
||||
@@ -419,7 +419,7 @@ func (a *DataManagementsApi) getExportedFileContent(c *core.WebContext, fileType
|
||||
minTransactionTime = utils.GetMinTransactionTimeFromUnixTime(exportTransactionDataReq.MinTime)
|
||||
}
|
||||
|
||||
allTransactions, err := a.transactions.GetAllSpecifiedTransactions(c, uid, maxTransactionTime, minTransactionTime, exportTransactionDataReq.Type, allCategoryIds, allAccountIds, tagFilters, noTags, exportTransactionDataReq.AmountFilter, exportTransactionDataReq.Keyword, pageCountForDataExport, true)
|
||||
allTransactions, err := a.transactions.GetAllSpecifiedTransactions(c, uid, maxTransactionTime, minTransactionTime, exportTransactionDataReq.Type, allCategoryIds, allAccountIds, tagFilters, noTags, exportTransactionDataReq.AmountFilter, exportTransactionDataReq.Keyword, false, pageCountForDataExport, true)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[data_managements.getExportedFileContent] failed to all transactions user \"uid:%d\", because %s", uid, err.Error())
|
||||
|
||||
@@ -166,7 +166,7 @@ func (a *ForgetPasswordsApi) UserResetPasswordHandler(c *core.WebContext) (any,
|
||||
Password: request.Password,
|
||||
}
|
||||
|
||||
_, _, err = a.users.UpdateUser(c, userNew, false)
|
||||
_, _, err = a.users.UpdateUser(c, userNew, false, false)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[forget_passwords.UserResetPasswordHandler] failed to update user \"uid:%d\", because %s", user.Uid, err.Error())
|
||||
|
||||
+2
-3
@@ -3,7 +3,6 @@ package api
|
||||
import (
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||
)
|
||||
|
||||
// HealthsApi represents health api
|
||||
@@ -18,8 +17,8 @@ var (
|
||||
func (a *HealthsApi) HealthStatusHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
result := make(map[string]string)
|
||||
|
||||
result["version"] = settings.Version
|
||||
result["commit"] = settings.CommitHash
|
||||
result["version"] = core.Version
|
||||
result["commit"] = core.CommitHash
|
||||
result["status"] = "ok"
|
||||
|
||||
return result, nil
|
||||
|
||||
@@ -103,7 +103,7 @@ func (a *ModelContextProtocolAPI) InitializeHandler(c *core.WebContext, jsonRPCR
|
||||
ServerInfo: &mcp.MCPImplementation{
|
||||
Name: mcpServerName,
|
||||
Title: core.ApplicationName,
|
||||
Version: settings.Version,
|
||||
Version: core.Version,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
+4
-5
@@ -3,7 +3,6 @@ package api
|
||||
import (
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||
)
|
||||
|
||||
// SystemsApi represents system api
|
||||
@@ -18,11 +17,11 @@ var (
|
||||
func (a *SystemsApi) VersionHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
result := make(map[string]string)
|
||||
|
||||
result["version"] = settings.Version
|
||||
result["commitHash"] = settings.CommitHash
|
||||
result["version"] = core.Version
|
||||
result["commitHash"] = core.CommitHash
|
||||
|
||||
if settings.BuildTime != "" {
|
||||
result["buildTime"] = settings.BuildTime
|
||||
if core.BuildTime != "" {
|
||||
result["buildTime"] = core.BuildTime
|
||||
}
|
||||
|
||||
return result, nil
|
||||
|
||||
+4
-4
@@ -69,10 +69,10 @@ func (a *TokensApi) TokenListHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
tokenResp.IsCurrent = true
|
||||
}
|
||||
|
||||
if token.TokenType == core.USER_TOKEN_TYPE_API && token.UserAgent != services.TokenUserAgentCreatedViaCli {
|
||||
tokenResp.UserAgent = services.TokenUserAgentForAPI
|
||||
} else if token.TokenType == core.USER_TOKEN_TYPE_MCP && token.UserAgent != services.TokenUserAgentCreatedViaCli {
|
||||
tokenResp.UserAgent = services.TokenUserAgentForMCP
|
||||
if token.TokenType == core.USER_TOKEN_TYPE_API && token.UserAgent != core.TokenUserAgentCreatedViaCli {
|
||||
tokenResp.UserAgent = core.TokenUserAgentForAPI
|
||||
} else if token.TokenType == core.USER_TOKEN_TYPE_MCP && token.UserAgent != core.TokenUserAgentCreatedViaCli {
|
||||
tokenResp.UserAgent = core.TokenUserAgentForMCP
|
||||
}
|
||||
|
||||
tokenResps[i] = tokenResp
|
||||
|
||||
@@ -12,13 +12,15 @@ import (
|
||||
|
||||
// TransactionTagsApi represents transaction tag api
|
||||
type TransactionTagsApi struct {
|
||||
tags *services.TransactionTagService
|
||||
tags *services.TransactionTagService
|
||||
tagGroups *services.TransactionTagGroupService
|
||||
}
|
||||
|
||||
// Initialize a transaction tag api singleton instance
|
||||
var (
|
||||
TransactionTags = &TransactionTagsApi{
|
||||
tags: services.TransactionTags,
|
||||
tags: services.TransactionTags,
|
||||
tagGroups: services.TransactionTagGroups,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -78,6 +80,20 @@ func (a *TransactionTagsApi) TagCreateHandler(c *core.WebContext) (any, *errs.Er
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
|
||||
if tagCreateReq.GroupId > 0 {
|
||||
tagGroup, err := a.tagGroups.GetTagGroupByTagGroupId(c, uid, tagCreateReq.GroupId)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transaction_tags.TagCreateHandler] failed to get tag group \"id:%d\" for user \"uid:%d\", because %s", tagCreateReq.GroupId, uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
if tagGroup == nil {
|
||||
log.Warnf(c, "[transaction_tags.TagCreateHandler] the tag group \"id:%d\" does not exist for user \"uid:%d\"", tagCreateReq.GroupId, uid)
|
||||
return nil, errs.ErrTransactionTagGroupNotFound
|
||||
}
|
||||
}
|
||||
|
||||
maxOrderId, err := a.tags.GetMaxDisplayOrder(c, uid, tagCreateReq.GroupId)
|
||||
|
||||
if err != nil {
|
||||
@@ -120,6 +136,20 @@ func (a *TransactionTagsApi) TagCreateBatchHandler(c *core.WebContext) (any, *er
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
|
||||
if tagCreateBatchReq.GroupId > 0 {
|
||||
tagGroup, err := a.tagGroups.GetTagGroupByTagGroupId(c, uid, tagCreateBatchReq.GroupId)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transaction_tags.TagCreateBatchHandler] failed to get tag group \"id:%d\" for user \"uid:%d\", because %s", tagCreateBatchReq.GroupId, uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
if tagGroup == nil {
|
||||
log.Warnf(c, "[transaction_tags.TagCreateBatchHandler] the tag group \"id:%d\" does not exist for user \"uid:%d\"", tagCreateBatchReq.GroupId, uid)
|
||||
return nil, errs.ErrTransactionTagGroupNotFound
|
||||
}
|
||||
}
|
||||
|
||||
maxOrderId, err := a.tags.GetMaxDisplayOrder(c, uid, tagCreateBatchReq.GroupId)
|
||||
|
||||
if err != nil {
|
||||
@@ -167,6 +197,20 @@ func (a *TransactionTagsApi) TagModifyHandler(c *core.WebContext) (any, *errs.Er
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
if tagModifyReq.GroupId != tag.TagGroupId && tagModifyReq.GroupId > 0 {
|
||||
tagGroup, err := a.tagGroups.GetTagGroupByTagGroupId(c, uid, tagModifyReq.GroupId)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transaction_tags.TagModifyHandler] failed to get tag group \"id:%d\" for user \"uid:%d\", because %s", tagModifyReq.GroupId, uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
if tagGroup == nil {
|
||||
log.Warnf(c, "[transaction_tags.TagModifyHandler] the tag group \"id:%d\" does not exist for user \"uid:%d\"", tagModifyReq.GroupId, uid)
|
||||
return nil, errs.ErrTransactionTagGroupNotFound
|
||||
}
|
||||
}
|
||||
|
||||
newTag := &models.TransactionTag{
|
||||
TagId: tag.TagId,
|
||||
Uid: uid,
|
||||
|
||||
+934
-61
File diff suppressed because it is too large
Load Diff
+12
-1
@@ -256,6 +256,7 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.WebContext) (any, *errs.Erro
|
||||
userUpdateReq.Nickname = strings.TrimSpace(userUpdateReq.Nickname)
|
||||
|
||||
modifyProfileBasicInfo := false
|
||||
modifyUseLastReconciledTime := false
|
||||
anythingUpdate := false
|
||||
userNew := &models.User{
|
||||
Uid: user.Uid,
|
||||
@@ -317,6 +318,16 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.WebContext) (any, *errs.Erro
|
||||
anythingUpdate = true
|
||||
}
|
||||
|
||||
if userUpdateReq.UseLastReconciledTime != nil && *userUpdateReq.UseLastReconciledTime != user.UseLastReconciledTime {
|
||||
user.UseLastReconciledTime = *userUpdateReq.UseLastReconciledTime
|
||||
userNew.UseLastReconciledTime = *userUpdateReq.UseLastReconciledTime
|
||||
modifyProfileBasicInfo = true
|
||||
modifyUseLastReconciledTime = true
|
||||
anythingUpdate = true
|
||||
} else {
|
||||
modifyUseLastReconciledTime = false
|
||||
}
|
||||
|
||||
if userUpdateReq.TransactionEditScope != nil && *userUpdateReq.TransactionEditScope != user.TransactionEditScope {
|
||||
user.TransactionEditScope = *userUpdateReq.TransactionEditScope
|
||||
userNew.TransactionEditScope = *userUpdateReq.TransactionEditScope
|
||||
@@ -531,7 +542,7 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.WebContext) (any, *errs.Erro
|
||||
return nil, errs.ErrNothingWillBeUpdated
|
||||
}
|
||||
|
||||
keyProfileUpdated, emailSetToUnverified, err := a.users.UpdateUser(c, userNew, modifyUserLanguage)
|
||||
keyProfileUpdated, emailSetToUnverified, err := a.users.UpdateUser(c, userNew, modifyUserLanguage, modifyUseLastReconciledTime)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[users.UserUpdateProfileHandler] failed to update user \"uid:%d\", because %s", user.Uid, err.Error())
|
||||
|
||||
@@ -67,7 +67,7 @@ func InitializeOAuth2Provider(config *settings.Config) error {
|
||||
|
||||
Container.current = oauth2Provider
|
||||
Container.usePKCE = config.OAuth2UsePKCE
|
||||
Container.oauth2HttpClient = httpclient.NewHttpClient(config.OAuth2RequestTimeout, config.OAuth2Proxy, config.OAuth2SkipTLSVerify, settings.GetUserAgent(), config.EnableDebugLog)
|
||||
Container.oauth2HttpClient = httpclient.NewHttpClient(config.OAuth2RequestTimeout, config.OAuth2Proxy, config.OAuth2SkipTLSVerify, core.GetOutgoingUserAgent(), config.EnableDebugLog)
|
||||
Container.externalUserAuthType = externalUserAuthType
|
||||
|
||||
return nil
|
||||
|
||||
@@ -150,7 +150,7 @@ func (l *UserDataCli) ModifyUserPassword(c *core.CliContext, username string, pa
|
||||
Password: password,
|
||||
}
|
||||
|
||||
_, _, err = l.users.UpdateUser(c, userNew, false)
|
||||
err = l.users.UpdateUserPassword(c, userNew)
|
||||
|
||||
if err != nil {
|
||||
log.CliErrorf(c, "[user_data.ModifyUserPassword] failed to update user \"%s\" password, because %s", user.Username, err.Error())
|
||||
|
||||
@@ -20,10 +20,20 @@ type DataTableTransactionDataExporter struct {
|
||||
|
||||
// BuildExportedContent writes the exported transaction data to the data table builder
|
||||
func (c *DataTableTransactionDataExporter) BuildExportedContent(ctx core.Context, dataTableBuilder datatable.TransactionDataTableBuilder, uid int64, transactions []*models.Transaction, accountMap map[int64]*models.Account, categoryMap map[int64]*models.TransactionCategory, tagMap map[int64]*models.TransactionTag, allTagIndexes map[int64][]int64) error {
|
||||
existsTransferOutTransactions := make(map[int64]bool)
|
||||
|
||||
for i := 0; i < len(transactions); i++ {
|
||||
transaction := transactions[i]
|
||||
|
||||
if transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_IN {
|
||||
if transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_OUT {
|
||||
existsTransferOutTransactions[transaction.TransactionId] = true
|
||||
}
|
||||
}
|
||||
|
||||
for i := 0; i < len(transactions); i++ {
|
||||
transaction := transactions[i]
|
||||
|
||||
if transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_IN && existsTransferOutTransactions[transaction.RelatedId] {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -36,14 +46,25 @@ func (c *DataTableTransactionDataExporter) BuildExportedContent(ctx core.Context
|
||||
dataRowMap[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = dataTableBuilder.ReplaceDelimiters(c.getDisplayTransactionTypeName(transaction.Type))
|
||||
dataRowMap[datatable.TRANSACTION_DATA_TABLE_CATEGORY] = c.getExportedTransactionCategoryName(dataTableBuilder, transaction.CategoryId, categoryMap)
|
||||
dataRowMap[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] = c.getExportedTransactionSubCategoryName(dataTableBuilder, transaction.CategoryId, categoryMap)
|
||||
dataRowMap[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = c.getExportedAccountName(dataTableBuilder, transaction.AccountId, accountMap)
|
||||
dataRowMap[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY] = c.getAccountCurrency(dataTableBuilder, transaction.AccountId, accountMap)
|
||||
dataRowMap[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(transaction.Amount)
|
||||
|
||||
if transaction.Type != models.TRANSACTION_DB_TYPE_TRANSFER_IN {
|
||||
dataRowMap[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = c.getExportedAccountName(dataTableBuilder, transaction.AccountId, accountMap)
|
||||
dataRowMap[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY] = c.getAccountCurrency(dataTableBuilder, transaction.AccountId, accountMap)
|
||||
dataRowMap[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(transaction.Amount)
|
||||
} else { // if transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_IN {
|
||||
dataRowMap[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = c.getExportedAccountName(dataTableBuilder, transaction.RelatedAccountId, accountMap)
|
||||
dataRowMap[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY] = c.getAccountCurrency(dataTableBuilder, transaction.RelatedAccountId, accountMap)
|
||||
dataRowMap[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(transaction.RelatedAccountAmount)
|
||||
}
|
||||
|
||||
if transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_OUT {
|
||||
dataRowMap[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = c.getExportedAccountName(dataTableBuilder, transaction.RelatedAccountId, accountMap)
|
||||
dataRowMap[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_CURRENCY] = c.getAccountCurrency(dataTableBuilder, transaction.RelatedAccountId, accountMap)
|
||||
dataRowMap[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT] = utils.FormatAmount(transaction.RelatedAccountAmount)
|
||||
} else if transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_IN {
|
||||
dataRowMap[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = c.getExportedAccountName(dataTableBuilder, transaction.AccountId, accountMap)
|
||||
dataRowMap[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_CURRENCY] = c.getAccountCurrency(dataTableBuilder, transaction.AccountId, accountMap)
|
||||
dataRowMap[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT] = utils.FormatAmount(transaction.Amount)
|
||||
}
|
||||
|
||||
dataRowMap[datatable.TRANSACTION_DATA_TABLE_GEOGRAPHIC_LOCATION] = c.getExportedGeographicLocation(transaction)
|
||||
|
||||
@@ -383,7 +383,7 @@ func (c *DataTableTransactionDataImporter) ParseImportedData(ctx core.Context, u
|
||||
Comment: description,
|
||||
GeoLongitude: geoLongitude,
|
||||
GeoLatitude: geoLatitude,
|
||||
CreatedIp: "127.0.0.1",
|
||||
CreatedIp: ctx.ClientIP(),
|
||||
},
|
||||
TagIds: tagIds,
|
||||
OriginalCategoryName: subCategoryName,
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package custom
|
||||
|
||||
import "github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
|
||||
// CustomTransactionDataParser represents the parser for custom transaction data files
|
||||
type CustomTransactionDataParser interface {
|
||||
ParseDataLines(ctx core.Context, data []byte) ([][]string, error)
|
||||
}
|
||||
+67
-63
@@ -1,4 +1,4 @@
|
||||
package dsv
|
||||
package custom
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"golang.org/x/text/encoding/simplifiedchinese"
|
||||
"golang.org/x/text/encoding/traditionalchinese"
|
||||
"golang.org/x/text/encoding/unicode"
|
||||
"golang.org/x/text/encoding/unicode/utf32"
|
||||
"golang.org/x/text/transform"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/converter"
|
||||
@@ -33,58 +34,57 @@ var supportedFileTypeSeparators = map[string]rune{
|
||||
}
|
||||
|
||||
var supportedFileEncodings = map[string]encoding.Encoding{
|
||||
"utf-8": unicode.UTF8, // UTF-8
|
||||
"utf-8-bom": unicode.UTF8BOM, // UTF-8 with BOM
|
||||
"utf-16le": unicode.UTF16(unicode.LittleEndian, unicode.UseBOM), // UTF-16 Little Endian
|
||||
"utf-16be": unicode.UTF16(unicode.BigEndian, unicode.UseBOM), // UTF-16 Big Endian
|
||||
"utf-16le-bom": unicode.UTF16(unicode.LittleEndian, unicode.ExpectBOM), // UTF-16 Little Endian with BOM
|
||||
"utf-16be-bom": unicode.UTF16(unicode.BigEndian, unicode.ExpectBOM), // UTF-16 Big Endian with BOM
|
||||
"cp437": charmap.CodePage437, // OEM United States (CP-437)
|
||||
"cp863": charmap.CodePage863, // OEM Canadian French (CP-863)
|
||||
"cp037": charmap.CodePage037, // IBM EBCDIC US/Canada (CP-037)
|
||||
"cp1047": charmap.CodePage1047, // IBM EBCDIC Open Systems (CP-1047)
|
||||
"cp1140": charmap.CodePage1140, // IBM EBCDIC US/Canada with Euro (CP-1140)
|
||||
"iso-8859-1": charmap.ISO8859_1, // Western European (ISO-8859-1)
|
||||
"cp850": charmap.CodePage850, // Western European (CP-850)
|
||||
"cp858": charmap.CodePage858, // Western European with Euro (CP-858)
|
||||
"windows-1252": charmap.Windows1252, // Western European (Windows-1252)
|
||||
"iso-8859-15": charmap.ISO8859_15, // Western European (ISO-8859-15)
|
||||
"iso-8859-4": charmap.ISO8859_4, // North European (ISO-8859-4)
|
||||
"iso-8859-10": charmap.ISO8859_10, // North European (ISO-8859-10)
|
||||
"cp865": charmap.CodePage865, // North European (CP-865)
|
||||
"iso-8859-2": charmap.ISO8859_2, // Central European (ISO-8859-2)
|
||||
"cp852": charmap.CodePage852, // Central European (CP-852)
|
||||
"windows-1250": charmap.Windows1250, // Central European (Windows-1250)
|
||||
"iso-8859-14": charmap.ISO8859_14, // Celtic (ISO-8859-14)
|
||||
"iso-8859-3": charmap.ISO8859_3, // South European (ISO-8859-3)
|
||||
"cp860": charmap.CodePage860, // Portuguese (CP-860)
|
||||
"iso-8859-7": charmap.ISO8859_7, // Greek (ISO-8859-7)
|
||||
"windows-1253": charmap.Windows1253, // Greek (Windows-1253)
|
||||
"iso-8859-9": charmap.ISO8859_9, // Turkish (ISO-8859-9)
|
||||
"windows-1254": charmap.Windows1254, // Turkish (Windows-1254)
|
||||
"iso-8859-13": charmap.ISO8859_13, // Baltic (ISO-8859-13)
|
||||
"windows-1257": charmap.Windows1257, // Baltic (Windows-1257)
|
||||
"iso-8859-16": charmap.ISO8859_16, // South-Eastern European (ISO-8859-16)
|
||||
"iso-8859-5": charmap.ISO8859_5, // Cyrillic (ISO-8859-5)
|
||||
"cp855": charmap.CodePage855, // Cyrillic (CP-855)
|
||||
"cp866": charmap.CodePage866, // Cyrillic (CP-866)
|
||||
"windows-1251": charmap.Windows1251, // Cyrillic (Windows-1251)
|
||||
"koi8r": charmap.KOI8R, // Cyrillic (KOI8-R)
|
||||
"koi8u": charmap.KOI8U, // Cyrillic (KOI8-U)
|
||||
"iso-8859-6": charmap.ISO8859_6, // Arabic (ISO-8859-6)
|
||||
"windows-1256": charmap.Windows1256, // Arabic (Windows-1256)
|
||||
"iso-8859-8": charmap.ISO8859_8, // Hebrew (ISO-8859-8)
|
||||
"cp862": charmap.CodePage862, // Hebrew (CP-862)
|
||||
"windows-1255": charmap.Windows1255, // Hebrew (Windows-1255)
|
||||
"windows-874": charmap.Windows874, // Thai (Windows-874)
|
||||
"windows-1258": charmap.Windows1258, // Vietnamese (Windows-1258)
|
||||
"gb18030": simplifiedchinese.GB18030, // Chinese (Simplified, GB18030)
|
||||
"gbk": simplifiedchinese.GBK, // Chinese (Simplified, GBK)
|
||||
"big5": traditionalchinese.Big5, // Chinese (Traditional, Big5)
|
||||
"euc-kr": korean.EUCKR, // Korean (EUC-KR)
|
||||
"euc-jp": japanese.EUCJP, // Japanese (EUC-JP)
|
||||
"iso-2022-jp": japanese.ISO2022JP, // Japanese (ISO-2022-JP)
|
||||
"shift_jis": japanese.ShiftJIS, // Japanese (Shift JIS)
|
||||
"utf-8": unicode.UTF8BOM, // UTF-8
|
||||
"utf-16le": unicode.UTF16(unicode.LittleEndian, unicode.UseBOM), // UTF-16 Little Endian
|
||||
"utf-16be": unicode.UTF16(unicode.BigEndian, unicode.UseBOM), // UTF-16 Big Endian
|
||||
"utf-32le": utf32.UTF32(utf32.LittleEndian, utf32.UseBOM), // UTF-32 Little Endian
|
||||
"utf-32be": utf32.UTF32(utf32.BigEndian, utf32.UseBOM), // UTF-32 Big Endian
|
||||
"cp437": charmap.CodePage437, // OEM United States (CP-437)
|
||||
"cp863": charmap.CodePage863, // OEM Canadian French (CP-863)
|
||||
"cp037": charmap.CodePage037, // IBM EBCDIC US/Canada (CP-037)
|
||||
"cp1047": charmap.CodePage1047, // IBM EBCDIC Open Systems (CP-1047)
|
||||
"cp1140": charmap.CodePage1140, // IBM EBCDIC US/Canada with Euro (CP-1140)
|
||||
"iso-8859-1": charmap.ISO8859_1, // Western European (ISO-8859-1)
|
||||
"cp850": charmap.CodePage850, // Western European (CP-850)
|
||||
"cp858": charmap.CodePage858, // Western European with Euro (CP-858)
|
||||
"windows-1252": charmap.Windows1252, // Western European (Windows-1252)
|
||||
"iso-8859-15": charmap.ISO8859_15, // Western European (ISO-8859-15)
|
||||
"iso-8859-4": charmap.ISO8859_4, // North European (ISO-8859-4)
|
||||
"iso-8859-10": charmap.ISO8859_10, // North European (ISO-8859-10)
|
||||
"cp865": charmap.CodePage865, // North European (CP-865)
|
||||
"iso-8859-2": charmap.ISO8859_2, // Central European (ISO-8859-2)
|
||||
"cp852": charmap.CodePage852, // Central European (CP-852)
|
||||
"windows-1250": charmap.Windows1250, // Central European (Windows-1250)
|
||||
"iso-8859-14": charmap.ISO8859_14, // Celtic (ISO-8859-14)
|
||||
"iso-8859-3": charmap.ISO8859_3, // South European (ISO-8859-3)
|
||||
"cp860": charmap.CodePage860, // Portuguese (CP-860)
|
||||
"iso-8859-7": charmap.ISO8859_7, // Greek (ISO-8859-7)
|
||||
"windows-1253": charmap.Windows1253, // Greek (Windows-1253)
|
||||
"iso-8859-9": charmap.ISO8859_9, // Turkish (ISO-8859-9)
|
||||
"windows-1254": charmap.Windows1254, // Turkish (Windows-1254)
|
||||
"iso-8859-13": charmap.ISO8859_13, // Baltic (ISO-8859-13)
|
||||
"windows-1257": charmap.Windows1257, // Baltic (Windows-1257)
|
||||
"iso-8859-16": charmap.ISO8859_16, // South-Eastern European (ISO-8859-16)
|
||||
"iso-8859-5": charmap.ISO8859_5, // Cyrillic (ISO-8859-5)
|
||||
"cp855": charmap.CodePage855, // Cyrillic (CP-855)
|
||||
"cp866": charmap.CodePage866, // Cyrillic (CP-866)
|
||||
"windows-1251": charmap.Windows1251, // Cyrillic (Windows-1251)
|
||||
"koi8r": charmap.KOI8R, // Cyrillic (KOI8-R)
|
||||
"koi8u": charmap.KOI8U, // Cyrillic (KOI8-U)
|
||||
"iso-8859-6": charmap.ISO8859_6, // Arabic (ISO-8859-6)
|
||||
"windows-1256": charmap.Windows1256, // Arabic (Windows-1256)
|
||||
"iso-8859-8": charmap.ISO8859_8, // Hebrew (ISO-8859-8)
|
||||
"cp862": charmap.CodePage862, // Hebrew (CP-862)
|
||||
"windows-1255": charmap.Windows1255, // Hebrew (Windows-1255)
|
||||
"windows-874": charmap.Windows874, // Thai (Windows-874)
|
||||
"windows-1258": charmap.Windows1258, // Vietnamese (Windows-1258)
|
||||
"gb18030": simplifiedchinese.GB18030, // Chinese (Simplified, GB18030)
|
||||
"gbk": simplifiedchinese.GBK, // Chinese (Simplified, GBK)
|
||||
"big5": traditionalchinese.Big5, // Chinese (Traditional, Big5)
|
||||
"euc-kr": korean.EUCKR, // Korean (EUC-KR)
|
||||
"euc-jp": japanese.EUCJP, // Japanese (EUC-JP)
|
||||
"iso-2022-jp": japanese.ISO2022JP, // Japanese (ISO-2022-JP)
|
||||
"shift_jis": japanese.ShiftJIS, // Japanese (Shift JIS)
|
||||
}
|
||||
|
||||
var customTransactionTypeNameMapping = map[models.TransactionType]string{
|
||||
@@ -94,10 +94,6 @@ var customTransactionTypeNameMapping = map[models.TransactionType]string{
|
||||
models.TRANSACTION_TYPE_TRANSFER: utils.IntToString(int(models.TRANSACTION_TYPE_TRANSFER)),
|
||||
}
|
||||
|
||||
type CustomTransactionDataDsvFileParser interface {
|
||||
ParseDsvFileLines(ctx core.Context, data []byte) ([][]string, error)
|
||||
}
|
||||
|
||||
// customTransactionDataDsvFileImporter defines the structure of custom dsv importer for transaction data
|
||||
type customTransactionDataDsvFileImporter struct {
|
||||
fileEncoding encoding.Encoding
|
||||
@@ -114,8 +110,8 @@ type customTransactionDataDsvFileImporter struct {
|
||||
transactionTagSeparator string
|
||||
}
|
||||
|
||||
// ParseDsvFileLines returns the parsed file lines for specified the dsv file data
|
||||
func (c *customTransactionDataDsvFileImporter) ParseDsvFileLines(ctx core.Context, data []byte) ([][]string, error) {
|
||||
// ParseDataLines returns the parsed file lines for specified the dsv file data
|
||||
func (c *customTransactionDataDsvFileImporter) ParseDataLines(ctx core.Context, data []byte) ([][]string, error) {
|
||||
reader := transform.NewReader(bytes.NewReader(data), c.fileEncoding.NewDecoder())
|
||||
csvReader := csv.NewReader(reader)
|
||||
csvReader.Comma = c.separator
|
||||
@@ -131,7 +127,7 @@ func (c *customTransactionDataDsvFileImporter) ParseDsvFileLines(ctx core.Contex
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(ctx, "[custom_transaction_data_dsv_file_importer.ParseDsvFileLines] cannot parse dsv data, because %s", err.Error())
|
||||
log.Errorf(ctx, "[custom_transaction_data_dsv_file_importer.ParseDataLines] cannot parse dsv data, because %s", err.Error())
|
||||
return nil, errs.ErrInvalidCSVFile
|
||||
}
|
||||
|
||||
@@ -151,7 +147,7 @@ func (c *customTransactionDataDsvFileImporter) ParseDsvFileLines(ctx core.Contex
|
||||
|
||||
// ParseImportedData returns the imported data by parsing the custom transaction dsv data
|
||||
func (c *customTransactionDataDsvFileImporter) ParseImportedData(ctx core.Context, user *models.User, data []byte, defaultTimezone *time.Location, additionalOptions converter.TransactionDataImporterOptions, accountMap map[string]*models.Account, expenseCategoryMap map[string]map[string]*models.TransactionCategory, incomeCategoryMap map[string]map[string]*models.TransactionCategory, transferCategoryMap map[string]map[string]*models.TransactionCategory, tagMap map[string]*models.TransactionTag) (models.ImportedTransactionSlice, []*models.Account, []*models.TransactionCategory, []*models.TransactionCategory, []*models.TransactionCategory, []*models.TransactionTag, error) {
|
||||
allLines, err := c.ParseDsvFileLines(ctx, data)
|
||||
allLines, err := c.ParseDataLines(ctx, data)
|
||||
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, nil, nil, err
|
||||
@@ -170,14 +166,18 @@ func IsDelimiterSeparatedValuesFileType(fileType string) bool {
|
||||
return exists
|
||||
}
|
||||
|
||||
// CreateNewCustomTransactionDataDsvFileParser returns a new custom dsv parser for transaction data
|
||||
func CreateNewCustomTransactionDataDsvFileParser(fileType string, fileEncoding string) (CustomTransactionDataDsvFileParser, error) {
|
||||
// CreateNewCustomTransactionDataDsvFileParser returns a new custom transaction data parser
|
||||
func CreateNewCustomTransactionDataDsvFileParser(fileType string, fileEncoding string) (CustomTransactionDataParser, error) {
|
||||
separator, exists := supportedFileTypeSeparators[fileType]
|
||||
|
||||
if !exists {
|
||||
return nil, errs.ErrImportFileTypeNotSupported
|
||||
}
|
||||
|
||||
if fileEncoding == "" {
|
||||
return nil, errs.ErrImportFileEncodingIsEmpty
|
||||
}
|
||||
|
||||
enc, exists := supportedFileEncodings[fileEncoding]
|
||||
|
||||
if !exists {
|
||||
@@ -198,6 +198,10 @@ func CreateNewCustomTransactionDataDsvFileImporter(fileType string, fileEncoding
|
||||
return nil, errs.ErrImportFileTypeNotSupported
|
||||
}
|
||||
|
||||
if fileEncoding == "" {
|
||||
return nil, errs.ErrImportFileEncodingIsEmpty
|
||||
}
|
||||
|
||||
enc, exists := supportedFileEncodings[fileEncoding]
|
||||
|
||||
if !exists {
|
||||
+5
-5
@@ -1,4 +1,4 @@
|
||||
package dsv
|
||||
package custom
|
||||
|
||||
import (
|
||||
"testing"
|
||||
@@ -25,13 +25,13 @@ func TestIsDelimiterSeparatedValuesFileType(t *testing.T) {
|
||||
assert.False(t, IsDelimiterSeparatedValuesFileType("ssv"))
|
||||
}
|
||||
|
||||
func TestCustomTransactionDataDsvFileParser_ParseDsvFileLines(t *testing.T) {
|
||||
func TestCustomTransactionDataDsvFileParser_ParseDataLines(t *testing.T) {
|
||||
importer, err := CreateNewCustomTransactionDataDsvFileParser("custom_csv", "utf-8")
|
||||
assert.Nil(t, err)
|
||||
|
||||
context := core.NewNullContext()
|
||||
|
||||
allLines, err := importer.ParseDsvFileLines(context, []byte(
|
||||
allLines, err := importer.ParseDataLines(context, []byte(
|
||||
"2024-09-01 00:00:00,B,123.45\n"+
|
||||
"2024-09-01 01:23:45,I,0.12\n"))
|
||||
assert.Nil(t, err)
|
||||
@@ -51,7 +51,7 @@ func TestCustomTransactionDataDsvFileParser_ParseDsvFileLines(t *testing.T) {
|
||||
importer, err = CreateNewCustomTransactionDataDsvFileParser("custom_tsv", "utf-8")
|
||||
assert.Nil(t, err)
|
||||
|
||||
allLines, err = importer.ParseDsvFileLines(context, []byte(
|
||||
allLines, err = importer.ParseDataLines(context, []byte(
|
||||
"2024-09-01 12:34:56\tE\t1.00\n"+
|
||||
"2024-09-01 23:59:59\tT\t0.05"))
|
||||
assert.Nil(t, err)
|
||||
@@ -71,7 +71,7 @@ func TestCustomTransactionDataDsvFileParser_ParseDsvFileLines(t *testing.T) {
|
||||
importer, err = CreateNewCustomTransactionDataDsvFileParser("custom_ssv", "utf-8")
|
||||
assert.Nil(t, err)
|
||||
|
||||
allLines, err = importer.ParseDsvFileLines(context, []byte(
|
||||
allLines, err = importer.ParseDataLines(context, []byte(
|
||||
"2024-09-01 12:34:56;E;1.00\n"+
|
||||
"2024-09-01 23:59:59;T;0.05"))
|
||||
assert.Nil(t, err)
|
||||
@@ -0,0 +1,137 @@
|
||||
package custom
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/converter"
|
||||
csvconverter "github.com/mayswind/ezbookkeeping/pkg/converters/csv"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/excel"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
)
|
||||
|
||||
const customOOXMLExcelFileType = "custom_xlsx"
|
||||
const customMSCFBExcelFileType = "custom_xls"
|
||||
|
||||
// customTransactionDataExcelFileImporter defines the structure of custom excel importer for transaction data
|
||||
type customTransactionDataExcelFileImporter struct {
|
||||
fileType string
|
||||
columnIndexMapping map[datatable.TransactionDataTableColumn]int
|
||||
transactionTypeNameMapping map[string]models.TransactionType
|
||||
hasHeaderLine bool
|
||||
timeFormat string
|
||||
timezoneFormat string
|
||||
amountDecimalSeparator string
|
||||
amountDigitGroupingSymbol string
|
||||
geoLocationSeparator string
|
||||
geoLocationOrder converter.TransactionGeoLocationOrder
|
||||
transactionTagSeparator string
|
||||
}
|
||||
|
||||
// ParseDataLines returns the parsed file lines for specified the excel file data
|
||||
func (c *customTransactionDataExcelFileImporter) ParseDataLines(ctx core.Context, data []byte) ([][]string, error) {
|
||||
var excelDataTable datatable.BasicDataTable
|
||||
var err error
|
||||
|
||||
if c.fileType == customOOXMLExcelFileType {
|
||||
excelDataTable, err = excel.CreateNewExcelOOXMLFileBasicDataTable(data, false)
|
||||
} else if c.fileType == customMSCFBExcelFileType {
|
||||
excelDataTable, err = excel.CreateNewExcelMSCFBFileBasicDataTable(data, false)
|
||||
} else {
|
||||
return nil, errs.ErrImportFileTypeNotSupported
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
iterator := excelDataTable.DataRowIterator()
|
||||
allLines := make([][]string, 0)
|
||||
|
||||
for iterator.HasNext() {
|
||||
row := iterator.Next()
|
||||
items := make([]string, row.ColumnCount())
|
||||
|
||||
for i := 0; i < row.ColumnCount(); i++ {
|
||||
items[i] = strings.Trim(row.GetData(i), " ")
|
||||
}
|
||||
|
||||
allLines = append(allLines, items)
|
||||
}
|
||||
|
||||
return allLines, nil
|
||||
}
|
||||
|
||||
// ParseImportedData returns the imported data by parsing the custom transaction dsv data
|
||||
func (c *customTransactionDataExcelFileImporter) ParseImportedData(ctx core.Context, user *models.User, data []byte, defaultTimezone *time.Location, additionalOptions converter.TransactionDataImporterOptions, accountMap map[string]*models.Account, expenseCategoryMap map[string]map[string]*models.TransactionCategory, incomeCategoryMap map[string]map[string]*models.TransactionCategory, transferCategoryMap map[string]map[string]*models.TransactionCategory, tagMap map[string]*models.TransactionTag) (models.ImportedTransactionSlice, []*models.Account, []*models.TransactionCategory, []*models.TransactionCategory, []*models.TransactionCategory, []*models.TransactionTag, error) {
|
||||
allLines, err := c.ParseDataLines(ctx, data)
|
||||
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, nil, nil, err
|
||||
}
|
||||
|
||||
dataTable := csvconverter.CreateNewCustomCsvBasicDataTable(allLines, c.hasHeaderLine)
|
||||
transactionDataTable := CreateNewCustomPlainTextDataTable(dataTable, c.columnIndexMapping, c.transactionTypeNameMapping, c.timeFormat, c.timezoneFormat, c.amountDecimalSeparator, c.amountDigitGroupingSymbol)
|
||||
dataTableImporter := converter.CreateNewImporterWithTypeNameMapping(customTransactionTypeNameMapping, c.geoLocationSeparator, c.geoLocationOrder, c.transactionTagSeparator)
|
||||
|
||||
return dataTableImporter.ParseImportedData(ctx, user, transactionDataTable, defaultTimezone, additionalOptions, accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap)
|
||||
}
|
||||
|
||||
// IsCustomExcelFileType returns whether the file type is the custom excel file type
|
||||
func IsCustomExcelFileType(fileType string) bool {
|
||||
return fileType == customOOXMLExcelFileType || fileType == customMSCFBExcelFileType
|
||||
}
|
||||
|
||||
// CreateNewCustomTransactionDataExcelFileParser returns a new custom transaction data parser
|
||||
func CreateNewCustomTransactionDataExcelFileParser(fileType string) (CustomTransactionDataParser, error) {
|
||||
if fileType != customOOXMLExcelFileType && fileType != customMSCFBExcelFileType {
|
||||
return nil, errs.ErrImportFileTypeNotSupported
|
||||
}
|
||||
|
||||
return &customTransactionDataExcelFileImporter{
|
||||
fileType: fileType,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CreateNewCustomTransactionDataExcelFileImporter returns a new custom excel importer for transaction data
|
||||
func CreateNewCustomTransactionDataExcelFileImporter(fileType string, columnIndexMapping map[datatable.TransactionDataTableColumn]int, transactionTypeNameMapping map[string]models.TransactionType, hasHeaderLine bool, timeFormat string, timezoneFormat string, amountDecimalSeparator string, amountDigitGroupingSymbol string, geoLocationSeparator string, geoLocationOrder string, transactionTagSeparator string) (converter.TransactionDataImporter, error) {
|
||||
if fileType != customOOXMLExcelFileType && fileType != customMSCFBExcelFileType {
|
||||
return nil, errs.ErrImportFileTypeNotSupported
|
||||
}
|
||||
|
||||
if geoLocationOrder == "" {
|
||||
geoLocationOrder = string(converter.TRANSACTION_GEO_LOCATION_ORDER_LONGITUDE_LATITUDE)
|
||||
} else if geoLocationOrder != string(converter.TRANSACTION_GEO_LOCATION_ORDER_LONGITUDE_LATITUDE) &&
|
||||
geoLocationOrder != string(converter.TRANSACTION_GEO_LOCATION_ORDER_LATITUDE_LONGITUDE) {
|
||||
return nil, errs.ErrImportFileTypeNotSupported
|
||||
}
|
||||
|
||||
if _, exists := columnIndexMapping[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME]; !exists {
|
||||
return nil, errs.ErrMissingRequiredFieldInHeaderRow
|
||||
}
|
||||
|
||||
if _, exists := columnIndexMapping[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE]; !exists {
|
||||
return nil, errs.ErrMissingRequiredFieldInHeaderRow
|
||||
}
|
||||
|
||||
if _, exists := columnIndexMapping[datatable.TRANSACTION_DATA_TABLE_AMOUNT]; !exists {
|
||||
return nil, errs.ErrMissingRequiredFieldInHeaderRow
|
||||
}
|
||||
|
||||
return &customTransactionDataExcelFileImporter{
|
||||
fileType: fileType,
|
||||
columnIndexMapping: columnIndexMapping,
|
||||
transactionTypeNameMapping: transactionTypeNameMapping,
|
||||
hasHeaderLine: hasHeaderLine,
|
||||
timeFormat: timeFormat,
|
||||
timezoneFormat: timezoneFormat,
|
||||
amountDecimalSeparator: amountDecimalSeparator,
|
||||
amountDigitGroupingSymbol: amountDigitGroupingSymbol,
|
||||
geoLocationSeparator: geoLocationSeparator,
|
||||
geoLocationOrder: converter.TransactionGeoLocationOrder(geoLocationOrder),
|
||||
transactionTagSeparator: transactionTagSeparator,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
package custom
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestIsCustomExcelFileType(t *testing.T) {
|
||||
assert.True(t, IsCustomExcelFileType("custom_xlsx"))
|
||||
assert.True(t, IsCustomExcelFileType("custom_xls"))
|
||||
|
||||
assert.False(t, IsCustomExcelFileType("xlsx"))
|
||||
assert.False(t, IsCustomExcelFileType("xls"))
|
||||
assert.False(t, IsCustomExcelFileType("excel"))
|
||||
}
|
||||
|
||||
func TestCustomTransactionDataParser_ParseOOXMLExcelDataLines_EmptyData(t *testing.T) {
|
||||
importer, err := CreateNewCustomTransactionDataExcelFileParser("custom_xlsx")
|
||||
assert.Nil(t, err)
|
||||
|
||||
context := core.NewNullContext()
|
||||
|
||||
testdata, err := os.ReadFile("../../../testdata/empty_excel_file.xlsx")
|
||||
assert.Nil(t, err)
|
||||
|
||||
allLines, err := importer.ParseDataLines(context, testdata)
|
||||
assert.Nil(t, err)
|
||||
|
||||
assert.Equal(t, 0, len(allLines))
|
||||
}
|
||||
|
||||
func TestCustomTransactionDataParser_ParseOOXMLExcelDataLines_SingleSheet(t *testing.T) {
|
||||
importer, err := CreateNewCustomTransactionDataExcelFileParser("custom_xlsx")
|
||||
assert.Nil(t, err)
|
||||
|
||||
context := core.NewNullContext()
|
||||
|
||||
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xlsx")
|
||||
assert.Nil(t, err)
|
||||
|
||||
allLines, err := importer.ParseDataLines(context, testdata)
|
||||
assert.Nil(t, err)
|
||||
|
||||
assert.Equal(t, 3, len(allLines))
|
||||
|
||||
assert.Equal(t, 3, len(allLines[0]))
|
||||
assert.Equal(t, "A1", allLines[0][0])
|
||||
assert.Equal(t, "B1", allLines[0][1])
|
||||
assert.Equal(t, "C1", allLines[0][2])
|
||||
|
||||
assert.Equal(t, 3, len(allLines[1]))
|
||||
assert.Equal(t, "A2", allLines[1][0])
|
||||
assert.Equal(t, "B2", allLines[1][1])
|
||||
assert.Equal(t, "C2", allLines[1][2])
|
||||
|
||||
assert.Equal(t, 3, len(allLines[2]))
|
||||
assert.Equal(t, "A3", allLines[2][0])
|
||||
assert.Equal(t, "B3", allLines[2][1])
|
||||
assert.Equal(t, "C3", allLines[2][2])
|
||||
}
|
||||
|
||||
func TestCustomTransactionDataParser_ParseOOXMLExcelDataLines_MultipleSheet(t *testing.T) {
|
||||
importer, err := CreateNewCustomTransactionDataExcelFileParser("custom_xlsx")
|
||||
assert.Nil(t, err)
|
||||
|
||||
context := core.NewNullContext()
|
||||
|
||||
testdata, err := os.ReadFile("../../../testdata/multiple_sheets_excel_file.xlsx")
|
||||
assert.Nil(t, err)
|
||||
|
||||
allLines, err := importer.ParseDataLines(context, testdata)
|
||||
assert.Nil(t, err)
|
||||
|
||||
assert.Equal(t, 9, len(allLines))
|
||||
|
||||
assert.Equal(t, 3, len(allLines[0]))
|
||||
assert.Equal(t, "A1", allLines[0][0])
|
||||
assert.Equal(t, "B1", allLines[0][1])
|
||||
assert.Equal(t, "C1", allLines[0][2])
|
||||
|
||||
assert.Equal(t, 3, len(allLines[1]))
|
||||
assert.Equal(t, "1-A2", allLines[1][0])
|
||||
assert.Equal(t, "1-B2", allLines[1][1])
|
||||
assert.Equal(t, "1-C2", allLines[1][2])
|
||||
|
||||
assert.Equal(t, 3, len(allLines[2]))
|
||||
assert.Equal(t, "1-A3", allLines[2][0])
|
||||
assert.Equal(t, "1-B3", allLines[2][1])
|
||||
assert.Equal(t, "1-C3", allLines[2][2])
|
||||
|
||||
assert.Equal(t, 3, len(allLines[3]))
|
||||
assert.Equal(t, "A1", allLines[3][0])
|
||||
assert.Equal(t, "B1", allLines[3][1])
|
||||
assert.Equal(t, "C1", allLines[3][2])
|
||||
|
||||
assert.Equal(t, 2, len(allLines[4]))
|
||||
assert.Equal(t, "3-A2", allLines[4][0])
|
||||
assert.Equal(t, "3-B2", allLines[4][1])
|
||||
|
||||
assert.Equal(t, 3, len(allLines[5]))
|
||||
assert.Equal(t, "A1", allLines[5][0])
|
||||
assert.Equal(t, "B1", allLines[5][1])
|
||||
assert.Equal(t, "C1", allLines[5][2])
|
||||
|
||||
assert.Equal(t, 3, len(allLines[6]))
|
||||
assert.Equal(t, "A1", allLines[6][0])
|
||||
assert.Equal(t, "B1", allLines[6][1])
|
||||
assert.Equal(t, "C1", allLines[6][2])
|
||||
|
||||
assert.Equal(t, 3, len(allLines[7]))
|
||||
assert.Equal(t, "5-A2", allLines[7][0])
|
||||
assert.Equal(t, "5-B2", allLines[7][1])
|
||||
assert.Equal(t, "5-C2", allLines[7][2])
|
||||
|
||||
assert.Equal(t, 3, len(allLines[8]))
|
||||
assert.Equal(t, "5-A3", allLines[8][0])
|
||||
assert.Equal(t, "5-B3", allLines[8][1])
|
||||
assert.Equal(t, "5-C3", allLines[8][2])
|
||||
}
|
||||
|
||||
func TestCustomTransactionDataParser_ParseOOXMLExcelDataLines_MultipleSheetWithDifferentColumnCount(t *testing.T) {
|
||||
importer, err := CreateNewCustomTransactionDataExcelFileParser("custom_xlsx")
|
||||
assert.Nil(t, err)
|
||||
|
||||
context := core.NewNullContext()
|
||||
|
||||
testdata, err := os.ReadFile("../../../testdata/multiple_sheets_with_different_header_row_excel_file.xlsx")
|
||||
assert.Nil(t, err)
|
||||
|
||||
_, err = importer.ParseDataLines(context, testdata)
|
||||
assert.EqualError(t, err, errs.ErrFieldsInMultiTableAreDifferent.Message)
|
||||
}
|
||||
|
||||
func TestCustomTransactionDataParser_ParseMSCFBExcelDataLines_EmptyData(t *testing.T) {
|
||||
importer, err := CreateNewCustomTransactionDataExcelFileParser("custom_xls")
|
||||
assert.Nil(t, err)
|
||||
|
||||
context := core.NewNullContext()
|
||||
|
||||
testdata, err := os.ReadFile("../../../testdata/empty_excel_file.xls")
|
||||
assert.Nil(t, err)
|
||||
|
||||
allLines, err := importer.ParseDataLines(context, testdata)
|
||||
assert.Nil(t, err)
|
||||
|
||||
assert.Equal(t, 0, len(allLines))
|
||||
}
|
||||
|
||||
func TestCustomTransactionDataParser_ParseMSCFBExcelDataLines_SingleSheet(t *testing.T) {
|
||||
importer, err := CreateNewCustomTransactionDataExcelFileParser("custom_xls")
|
||||
assert.Nil(t, err)
|
||||
|
||||
context := core.NewNullContext()
|
||||
|
||||
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xls")
|
||||
assert.Nil(t, err)
|
||||
|
||||
allLines, err := importer.ParseDataLines(context, testdata)
|
||||
assert.Nil(t, err)
|
||||
|
||||
assert.Equal(t, 3, len(allLines))
|
||||
|
||||
assert.Equal(t, 3, len(allLines[0]))
|
||||
assert.Equal(t, "A1", allLines[0][0])
|
||||
assert.Equal(t, "B1", allLines[0][1])
|
||||
assert.Equal(t, "C1", allLines[0][2])
|
||||
|
||||
assert.Equal(t, 3, len(allLines[1]))
|
||||
assert.Equal(t, "A2", allLines[1][0])
|
||||
assert.Equal(t, "B2", allLines[1][1])
|
||||
assert.Equal(t, "C2", allLines[1][2])
|
||||
|
||||
assert.Equal(t, 3, len(allLines[2]))
|
||||
assert.Equal(t, "A3", allLines[2][0])
|
||||
assert.Equal(t, "B3", allLines[2][1])
|
||||
assert.Equal(t, "C3", allLines[2][2])
|
||||
}
|
||||
|
||||
func TestCustomTransactionDataParser_ParseMSCFBExcelDataLines_MultipleSheet(t *testing.T) {
|
||||
importer, err := CreateNewCustomTransactionDataExcelFileParser("custom_xls")
|
||||
assert.Nil(t, err)
|
||||
|
||||
context := core.NewNullContext()
|
||||
|
||||
testdata, err := os.ReadFile("../../../testdata/multiple_sheets_excel_file.xls")
|
||||
assert.Nil(t, err)
|
||||
|
||||
allLines, err := importer.ParseDataLines(context, testdata)
|
||||
assert.Nil(t, err)
|
||||
|
||||
assert.Equal(t, 9, len(allLines))
|
||||
|
||||
assert.Equal(t, 3, len(allLines[0]))
|
||||
assert.Equal(t, "A1", allLines[0][0])
|
||||
assert.Equal(t, "B1", allLines[0][1])
|
||||
assert.Equal(t, "C1", allLines[0][2])
|
||||
|
||||
assert.Equal(t, 3, len(allLines[1]))
|
||||
assert.Equal(t, "1-A2", allLines[1][0])
|
||||
assert.Equal(t, "1-B2", allLines[1][1])
|
||||
assert.Equal(t, "1-C2", allLines[1][2])
|
||||
|
||||
assert.Equal(t, 3, len(allLines[2]))
|
||||
assert.Equal(t, "1-A3", allLines[2][0])
|
||||
assert.Equal(t, "1-B3", allLines[2][1])
|
||||
assert.Equal(t, "1-C3", allLines[2][2])
|
||||
|
||||
assert.Equal(t, 3, len(allLines[3]))
|
||||
assert.Equal(t, "A1", allLines[3][0])
|
||||
assert.Equal(t, "B1", allLines[3][1])
|
||||
assert.Equal(t, "C1", allLines[3][2])
|
||||
|
||||
assert.Equal(t, 3, len(allLines[4]))
|
||||
assert.Equal(t, "3-A2", allLines[4][0])
|
||||
assert.Equal(t, "3-B2", allLines[4][1])
|
||||
assert.Equal(t, "", allLines[4][2])
|
||||
|
||||
assert.Equal(t, 3, len(allLines[5]))
|
||||
assert.Equal(t, "A1", allLines[5][0])
|
||||
assert.Equal(t, "B1", allLines[5][1])
|
||||
assert.Equal(t, "C1", allLines[5][2])
|
||||
|
||||
assert.Equal(t, 3, len(allLines[6]))
|
||||
assert.Equal(t, "A1", allLines[6][0])
|
||||
assert.Equal(t, "B1", allLines[6][1])
|
||||
assert.Equal(t, "C1", allLines[6][2])
|
||||
|
||||
assert.Equal(t, 3, len(allLines[7]))
|
||||
assert.Equal(t, "5-A2", allLines[7][0])
|
||||
assert.Equal(t, "5-B2", allLines[7][1])
|
||||
assert.Equal(t, "5-C2", allLines[7][2])
|
||||
|
||||
assert.Equal(t, 3, len(allLines[8]))
|
||||
assert.Equal(t, "5-A3", allLines[8][0])
|
||||
assert.Equal(t, "5-B3", allLines[8][1])
|
||||
assert.Equal(t, "5-C3", allLines[8][2])
|
||||
}
|
||||
|
||||
func TestCustomTransactionDataParser_ParseMSCFBExcelDataLines_MultipleSheetWithDifferentColumnCount(t *testing.T) {
|
||||
importer, err := CreateNewCustomTransactionDataExcelFileParser("custom_xls")
|
||||
assert.Nil(t, err)
|
||||
|
||||
context := core.NewNullContext()
|
||||
|
||||
testdata, err := os.ReadFile("../../../testdata/multiple_sheets_with_different_header_row_excel_file.xls")
|
||||
assert.Nil(t, err)
|
||||
|
||||
_, err = importer.ParseDataLines(context, testdata)
|
||||
assert.EqualError(t, err, errs.ErrFieldsInMultiTableAreDifferent.Message)
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package dsv
|
||||
package custom
|
||||
|
||||
import (
|
||||
"strings"
|
||||
@@ -86,7 +86,7 @@ func (t *ExcelMSCFBFileBasicDataTable) DataRowIterator() datatable.BasicDataTabl
|
||||
// ColumnCount returns the total count of column in this data row
|
||||
func (r *ExcelMSCFBFileBasicDataTableRow) ColumnCount() int {
|
||||
row := r.sheet.Row(r.rowIndex)
|
||||
return row.LastCol() + 1
|
||||
return row.LastCol()
|
||||
}
|
||||
|
||||
// GetData returns the data in the specified column index
|
||||
@@ -195,7 +195,10 @@ func CreateNewExcelMSCFBFileBasicDataTable(data []byte, hasTitleLine bool) (data
|
||||
}
|
||||
|
||||
if i == 0 {
|
||||
for j := 0; j <= row.LastCol(); j++ {
|
||||
// row.LastCol() returns "colMac" in the "Row" struct, that is an unsigned integer that specifies the one-based index of the last column.
|
||||
// But row.FirstCol() returns "colMic" in the "Row" struct, that is an unsigned integer that specifies the zero-based index of the first column.
|
||||
// Reference: https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-xls/4aab09eb-49ed-4d01-a3b1-1d726247d3c2
|
||||
for j := 0; j < row.LastCol(); j++ {
|
||||
headerItem := row.Col(j)
|
||||
|
||||
if headerItem == "" {
|
||||
@@ -205,7 +208,7 @@ func CreateNewExcelMSCFBFileBasicDataTable(data []byte, hasTitleLine bool) (data
|
||||
firstRowItems = append(firstRowItems, headerItem)
|
||||
}
|
||||
} else {
|
||||
for j := 0; j <= min(row.LastCol(), len(firstRowItems)-1); j++ {
|
||||
for j := 0; j < min(row.LastCol(), len(firstRowItems)); j++ {
|
||||
headerItem := row.Col(j)
|
||||
|
||||
if headerItem != firstRowItems[j] {
|
||||
|
||||
@@ -300,10 +300,10 @@ func TestExcelMSCFBFileBasicDataRowColumnCount(t *testing.T) {
|
||||
iterator := datatable.DataRowIterator()
|
||||
|
||||
row1 := iterator.Next()
|
||||
assert.EqualValues(t, 4, row1.ColumnCount())
|
||||
assert.EqualValues(t, 3, row1.ColumnCount())
|
||||
|
||||
row2 := iterator.Next()
|
||||
assert.EqualValues(t, 4, row2.ColumnCount())
|
||||
assert.EqualValues(t, 3, row2.ColumnCount())
|
||||
}
|
||||
|
||||
func TestExcelMSCFBFileBasicDataRowGetData(t *testing.T) {
|
||||
|
||||
@@ -5,9 +5,9 @@ import (
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/beancount"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/camt"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/converter"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/custom"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/default"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/dsv"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/feidee"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/fireflyIII"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/gnucash"
|
||||
@@ -85,17 +85,29 @@ func GetTransactionDataImporter(fileType string) (converter.TransactionDataImpor
|
||||
}
|
||||
}
|
||||
|
||||
// IsCustomDelimiterSeparatedValuesFileType returns whether the file type is the delimiter-separated values file type
|
||||
func IsCustomDelimiterSeparatedValuesFileType(fileType string) bool {
|
||||
return dsv.IsDelimiterSeparatedValuesFileType(fileType)
|
||||
// IsCustomFileFormatFileType returns whether the file type is the custom file format
|
||||
func IsCustomFileFormatFileType(fileType string) bool {
|
||||
return custom.IsDelimiterSeparatedValuesFileType(fileType) || custom.IsCustomExcelFileType(fileType)
|
||||
}
|
||||
|
||||
// CreateNewDelimiterSeparatedValuesDataParser returns a new delimiter-separated values data parser according to the file type and encoding
|
||||
func CreateNewDelimiterSeparatedValuesDataParser(fileType string, fileEncoding string) (dsv.CustomTransactionDataDsvFileParser, error) {
|
||||
return dsv.CreateNewCustomTransactionDataDsvFileParser(fileType, fileEncoding)
|
||||
// CreateNewCustomFileFormatTransactionDataParser returns a new custom transaction data parser according to the file type and encoding
|
||||
func CreateNewCustomFileFormatTransactionDataParser(fileType string, fileEncoding string) (custom.CustomTransactionDataParser, error) {
|
||||
if custom.IsDelimiterSeparatedValuesFileType(fileType) {
|
||||
return custom.CreateNewCustomTransactionDataDsvFileParser(fileType, fileEncoding)
|
||||
} else if custom.IsCustomExcelFileType(fileType) {
|
||||
return custom.CreateNewCustomTransactionDataExcelFileParser(fileType)
|
||||
} else {
|
||||
return nil, errs.ErrImportFileTypeNotSupported
|
||||
}
|
||||
}
|
||||
|
||||
// CreateNewDelimiterSeparatedValuesDataImporter returns a new delimiter-separated values data importer according to the file type and encoding
|
||||
func CreateNewDelimiterSeparatedValuesDataImporter(fileType string, fileEncoding string, columnIndexMapping map[datatable.TransactionDataTableColumn]int, transactionTypeNameMapping map[string]models.TransactionType, hasHeaderLine bool, timeFormat string, timezoneFormat string, amountDecimalSeparator string, amountDigitGroupingSymbol string, geoLocationSeparator string, geoLocationOrder string, transactionTagSeparator string) (converter.TransactionDataImporter, error) {
|
||||
return dsv.CreateNewCustomTransactionDataDsvFileImporter(fileType, fileEncoding, columnIndexMapping, transactionTypeNameMapping, hasHeaderLine, timeFormat, timezoneFormat, amountDecimalSeparator, amountDigitGroupingSymbol, geoLocationSeparator, geoLocationOrder, transactionTagSeparator)
|
||||
// CreateNewCustomTransactionDataImporter returns a new custom transaction data importer according to the file type and encoding
|
||||
func CreateNewCustomTransactionDataImporter(fileType string, fileEncoding string, columnIndexMapping map[datatable.TransactionDataTableColumn]int, transactionTypeNameMapping map[string]models.TransactionType, hasHeaderLine bool, timeFormat string, timezoneFormat string, amountDecimalSeparator string, amountDigitGroupingSymbol string, geoLocationSeparator string, geoLocationOrder string, transactionTagSeparator string) (converter.TransactionDataImporter, error) {
|
||||
if custom.IsDelimiterSeparatedValuesFileType(fileType) {
|
||||
return custom.CreateNewCustomTransactionDataDsvFileImporter(fileType, fileEncoding, columnIndexMapping, transactionTypeNameMapping, hasHeaderLine, timeFormat, timezoneFormat, amountDecimalSeparator, amountDigitGroupingSymbol, geoLocationSeparator, geoLocationOrder, transactionTagSeparator)
|
||||
} else if custom.IsCustomExcelFileType(fileType) {
|
||||
return custom.CreateNewCustomTransactionDataExcelFileImporter(fileType, columnIndexMapping, transactionTypeNameMapping, hasHeaderLine, timeFormat, timezoneFormat, amountDecimalSeparator, amountDigitGroupingSymbol, geoLocationSeparator, geoLocationOrder, transactionTagSeparator)
|
||||
} else {
|
||||
return nil, errs.ErrImportFileTypeNotSupported
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,6 +170,29 @@ func TestWeChatPayCsvFileImporterParseImportedData_ParseInvalidType(t *testing.T
|
||||
assert.EqualError(t, err, errs.ErrNotFoundTransactionDataInFile.Message)
|
||||
}
|
||||
|
||||
func TestWeChatPayCsvFileImporterParseImportedData_ParseAmountWithThousandSeparator(t *testing.T) {
|
||||
importer := WeChatPayTransactionDataCsvFileImporter
|
||||
context := core.NewNullContext()
|
||||
|
||||
user := &models.User{
|
||||
Uid: 1234567890,
|
||||
DefaultCurrency: "CNY",
|
||||
}
|
||||
|
||||
data1 := "微信支付账单明细,,,,\n" +
|
||||
"微信昵称:[xxx],,,,\n" +
|
||||
"起始时间:[2024-01-01 00:00:00] 终止时间:[2024-09-01 23:59:59],,,,\n" +
|
||||
",,,,\n" +
|
||||
"----------------------微信支付账单明细列表--------------------,,,,\n" +
|
||||
"交易时间,交易类型,收/支,金额(元),支付方式,当前状态\n" +
|
||||
"2024-09-01 01:23:45,二维码收款,收入,\"¥1,234.56\",/,已收钱\n"
|
||||
allNewTransactions, _, _, _, _, _, err := importer.ParseImportedData(context, user, []byte(data1), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
|
||||
assert.Nil(t, err)
|
||||
|
||||
assert.Equal(t, 1, len(allNewTransactions))
|
||||
assert.Equal(t, int64(123456), allNewTransactions[0].Amount)
|
||||
}
|
||||
|
||||
func TestWeChatPayCsvFileImporterParseImportedData_ParseInvalidAmount(t *testing.T) {
|
||||
importer := WeChatPayTransactionDataCsvFileImporter
|
||||
context := core.NewNullContext()
|
||||
|
||||
@@ -71,7 +71,7 @@ func (p *weChatPayTransactionDataRowParser) Parse(ctx core.Context, user *models
|
||||
}
|
||||
|
||||
if p.hasOriginalColumn(wechatPayTransactionAmountColumnName) {
|
||||
amount, success := utils.ParseFirstConsecutiveNumber(dataRow.GetData(wechatPayTransactionAmountColumnName))
|
||||
amount, success := utils.ParseFirstConsecutiveNumber(strings.ReplaceAll(dataRow.GetData(wechatPayTransactionAmountColumnName), ",", ""))
|
||||
|
||||
if !success {
|
||||
log.Errorf(ctx, "[wechat_pay_transaction_data_row_parser.Parse] cannot parse amount \"%s\" of transaction in row \"%s\"", dataRow.GetData(wechatPayTransactionAmountColumnName), rowId)
|
||||
|
||||
@@ -1,4 +1,21 @@
|
||||
package core
|
||||
|
||||
import "fmt"
|
||||
|
||||
// ApplicationName represents the application name
|
||||
const ApplicationName = "ezBookkeeping"
|
||||
|
||||
// Version, CommitHash and BuildTime are set at build
|
||||
var (
|
||||
Version string
|
||||
CommitHash string
|
||||
BuildTime string
|
||||
)
|
||||
|
||||
func GetOutgoingUserAgent() string {
|
||||
if Version == "" {
|
||||
return ApplicationName
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s/%s", ApplicationName, Version)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import "context"
|
||||
// Context is the base context of ezBookkeeping
|
||||
type Context interface {
|
||||
context.Context
|
||||
ClientIP() string
|
||||
GetContextId() string
|
||||
GetClientLocale() string
|
||||
}
|
||||
|
||||
@@ -12,6 +12,11 @@ type CliContext struct {
|
||||
command *cli.Command
|
||||
}
|
||||
|
||||
// ClientIP returns the client IP address, for CLI context, it always returns the loopback address
|
||||
func (c *CliContext) ClientIP() string {
|
||||
return "127.0.0.1"
|
||||
}
|
||||
|
||||
// GetContextId returns the current context id
|
||||
func (c *CliContext) GetContextId() string {
|
||||
return ""
|
||||
|
||||
@@ -14,6 +14,11 @@ type CronContext struct {
|
||||
cronJobInterval time.Duration
|
||||
}
|
||||
|
||||
// ClientIP returns the client IP address, for cron job context, it always returns the loopback address
|
||||
func (c *CronContext) ClientIP() string {
|
||||
return "127.0.0.1"
|
||||
}
|
||||
|
||||
// GetContextId returns the current context id
|
||||
func (c *CronContext) GetContextId() string {
|
||||
return c.contextId
|
||||
|
||||
@@ -9,6 +9,11 @@ type NullContext struct {
|
||||
context.Context
|
||||
}
|
||||
|
||||
// ClientIP returns the client IP address, for null context, it always returns the loopback address
|
||||
func (c *NullContext) ClientIP() string {
|
||||
return "127.0.0.1"
|
||||
}
|
||||
|
||||
// GetContextId returns the current context id
|
||||
func (c *NullContext) GetContextId() string {
|
||||
return nullContextId
|
||||
|
||||
@@ -6,6 +6,15 @@ import (
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// TokenUserAgentCreatedViaCli is the user agent of token created via cli
|
||||
const TokenUserAgentCreatedViaCli = ApplicationName + " Cli"
|
||||
|
||||
// TokenUserAgentForAPI is the user agent for API token
|
||||
const TokenUserAgentForAPI = ApplicationName + " API"
|
||||
|
||||
// TokenUserAgentForMCP is the user agent for MCP token
|
||||
const TokenUserAgentForMCP = ApplicationName + " MCP"
|
||||
|
||||
// TokenType represents token type
|
||||
type TokenType byte
|
||||
|
||||
|
||||
+24
-22
@@ -4,26 +4,28 @@ import "net/http"
|
||||
|
||||
// Error codes related to accounts
|
||||
var (
|
||||
ErrAccountIdInvalid = NewNormalError(NormalSubcategoryAccount, 0, http.StatusBadRequest, "account id is invalid")
|
||||
ErrAccountNotFound = NewNormalError(NormalSubcategoryAccount, 1, http.StatusBadRequest, "account not found")
|
||||
ErrAccountTypeInvalid = NewNormalError(NormalSubcategoryAccount, 2, http.StatusBadRequest, "account type is invalid")
|
||||
ErrAccountCurrencyInvalid = NewNormalError(NormalSubcategoryAccount, 3, http.StatusBadRequest, "account currency is invalid")
|
||||
ErrAccountHaveNoSubAccount = NewNormalError(NormalSubcategoryAccount, 4, http.StatusBadRequest, "account must have at least one sub-account")
|
||||
ErrAccountCannotHaveSubAccounts = NewNormalError(NormalSubcategoryAccount, 5, http.StatusBadRequest, "account cannot have sub-accounts")
|
||||
ErrParentAccountCannotSetCurrency = NewNormalError(NormalSubcategoryAccount, 6, http.StatusBadRequest, "parent account cannot set currency")
|
||||
ErrParentAccountCannotSetBalance = NewNormalError(NormalSubcategoryAccount, 7, http.StatusBadRequest, "parent account cannot set balance")
|
||||
ErrSubAccountCategoryNotEqualsToParent = NewNormalError(NormalSubcategoryAccount, 8, http.StatusBadRequest, "sub-account category not equals to parent")
|
||||
ErrSubAccountTypeInvalid = NewNormalError(NormalSubcategoryAccount, 9, http.StatusBadRequest, "sub-account type invalid")
|
||||
ErrSourceAccountNotFound = NewNormalError(NormalSubcategoryAccount, 11, http.StatusBadRequest, "source account not found")
|
||||
ErrDestinationAccountNotFound = NewNormalError(NormalSubcategoryAccount, 12, http.StatusBadRequest, "destination account not found")
|
||||
ErrAccountInUseCannotBeDeleted = NewNormalError(NormalSubcategoryAccount, 13, http.StatusBadRequest, "account is in use and cannot be deleted")
|
||||
ErrAccountCategoryInvalid = NewNormalError(NormalSubcategoryAccount, 14, http.StatusBadRequest, "account category is invalid")
|
||||
ErrAccountBalanceTimeNotSet = NewNormalError(NormalSubcategoryAccount, 15, http.StatusBadRequest, "account balance time is not set")
|
||||
ErrCannotSetStatementDateForNonCreditCard = NewNormalError(NormalSubcategoryAccount, 16, http.StatusBadRequest, "cannot set statement date for non credit card account")
|
||||
ErrCannotSetStatementDateForSubAccount = NewNormalError(NormalSubcategoryAccount, 17, http.StatusBadRequest, "cannot set statement date for sub account")
|
||||
ErrSubAccountNotFound = NewNormalError(NormalSubcategoryAccount, 18, http.StatusBadRequest, "sub-account not found")
|
||||
ErrSubAccountInUseCannotBeDeleted = NewNormalError(NormalSubcategoryAccount, 19, http.StatusBadRequest, "sub-account is in use and cannot be deleted")
|
||||
ErrNotSupportedChangeCurrency = NewNormalError(NormalSubcategoryAccount, 20, http.StatusBadRequest, "not supported to modify account currency")
|
||||
ErrNotSupportedChangeBalance = NewNormalError(NormalSubcategoryAccount, 21, http.StatusBadRequest, "not supported to modify account balance")
|
||||
ErrNotSupportedChangeBalanceTime = NewNormalError(NormalSubcategoryAccount, 22, http.StatusBadRequest, "not supported to modify account balance time")
|
||||
ErrAccountIdInvalid = NewNormalError(NormalSubcategoryAccount, 0, http.StatusBadRequest, "account id is invalid")
|
||||
ErrAccountNotFound = NewNormalError(NormalSubcategoryAccount, 1, http.StatusBadRequest, "account not found")
|
||||
ErrAccountTypeInvalid = NewNormalError(NormalSubcategoryAccount, 2, http.StatusBadRequest, "account type is invalid")
|
||||
ErrAccountCurrencyInvalid = NewNormalError(NormalSubcategoryAccount, 3, http.StatusBadRequest, "account currency is invalid")
|
||||
ErrAccountHaveNoSubAccount = NewNormalError(NormalSubcategoryAccount, 4, http.StatusBadRequest, "account must have at least one sub-account")
|
||||
ErrAccountCannotHaveSubAccounts = NewNormalError(NormalSubcategoryAccount, 5, http.StatusBadRequest, "account cannot have sub-accounts")
|
||||
ErrParentAccountCannotSetCurrency = NewNormalError(NormalSubcategoryAccount, 6, http.StatusBadRequest, "parent account cannot set currency")
|
||||
ErrParentAccountCannotSetBalance = NewNormalError(NormalSubcategoryAccount, 7, http.StatusBadRequest, "parent account cannot set balance")
|
||||
ErrSubAccountCategoryNotEqualsToParent = NewNormalError(NormalSubcategoryAccount, 8, http.StatusBadRequest, "sub-account category not equals to parent")
|
||||
ErrSubAccountTypeInvalid = NewNormalError(NormalSubcategoryAccount, 9, http.StatusBadRequest, "sub-account type invalid")
|
||||
ErrSourceAccountNotFound = NewNormalError(NormalSubcategoryAccount, 11, http.StatusBadRequest, "source account not found")
|
||||
ErrDestinationAccountNotFound = NewNormalError(NormalSubcategoryAccount, 12, http.StatusBadRequest, "destination account not found")
|
||||
ErrAccountInUseCannotBeDeleted = NewNormalError(NormalSubcategoryAccount, 13, http.StatusBadRequest, "account is in use and cannot be deleted")
|
||||
ErrAccountCategoryInvalid = NewNormalError(NormalSubcategoryAccount, 14, http.StatusBadRequest, "account category is invalid")
|
||||
ErrAccountBalanceTimeNotSet = NewNormalError(NormalSubcategoryAccount, 15, http.StatusBadRequest, "account balance time is not set")
|
||||
ErrCannotSetStatementDateForNonCreditCard = NewNormalError(NormalSubcategoryAccount, 16, http.StatusBadRequest, "cannot set statement date for non credit card account")
|
||||
ErrCannotSetStatementDateForSubAccount = NewNormalError(NormalSubcategoryAccount, 17, http.StatusBadRequest, "cannot set statement date for sub account")
|
||||
ErrSubAccountNotFound = NewNormalError(NormalSubcategoryAccount, 18, http.StatusBadRequest, "sub-account not found")
|
||||
ErrSubAccountInUseCannotBeDeleted = NewNormalError(NormalSubcategoryAccount, 19, http.StatusBadRequest, "sub-account is in use and cannot be deleted")
|
||||
ErrNotSupportedChangeCurrency = NewNormalError(NormalSubcategoryAccount, 20, http.StatusBadRequest, "not supported to modify account currency")
|
||||
ErrNotSupportedChangeBalance = NewNormalError(NormalSubcategoryAccount, 21, http.StatusBadRequest, "not supported to modify account balance")
|
||||
ErrNotSupportedChangeBalanceTime = NewNormalError(NormalSubcategoryAccount, 22, http.StatusBadRequest, "not supported to modify account balance time")
|
||||
ErrParentAccountCannotSetLastReconciledTime = NewNormalError(NormalSubcategoryAccount, 23, http.StatusBadRequest, "parent account cannot set last reconciled time")
|
||||
ErrCannotSetLastReconciledTimeBeforeCurrent = NewNormalError(NormalSubcategoryAccount, 24, http.StatusBadRequest, "cannot set last reconciled time before current value")
|
||||
)
|
||||
|
||||
@@ -45,4 +45,5 @@ var (
|
||||
ErrCannotMoveTransactionFromOrToHiddenAccount = NewNormalError(NormalSubcategoryTransaction, 38, http.StatusBadRequest, "cannot move transaction from or to hidden account")
|
||||
ErrCannotMoveTransactionFromOrToParentAccount = NewNormalError(NormalSubcategoryTransaction, 39, http.StatusBadRequest, "cannot move transaction from or to parent account")
|
||||
ErrCannotMoveTransactionBetweenAccountsWithDifferentCurrencies = NewNormalError(NormalSubcategoryTransaction, 40, http.StatusBadRequest, "cannot move transaction between accounts with different currencies")
|
||||
ErrCannotAddTagsToTooManyTransactionsOneTime = NewNormalError(NormalSubcategoryTransaction, 41, http.StatusBadRequest, "cannot add tags to too many transactions one time")
|
||||
)
|
||||
|
||||
@@ -41,4 +41,5 @@ var (
|
||||
ErrCannotLoginByPassword = NewNormalError(NormalSubcategoryUser, 32, http.StatusBadRequest, "cannot login by password")
|
||||
ErrUserNameIsInvalid = NewNormalError(NormalSubcategoryUser, 33, http.StatusBadRequest, "user name is invalid")
|
||||
ErrNickNameIsInvalid = NewNormalError(NormalSubcategoryUser, 34, http.StatusBadRequest, "nick name is invalid")
|
||||
ErrLastReconciledTimeIsNotEnabled = NewNormalError(NormalSubcategoryUser, 35, http.StatusBadRequest, "last reconciled time is not enabled")
|
||||
)
|
||||
|
||||
@@ -108,6 +108,6 @@ func (e *CommonHttpExchangeRatesDataProvider) GetLatestExchangeRates(c core.Cont
|
||||
func newCommonHttpExchangeRatesDataProvider(config *settings.Config, dataSource HttpExchangeRatesDataSource) *CommonHttpExchangeRatesDataProvider {
|
||||
return &CommonHttpExchangeRatesDataProvider{
|
||||
dataSource: dataSource,
|
||||
httpClient: httpclient.NewHttpClient(config.ExchangeRatesRequestTimeout, config.ExchangeRatesProxy, config.ExchangeRatesSkipTLSVerify, settings.GetUserAgent(), config.EnableDebugLog),
|
||||
httpClient: httpclient.NewHttpClient(config.ExchangeRatesRequestTimeout, config.ExchangeRatesProxy, config.ExchangeRatesSkipTLSVerify, core.GetOutgoingUserAgent(), config.EnableDebugLog),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,21 +14,6 @@ import (
|
||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||
)
|
||||
|
||||
func TestExchangeRatesApiLatestExchangeRateHandler_ReserveBankOfAustraliaDataSource(t *testing.T) {
|
||||
exchangeRateResponse := executeLatestExchangeRateHandler(t, settings.ReserveBankOfAustraliaDataSource)
|
||||
|
||||
if exchangeRateResponse == nil {
|
||||
return
|
||||
}
|
||||
|
||||
assert.Equal(t, "AUD", exchangeRateResponse.BaseCurrency)
|
||||
|
||||
supportedCurrencyCodes := []string{"CAD", "CHF", "CNY", "EUR", "GBP", "HKD", "IDR", "INR", "JPY", "KRW",
|
||||
"MYR", "NZD", "PGK", "PHP", "SGD", "THB", "TWD", "USD", "VND"}
|
||||
|
||||
checkExchangeRatesHaveSpecifiedCurrencies(t, exchangeRateResponse.BaseCurrency, supportedCurrencyCodes, exchangeRateResponse.ExchangeRates)
|
||||
}
|
||||
|
||||
func TestExchangeRatesApiLatestExchangeRateHandler_BankOfCanadaDataSource(t *testing.T) {
|
||||
exchangeRateResponse := executeLatestExchangeRateHandler(t, settings.BankOfCanadaDataSource)
|
||||
|
||||
@@ -39,7 +24,7 @@ func TestExchangeRatesApiLatestExchangeRateHandler_BankOfCanadaDataSource(t *tes
|
||||
assert.Equal(t, "CAD", exchangeRateResponse.BaseCurrency)
|
||||
|
||||
supportedCurrencyCodes := []string{"AUD", "BRL", "CHF", "CNY", "EUR", "GBP", "HKD", "IDR", "INR",
|
||||
"JPY", "KRW", "MXN", "MYR", "NOK", "NZD", "PEN", "RUB", "SAR", "SEK", "SGD", "THB", "TRY", "TWD",
|
||||
"JPY", "KRW", "MXN", "MYR", "NOK", "NZD", "PEN", "PLN", "RUB", "SAR", "SEK", "SGD", "THB", "TRY", "TWD",
|
||||
"USD", "VND", "ZAR"}
|
||||
|
||||
checkExchangeRatesHaveSpecifiedCurrencies(t, exchangeRateResponse.BaseCurrency, supportedCurrencyCodes, exchangeRateResponse.ExchangeRates)
|
||||
@@ -150,6 +135,22 @@ func TestExchangeRatesApiLatestExchangeRateHandler_BankOfIsraelDataSource(t *tes
|
||||
checkExchangeRatesHaveSpecifiedCurrencies(t, exchangeRateResponse.BaseCurrency, supportedCurrencyCodes, exchangeRateResponse.ExchangeRates)
|
||||
}
|
||||
|
||||
func TestExchangeRatesApiLatestExchangeRateHandler_NationalBankOfKazakhstan(t *testing.T) {
|
||||
exchangeRateResponse := executeLatestExchangeRateHandler(t, settings.NationalBankOfKazakhstanDataSource)
|
||||
|
||||
if exchangeRateResponse == nil {
|
||||
return
|
||||
}
|
||||
|
||||
assert.Equal(t, "KZT", exchangeRateResponse.BaseCurrency)
|
||||
|
||||
supportedCurrencyCodes := []string{"AED", "AMD", "AUD", "AZN", "BRL", "BYN", "CAD", "CHF", "CNY", "CZK",
|
||||
"DKK", "EUR", "GBP", "GEL", "HKD", "HUF", "INR", "IRR", "JPY", "KGS", "KRW", "KWD", "MDL", "MXN",
|
||||
"MYR", "NOK", "PLN", "RUB", "SAR", "SEK", "SGD", "THB", "TJS", "TRY", "UAH", "USD", "UZS", "ZAR"}
|
||||
|
||||
checkExchangeRatesHaveSpecifiedCurrencies(t, exchangeRateResponse.BaseCurrency, supportedCurrencyCodes, exchangeRateResponse.ExchangeRates)
|
||||
}
|
||||
|
||||
func TestExchangeRatesApiLatestExchangeRateHandler_CentralBankOfMyanmarDataSource(t *testing.T) {
|
||||
exchangeRateResponse := executeLatestExchangeRateHandler(t, settings.CentralBankOfMyanmarDataSource)
|
||||
|
||||
@@ -285,7 +286,7 @@ func TestExchangeRatesApiLatestExchangeRateHandler_CentralBankOfUzbekistanDataSo
|
||||
assert.Equal(t, "UZS", exchangeRateResponse.BaseCurrency)
|
||||
|
||||
supportedCurrencyCodes := []string{"AED", "AFN", "AMD", "ARS", "AUD", "AZN",
|
||||
"BDT", "BGN", "BHD", "BND", "BRL", "BYN", "CAD", "CHF", "CNY", "CUP", "CZK",
|
||||
"BDT", "BHD", "BND", "BRL", "BYN", "CAD", "CHF", "CNY", "CUP", "CZK",
|
||||
"DKK", "DZD", "EGP", "EUR", "GBP", "GEL", "HKD", "HUF", "IDR", "ILS", "INR", "IQD", "IRR", "ISK",
|
||||
"JOD", "JPY", "KGS", "KHR", "KRW", "KWD", "KZT", "LAK", "LBP", "LYD",
|
||||
"MAD", "MDL", "MMK", "MNT", "MXN", "MYR", "NOK", "NZD", "OMR", "PHP", "PKR", "PLN",
|
||||
|
||||
@@ -19,10 +19,7 @@ var (
|
||||
|
||||
// InitializeExchangeRatesDataSource initializes the current exchange rates data source according to the config
|
||||
func InitializeExchangeRatesDataSource(config *settings.Config) error {
|
||||
if config.ExchangeRatesDataSource == settings.ReserveBankOfAustraliaDataSource {
|
||||
Container.current = newCommonHttpExchangeRatesDataProvider(config, &ReserveBankOfAustraliaDataSource{})
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.BankOfCanadaDataSource {
|
||||
if config.ExchangeRatesDataSource == settings.BankOfCanadaDataSource {
|
||||
Container.current = newCommonHttpExchangeRatesDataProvider(config, &BankOfCanadaDataSource{})
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.CzechNationalBankDataSource {
|
||||
@@ -43,6 +40,9 @@ func InitializeExchangeRatesDataSource(config *settings.Config) error {
|
||||
} else if config.ExchangeRatesDataSource == settings.BankOfIsraelDataSource {
|
||||
Container.current = newCommonHttpExchangeRatesDataProvider(config, &BankOfIsraelDataSource{})
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.NationalBankOfKazakhstanDataSource {
|
||||
Container.current = newCommonHttpExchangeRatesDataProvider(config, &NationalBankOfKazakhstanDataSource{})
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.CentralBankOfMyanmarDataSource {
|
||||
Container.current = newCommonHttpExchangeRatesDataProvider(config, &CentralBankOfMyanmarDataSource{})
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
package exchangerates
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"math"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/validators"
|
||||
)
|
||||
|
||||
const nationalBankOfKazakhstanExchangeRateUrl = "https://www.nationalbank.kz/rss/rates_all.xml"
|
||||
const nationalBankOfKazakhstanExchangeRateReferenceUrl = "https://nationalbank.kz/en/exchangerates/ezhednevnye-oficialnye-rynochnye-kursy-valyut"
|
||||
const nationalBankOfKazakhstanDataSource = "Қазақстан Республикасының Ұлттық Банкі"
|
||||
const nationalBankOfKazakhstanBaseCurrency = "KZT"
|
||||
|
||||
const nationalBankOfKazakhstanUpdateDateFormat = "02.01.2006"
|
||||
const nationalBankOfKazakhstanUpdateDateTimezone = "Asia/Almaty"
|
||||
|
||||
// NationalBankOfKazakhstanDataSource defines the structure of exchange rates data source of the national bank of Kazakhstan
|
||||
type NationalBankOfKazakhstanDataSource struct {
|
||||
HttpExchangeRatesDataSource
|
||||
}
|
||||
|
||||
// NationalBankOfKazakhstanExchangeRates represents the exchange rates data from the national bank of Kazakhstan
|
||||
type NationalBankOfKazakhstanExchangeRates struct {
|
||||
Channel struct {
|
||||
Items []*NationalBankOfKazakhstanExchangeRate `xml:"item"`
|
||||
} `xml:"channel"`
|
||||
}
|
||||
|
||||
// NationalBankOfKazakhstanExchangeRate represents the exchange rate data from the national bank of Kazakhstan
|
||||
type NationalBankOfKazakhstanExchangeRate struct {
|
||||
Currency string `xml:"title"`
|
||||
Rate string `xml:"description"`
|
||||
Unit string `xml:"quant"`
|
||||
Date string `xml:"pubDate"`
|
||||
}
|
||||
|
||||
// ToLatestExchangeRateResponse returns a view-object according to original data from the national bank of Kazakhstan
|
||||
func (e *NationalBankOfKazakhstanExchangeRates) ToLatestExchangeRateResponse(c core.Context) *models.LatestExchangeRateResponse {
|
||||
if e == nil || len(e.Channel.Items) < 1 {
|
||||
log.Errorf(c, "[national_bank_of_kazakhstan_datasource.ToLatestExchangeRateResponse] exchange rates is empty")
|
||||
return nil
|
||||
}
|
||||
|
||||
timezone, err := time.LoadLocation(nationalBankOfKazakhstanUpdateDateTimezone)
|
||||
if err != nil {
|
||||
log.Errorf(c, "[national_bank_of_kazakhstan_datasource.ToLatestExchangeRateResponse] failed to load timezone, timezone name is %s", nationalBankOfKazakhstanUpdateDateTimezone)
|
||||
return nil
|
||||
}
|
||||
|
||||
exchangeRates := make(models.LatestExchangeRateSlice, 0, len(e.Channel.Items))
|
||||
latestUpdateTime := int64(0)
|
||||
|
||||
for i := 0; i < len(e.Channel.Items); i++ {
|
||||
exchangeRate := e.Channel.Items[i]
|
||||
|
||||
if _, exists := validators.AllCurrencyNames[exchangeRate.Currency]; !exists {
|
||||
continue
|
||||
}
|
||||
|
||||
updateTime, err := time.ParseInLocation(nationalBankOfKazakhstanUpdateDateFormat, exchangeRate.Date, timezone)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[central_bank_of_kazakhstan_datasource.ToLatestExchangeRateResponse] failed to parse update date, datetime is %s", exchangeRate.Date)
|
||||
return nil
|
||||
}
|
||||
|
||||
if updateTime.Unix() > latestUpdateTime {
|
||||
latestUpdateTime = updateTime.Unix()
|
||||
}
|
||||
|
||||
finalRate := exchangeRate.ToLatestExchangeRate(c)
|
||||
if finalRate == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
exchangeRates = append(exchangeRates, finalRate)
|
||||
}
|
||||
|
||||
return &models.LatestExchangeRateResponse{
|
||||
DataSource: nationalBankOfKazakhstanDataSource,
|
||||
ReferenceUrl: nationalBankOfKazakhstanExchangeRateReferenceUrl,
|
||||
UpdateTime: latestUpdateTime,
|
||||
BaseCurrency: nationalBankOfKazakhstanBaseCurrency,
|
||||
ExchangeRates: exchangeRates,
|
||||
}
|
||||
}
|
||||
|
||||
// ToLatestExchangeRate returns a data pair according to original data from the national bank of Kazakhstan
|
||||
func (e *NationalBankOfKazakhstanExchangeRate) ToLatestExchangeRate(c core.Context) *models.LatestExchangeRate {
|
||||
rate, err := utils.StringToFloat64(e.Rate)
|
||||
if err != nil {
|
||||
log.Warnf(c, "[national_bank_of_kazakhstan_datasource.ToLatestExchangeRate] failed to parse rate, currency is %s, rate is %s", e.Currency, e.Rate)
|
||||
return nil
|
||||
}
|
||||
|
||||
if rate <= 0 {
|
||||
log.Warnf(c, "[national_bank_of_kazakhstan_datasource.ToLatestExchangeRate] rate is invalid, currency is %s, rate is %s", e.Currency, e.Rate)
|
||||
return nil
|
||||
}
|
||||
|
||||
unit, err := utils.StringToFloat64(e.Unit)
|
||||
if err != nil {
|
||||
log.Warnf(c, "[national_bank_of_kazakhstan_datasource.ToLatestExchangeRate] failed to parse unit, currency=%s, unit=%s", e.Currency, e.Unit)
|
||||
}
|
||||
|
||||
if unit <= 0 {
|
||||
log.Warnf(c, "[national_bank_of_kazakhstan_datasource.ToLatestExchangeRate] unit is less or equal zero, currency is %s, unit is %s", e.Currency, e.Unit)
|
||||
return nil
|
||||
}
|
||||
|
||||
finalRate := unit / rate
|
||||
if math.IsInf(finalRate, 0) {
|
||||
log.Warnf(c, "[national_bank_of_kazakhstan_datasource.ToLatestExchangeRate] final exchange rate calculation failed, currency is %s, unit is %s, rate is %s", e.Currency, e.Unit, e.Rate)
|
||||
return nil
|
||||
}
|
||||
|
||||
return &models.LatestExchangeRate{
|
||||
Currency: e.Currency,
|
||||
Rate: utils.Float64ToString(finalRate),
|
||||
}
|
||||
}
|
||||
|
||||
// BuildRequests returns the national bank of Kazakhstan exchange rates http requests
|
||||
func (e *NationalBankOfKazakhstanDataSource) BuildRequests() ([]*http.Request, error) {
|
||||
req, err := http.NewRequest("GET", nationalBankOfKazakhstanExchangeRateUrl, nil)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return []*http.Request{req}, nil
|
||||
}
|
||||
|
||||
// Parse returns the common response entity according to the national bank of Kazakhstan data source raw response
|
||||
func (e *NationalBankOfKazakhstanDataSource) Parse(c core.Context, content []byte) (*models.LatestExchangeRateResponse, error) {
|
||||
nationalBankOfKazakhstanData := &NationalBankOfKazakhstanExchangeRates{}
|
||||
err := xml.Unmarshal(content, nationalBankOfKazakhstanData)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[national_bank_of_kazakhstan_datasource.Parse] failed to parse xml data, content is %s, because %s", string(content), err.Error())
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
latestExchangeRateResponse := nationalBankOfKazakhstanData.ToLatestExchangeRateResponse(c)
|
||||
|
||||
if latestExchangeRateResponse == nil {
|
||||
log.Errorf(c, "[national_bank_of_kazakhstan_datasource.Parse] failed to parse latest exchange rate data, content is %s", string(content))
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
return latestExchangeRateResponse, nil
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package exchangerates
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
)
|
||||
|
||||
const nationalBankOfKazakhstanMinimumRequiredContent = "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n" +
|
||||
" <rss version=\"2.0\">\n" +
|
||||
" <channel>\n" +
|
||||
" <item>\n" +
|
||||
" <title>USD</title>\n" +
|
||||
" <pubDate>28.04.2026</pubDate>\n" +
|
||||
" <description>450.50</description>\n" +
|
||||
" <quant>1</quant>\n" +
|
||||
" </item>\n" +
|
||||
" <item>\n" +
|
||||
" <title>VND</title>\n" +
|
||||
" <pubDate>28.04.2026</pubDate>\n" +
|
||||
" <description>0.018</description>\n" +
|
||||
" <quant>10</quant>\n" +
|
||||
" </item>\n" +
|
||||
" </channel>\n" +
|
||||
"</rss>"
|
||||
|
||||
func TestNationalBankOfKazakhstanDataSource_StandardDataExtractBaseCurrency(t *testing.T) {
|
||||
dataSource := &NationalBankOfKazakhstanDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
resp, err := dataSource.Parse(context, []byte(nationalBankOfKazakhstanMinimumRequiredContent))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, "KZT", resp.BaseCurrency)
|
||||
}
|
||||
|
||||
func TestNationalBankOfKazakhstanDataSource_StandardDataExtractUpdateTime(t *testing.T) {
|
||||
dataSource := &NationalBankOfKazakhstanDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
resp, err := dataSource.Parse(context, []byte(nationalBankOfKazakhstanMinimumRequiredContent))
|
||||
assert.Equal(t, nil, err)
|
||||
|
||||
assert.Equal(t, int64(1777316400), resp.UpdateTime)
|
||||
}
|
||||
|
||||
func TestNationalBankOfKazakhstanDataSource_StandardDataExtractExchangeRates(t *testing.T) {
|
||||
dataSource := &NationalBankOfKazakhstanDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
resp, err := dataSource.Parse(context, []byte(nationalBankOfKazakhstanMinimumRequiredContent))
|
||||
assert.Equal(t, nil, err)
|
||||
|
||||
assert.Contains(t, resp.ExchangeRates, &models.LatestExchangeRate{
|
||||
Currency: "USD",
|
||||
Rate: "0.0022197558268590455",
|
||||
})
|
||||
|
||||
assert.Contains(t, resp.ExchangeRates, &models.LatestExchangeRate{
|
||||
Currency: "VND",
|
||||
Rate: "555.5555555555555",
|
||||
})
|
||||
}
|
||||
|
||||
func TestNationalBankOfKazakhstanDataSource_BlankContent(t *testing.T) {
|
||||
dataSource := &NationalBankOfKazakhstanDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte(""))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestNationalBankOfKazakhstanDataSource_EmptyData(t *testing.T) {
|
||||
dataSource := &NationalBankOfKazakhstanDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
content := "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n" +
|
||||
"<rss version=\"2.0\">\n" +
|
||||
"<channel>\n" +
|
||||
"</channel>\n" +
|
||||
"</rss>"
|
||||
|
||||
_, err := dataSource.Parse(context, []byte(content))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestNationalBankOfKazakhstanDataSource_InvalidCurrency(t *testing.T) {
|
||||
dataSource := &NationalBankOfKazakhstanDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
content := "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n" +
|
||||
" <rss version=\"2.0\">\n" +
|
||||
" <channel>\n" +
|
||||
" <item>\n" +
|
||||
" <title>XXX</title>\n" +
|
||||
" <pubDate>28.04.2026</pubDate>\n" +
|
||||
" <description>450.50</description>\n" +
|
||||
" <quant>1</quant>\n" +
|
||||
" </item>\n" +
|
||||
" </channel>\n" +
|
||||
"</rss>"
|
||||
|
||||
resp, err := dataSource.Parse(context, []byte(content))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, resp.ExchangeRates, 0)
|
||||
}
|
||||
|
||||
func TestNationalBankOfKazakhstanDataSource_InvalidUnit(t *testing.T) {
|
||||
dataSource := &NationalBankOfKazakhstanDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
content := "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n" +
|
||||
" <rss version=\"2.0\">\n" +
|
||||
" <channel>\n" +
|
||||
" <item>\n" +
|
||||
" <title>USD</title>\n" +
|
||||
" <pubDate>28.04.2026</pubDate>\n" +
|
||||
" <description>450.50</description>\n" +
|
||||
" <quant>null</quant>\n" +
|
||||
" </item>\n" +
|
||||
" </channel>\n" +
|
||||
"</rss>"
|
||||
|
||||
resp, err := dataSource.Parse(context, []byte(content))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, resp.ExchangeRates, 0)
|
||||
|
||||
content = "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n" +
|
||||
" <rss version=\"2.0\">\n" +
|
||||
" <channel>\n" +
|
||||
" <item>\n" +
|
||||
" <title>USD</title>\n" +
|
||||
" <pubDate>28.04.2026</pubDate>\n" +
|
||||
" <description>450.50</description>\n" +
|
||||
" <quant>0</quant>\n" +
|
||||
" </item>\n" +
|
||||
" </channel>\n" +
|
||||
"</rss>"
|
||||
|
||||
resp, err = dataSource.Parse(context, []byte(content))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, resp.ExchangeRates, 0)
|
||||
}
|
||||
|
||||
func TestNationalBankOfKazakhstanDataSource_InvalidRate(t *testing.T) {
|
||||
dataSource := &NationalBankOfKazakhstanDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
content := "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n" +
|
||||
" <rss version=\"2.0\">\n" +
|
||||
" <channel>\n" +
|
||||
" <item>\n" +
|
||||
" <title>USD</title>\n" +
|
||||
" <pubDate>28.04.2026</pubDate>\n" +
|
||||
" <description>null</description>\n" +
|
||||
" <quant>1</quant>\n" +
|
||||
" </item>\n" +
|
||||
" </channel>\n" +
|
||||
"</rss>"
|
||||
|
||||
resp, err := dataSource.Parse(context, []byte(content))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, resp.ExchangeRates, 0)
|
||||
|
||||
content = "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n" +
|
||||
" <rss version=\"2.0\">\n" +
|
||||
" <channel>\n" +
|
||||
" <item>\n" +
|
||||
" <title>USD</title>\n" +
|
||||
" <pubDate>28.04.2026</pubDate>\n" +
|
||||
" <description>0</description>\n" +
|
||||
" <quant>1</quant>\n" +
|
||||
" </item>\n" +
|
||||
" </channel>\n" +
|
||||
"</rss>"
|
||||
|
||||
resp, err = dataSource.Parse(context, []byte(content))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, resp.ExchangeRates, 0)
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
package exchangerates
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/html/charset"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/validators"
|
||||
)
|
||||
|
||||
const reserveBankOfAustraliaExchangeRateUrl = "https://www.rba.gov.au/rss/rss-cb-exchange-rates.xml"
|
||||
const reserveBankOfAustraliaExchangeRateReferenceUrl = "https://www.rba.gov.au/statistics/frequency/exchange-rates.html"
|
||||
const reserveBankOfAustraliaDataSource = "Reserve Bank of Australia"
|
||||
const reserveBankOfAustraliaBaseCurrency = "AUD"
|
||||
|
||||
const reserveBankOfAustraliaDataUpdateDateFormat = "2006-01-02T15:04:05Z07:00"
|
||||
|
||||
// ReserveBankOfAustraliaDataSource defines the structure of exchange rates data source of the reserve bank of Australia
|
||||
type ReserveBankOfAustraliaDataSource struct {
|
||||
HttpExchangeRatesDataSource
|
||||
}
|
||||
|
||||
// ReserveBankOfAustraliaData represents the whole data from the reserve bank of Australia
|
||||
type ReserveBankOfAustraliaData struct {
|
||||
XMLName xml.Name `xml:"RDF"`
|
||||
Channel *ReserveBankOfAustraliaRssChannel `xml:"channel"`
|
||||
Items []*ReserveBankOfAustraliaRssItem `xml:"item"`
|
||||
}
|
||||
|
||||
// ReserveBankOfAustraliaRssChannel represents the rss channel from the reserve bank of Australia
|
||||
type ReserveBankOfAustraliaRssChannel struct {
|
||||
Date string `xml:"date"`
|
||||
}
|
||||
|
||||
// ReserveBankOfAustraliaRssItem represents the rss item from the reserve bank of Australia
|
||||
type ReserveBankOfAustraliaRssItem struct {
|
||||
Statistics *ReserveBankOfAustraliaItemStatistics `xml:"statistics"`
|
||||
}
|
||||
|
||||
// ReserveBankOfAustraliaItemStatistics represents the item statistics from the reserve bank of Australia
|
||||
type ReserveBankOfAustraliaItemStatistics struct {
|
||||
ExchangeRate *ReserveBankOfAustraliaExchangeRate `xml:"exchangeRate"`
|
||||
}
|
||||
|
||||
// ReserveBankOfAustraliaExchangeRate represents the exchange rate from the reserve bank of Australia
|
||||
type ReserveBankOfAustraliaExchangeRate struct {
|
||||
BaseCurrency string `xml:"baseCurrency"`
|
||||
TargetCurrency string `xml:"targetCurrency"`
|
||||
Observation *ReserveBankOfAustraliaExchangeRateObservation `xml:"observation"`
|
||||
}
|
||||
|
||||
// ReserveBankOfAustraliaExchangeRateObservation represents the exchange rate data from the reserve bank of Australia
|
||||
type ReserveBankOfAustraliaExchangeRateObservation struct {
|
||||
Value string `xml:"value"`
|
||||
Unit string `xml:"unit"`
|
||||
}
|
||||
|
||||
// ToLatestExchangeRateResponse returns a view-object according to original data from the reserve bank of Australia
|
||||
func (e *ReserveBankOfAustraliaData) ToLatestExchangeRateResponse(c core.Context) *models.LatestExchangeRateResponse {
|
||||
if e.Channel == nil {
|
||||
log.Errorf(c, "[reserve_bank_of_australia_datasource.ToLatestExchangeRateResponse] rss channel does not exist")
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(e.Items) < 1 {
|
||||
log.Errorf(c, "[reserve_bank_of_australia_datasource.ToLatestExchangeRateResponse] rss items is empty")
|
||||
return nil
|
||||
}
|
||||
|
||||
exchangeRates := make(models.LatestExchangeRateSlice, 0, len(e.Items))
|
||||
|
||||
for i := 0; i < len(e.Items); i++ {
|
||||
item := e.Items[i]
|
||||
|
||||
if item.Statistics == nil || item.Statistics.ExchangeRate == nil || item.Statistics.ExchangeRate.Observation == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if item.Statistics.ExchangeRate.BaseCurrency != reserveBankOfAustraliaBaseCurrency || item.Statistics.ExchangeRate.Observation.Unit != reserveBankOfAustraliaBaseCurrency {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, exists := validators.AllCurrencyNames[item.Statistics.ExchangeRate.TargetCurrency]; !exists {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, err := utils.StringToFloat64(item.Statistics.ExchangeRate.Observation.Value); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
exchangeRates = append(exchangeRates, item.Statistics.ExchangeRate.ToLatestExchangeRate())
|
||||
}
|
||||
|
||||
updateDateTime := e.Channel.Date
|
||||
updateTime, err := time.Parse(reserveBankOfAustraliaDataUpdateDateFormat, updateDateTime)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[reserve_bank_of_australia_datasource.ToLatestExchangeRateResponse] failed to parse update date, datetime is %s", updateDateTime)
|
||||
return nil
|
||||
}
|
||||
|
||||
latestExchangeRateResp := &models.LatestExchangeRateResponse{
|
||||
DataSource: reserveBankOfAustraliaDataSource,
|
||||
ReferenceUrl: reserveBankOfAustraliaExchangeRateReferenceUrl,
|
||||
UpdateTime: updateTime.Unix(),
|
||||
BaseCurrency: reserveBankOfAustraliaBaseCurrency,
|
||||
ExchangeRates: exchangeRates,
|
||||
}
|
||||
|
||||
return latestExchangeRateResp
|
||||
}
|
||||
|
||||
// ToLatestExchangeRate returns a data pair according to original data from the reserve bank of Australia
|
||||
func (e *ReserveBankOfAustraliaExchangeRate) ToLatestExchangeRate() *models.LatestExchangeRate {
|
||||
return &models.LatestExchangeRate{
|
||||
Currency: e.TargetCurrency,
|
||||
Rate: e.Observation.Value,
|
||||
}
|
||||
}
|
||||
|
||||
// BuildRequests returns the reserve bank of Australia exchange rates http requests
|
||||
func (e *ReserveBankOfAustraliaDataSource) BuildRequests() ([]*http.Request, error) {
|
||||
req, err := http.NewRequest("GET", reserveBankOfAustraliaExchangeRateUrl, nil)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return []*http.Request{req}, nil
|
||||
}
|
||||
|
||||
// Parse returns the common response entity according to the the reserve bank of Australia data source raw response
|
||||
func (e *ReserveBankOfAustraliaDataSource) Parse(c core.Context, content []byte) (*models.LatestExchangeRateResponse, error) {
|
||||
xmlDecoder := xml.NewDecoder(bytes.NewReader(content))
|
||||
xmlDecoder.CharsetReader = charset.NewReaderLabel
|
||||
|
||||
reserveBankOfAustraliaData := &ReserveBankOfAustraliaData{}
|
||||
err := xmlDecoder.Decode(reserveBankOfAustraliaData)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[reserve_bank_of_australia_datasource.Parse] failed to parse xml data, content is %s, because %s", string(content), err.Error())
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
latestExchangeRateResponse := reserveBankOfAustraliaData.ToLatestExchangeRateResponse(c)
|
||||
|
||||
if latestExchangeRateResponse == nil {
|
||||
log.Errorf(c, "[reserve_bank_of_australia_datasource.Parse] failed to parse latest exchange rate data, content is %s", string(content))
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
return latestExchangeRateResponse, nil
|
||||
}
|
||||
@@ -1,268 +0,0 @@
|
||||
package exchangerates
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
)
|
||||
|
||||
const reserveBankOfAustraliaMinimumRequiredContent = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" +
|
||||
"<rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\" xmlns:rba=\"https://www.rba.gov.au/statistics/frequency/exchange-rates.html\" xmlns:cb=\"http://www.cbwiki.net/wiki/index.php/Specification_1.2/\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:dcterms=\"http://purl.org/dc/terms/\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns=\"http://purl.org/rss/1.0/\" xsi:schemaLocation=\"http://www.w3.org/1999/02/22-rdf-syntax-ns# rdf.xsd\">\n" +
|
||||
" <channel rdf:about=\"https://www.rba.gov.au/rss/rss-cb-exchange-rates.xml\">\n" +
|
||||
" <dc:date>2021-04-01T16:45:00+11:00</dc:date>\n" +
|
||||
" </channel>\n" +
|
||||
" <item rdf:about=\"https://www.rba.gov.au/statistics/frequency/exchange-rates.html#USD\">\n" +
|
||||
" <cb:statistics rdf:parseType=\"Resource\">\n" +
|
||||
" <cb:exchangeRate rdf:parseType=\"Resource\">\n" +
|
||||
" <cb:observation rdf:parseType=\"Resource\">\n" +
|
||||
" <cb:value>0.7543</cb:value>\n" +
|
||||
" <cb:unit>AUD</cb:unit>\n" +
|
||||
" </cb:observation>\n" +
|
||||
" <cb:baseCurrency>AUD</cb:baseCurrency>\n" +
|
||||
" <cb:targetCurrency>USD</cb:targetCurrency>\n" +
|
||||
" </cb:exchangeRate>\n" +
|
||||
" </cb:statistics>\n" +
|
||||
" </item>\n" +
|
||||
" <item rdf:about=\"https://www.rba.gov.au/statistics/frequency/exchange-rates.html#CNY\">\n" +
|
||||
" <cb:statistics rdf:parseType=\"Resource\">\n" +
|
||||
" <cb:exchangeRate rdf:parseType=\"Resource\">\n" +
|
||||
" <cb:observation rdf:parseType=\"Resource\">\n" +
|
||||
" <cb:value>4.9577</cb:value>\n" +
|
||||
" <cb:unit>AUD</cb:unit>\n" +
|
||||
" </cb:observation>\n" +
|
||||
" <cb:baseCurrency>AUD</cb:baseCurrency>\n" +
|
||||
" <cb:targetCurrency>CNY</cb:targetCurrency>\n" +
|
||||
" </cb:exchangeRate>\n" +
|
||||
" </cb:statistics>\n" +
|
||||
" </item>\n" +
|
||||
"</rdf:RDF>"
|
||||
|
||||
func TestReserveBankOfAustraliaDataSource_StandardDataExtractBaseCurrency(t *testing.T) {
|
||||
dataSource := &ReserveBankOfAustraliaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(reserveBankOfAustraliaMinimumRequiredContent))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, "AUD", actualLatestExchangeRateResponse.BaseCurrency)
|
||||
}
|
||||
|
||||
func TestReserveBankOfAustraliaDataSource_StandardDataExtractUpdateTime(t *testing.T) {
|
||||
dataSource := &ReserveBankOfAustraliaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(reserveBankOfAustraliaMinimumRequiredContent))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, int64(1617255900), actualLatestExchangeRateResponse.UpdateTime)
|
||||
}
|
||||
|
||||
func TestReserveBankOfAustraliaDataSource_StandardDataExtractExchangeRates(t *testing.T) {
|
||||
dataSource := &ReserveBankOfAustraliaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(reserveBankOfAustraliaMinimumRequiredContent))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Contains(t, actualLatestExchangeRateResponse.ExchangeRates, &models.LatestExchangeRate{
|
||||
Currency: "USD",
|
||||
Rate: "0.7543",
|
||||
})
|
||||
assert.Contains(t, actualLatestExchangeRateResponse.ExchangeRates, &models.LatestExchangeRate{
|
||||
Currency: "CNY",
|
||||
Rate: "4.9577",
|
||||
})
|
||||
}
|
||||
|
||||
func TestReserveBankOfAustraliaDataSource_BlankContent(t *testing.T) {
|
||||
dataSource := &ReserveBankOfAustraliaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte(""))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestReserveBankOfAustraliaDataSource_OnlyXMLHeader(t *testing.T) {
|
||||
dataSource := &ReserveBankOfAustraliaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"UTF-8\"?>"))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestReserveBankOfAustraliaDataSource_EmptyRDFContent(t *testing.T) {
|
||||
dataSource := &ReserveBankOfAustraliaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"+
|
||||
"<rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\" xmlns:rba=\"https://www.rba.gov.au/statistics/frequency/exchange-rates.html\" xmlns:cb=\"http://www.cbwiki.net/wiki/index.php/Specification_1.2/\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:dcterms=\"http://purl.org/dc/terms/\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns=\"http://purl.org/rss/1.0/\" xsi:schemaLocation=\"http://www.w3.org/1999/02/22-rdf-syntax-ns# rdf.xsd\">\n"+
|
||||
"</rdf:RDF>"))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestReserveBankOfAustraliaDataSource_EmptyChannelContent(t *testing.T) {
|
||||
dataSource := &ReserveBankOfAustraliaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"+
|
||||
"<rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\" xmlns:rba=\"https://www.rba.gov.au/statistics/frequency/exchange-rates.html\" xmlns:cb=\"http://www.cbwiki.net/wiki/index.php/Specification_1.2/\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:dcterms=\"http://purl.org/dc/terms/\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns=\"http://purl.org/rss/1.0/\" xsi:schemaLocation=\"http://www.w3.org/1999/02/22-rdf-syntax-ns# rdf.xsd\">\n"+
|
||||
" <channel rdf:about=\"https://www.rba.gov.au/rss/rss-cb-exchange-rates.xml\">\n"+
|
||||
" </channel>"+
|
||||
" <item rdf:about=\"https://www.rba.gov.au/statistics/frequency/exchange-rates.html#USD\">\n"+
|
||||
" <cb:statistics rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:exchangeRate rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:observation rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:value>0.7543</cb:value>\n"+
|
||||
" <cb:unit>AUD</cb:unit>\n"+
|
||||
" </cb:observation>\n"+
|
||||
" <cb:baseCurrency>AUD</cb:baseCurrency>\n"+
|
||||
" <cb:targetCurrency>USD</cb:targetCurrency>\n"+
|
||||
" </cb:exchangeRate>\n"+
|
||||
" </cb:statistics>\n"+
|
||||
" </item>\n"+
|
||||
"</rdf:RDF>"))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestReserveBankOfAustraliaDataSource_NoItem(t *testing.T) {
|
||||
dataSource := &ReserveBankOfAustraliaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
_, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"+
|
||||
"<rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\" xmlns:rba=\"https://www.rba.gov.au/statistics/frequency/exchange-rates.html\" xmlns:cb=\"http://www.cbwiki.net/wiki/index.php/Specification_1.2/\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:dcterms=\"http://purl.org/dc/terms/\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns=\"http://purl.org/rss/1.0/\" xsi:schemaLocation=\"http://www.w3.org/1999/02/22-rdf-syntax-ns# rdf.xsd\">\n"+
|
||||
" <channel rdf:about=\"https://www.rba.gov.au/rss/rss-cb-exchange-rates.xml\">\n"+
|
||||
" <dc:date>2021-04-01T16:45:00+11:00</dc:date>\n"+
|
||||
" </channel>\n"+
|
||||
"</rdf:RDF>"))
|
||||
assert.NotEqual(t, nil, err)
|
||||
}
|
||||
|
||||
func TestReserveBankOfAustraliaDataSource_BaseCurrencyNotEqualPreset(t *testing.T) {
|
||||
dataSource := &ReserveBankOfAustraliaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"+
|
||||
"<rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\" xmlns:rba=\"https://www.rba.gov.au/statistics/frequency/exchange-rates.html\" xmlns:cb=\"http://www.cbwiki.net/wiki/index.php/Specification_1.2/\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:dcterms=\"http://purl.org/dc/terms/\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns=\"http://purl.org/rss/1.0/\" xsi:schemaLocation=\"http://www.w3.org/1999/02/22-rdf-syntax-ns# rdf.xsd\">\n"+
|
||||
" <channel rdf:about=\"https://www.rba.gov.au/rss/rss-cb-exchange-rates.xml\">\n"+
|
||||
" <dc:date>2021-04-01T16:45:00+11:00</dc:date>\n"+
|
||||
" </channel>\n"+
|
||||
" <item rdf:about=\"https://www.rba.gov.au/statistics/frequency/exchange-rates.html#USD\">\n"+
|
||||
" <cb:statistics rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:exchangeRate rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:observation rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:value>0.7543</cb:value>\n"+
|
||||
" <cb:unit>AUD</cb:unit>\n"+
|
||||
" </cb:observation>\n"+
|
||||
" <cb:baseCurrency>USD</cb:baseCurrency>\n"+
|
||||
" <cb:targetCurrency>USD</cb:targetCurrency>\n"+
|
||||
" </cb:exchangeRate>\n"+
|
||||
" </cb:statistics>\n"+
|
||||
" </item>\n"+
|
||||
"</rdf:RDF>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
|
||||
func TestReserveBankOfAustraliaDataSource_UnitCurrencyNotEqualPreset(t *testing.T) {
|
||||
dataSource := &ReserveBankOfAustraliaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"+
|
||||
"<rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\" xmlns:rba=\"https://www.rba.gov.au/statistics/frequency/exchange-rates.html\" xmlns:cb=\"http://www.cbwiki.net/wiki/index.php/Specification_1.2/\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:dcterms=\"http://purl.org/dc/terms/\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns=\"http://purl.org/rss/1.0/\" xsi:schemaLocation=\"http://www.w3.org/1999/02/22-rdf-syntax-ns# rdf.xsd\">\n"+
|
||||
" <channel rdf:about=\"https://www.rba.gov.au/rss/rss-cb-exchange-rates.xml\">\n"+
|
||||
" <dc:date>2021-04-01T16:45:00+11:00</dc:date>\n"+
|
||||
" </channel>\n"+
|
||||
" <item rdf:about=\"https://www.rba.gov.au/statistics/frequency/exchange-rates.html#USD\">\n"+
|
||||
" <cb:statistics rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:exchangeRate rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:observation rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:value>0.7543</cb:value>\n"+
|
||||
" <cb:unit>USD</cb:unit>\n"+
|
||||
" </cb:observation>\n"+
|
||||
" <cb:baseCurrency>AUD</cb:baseCurrency>\n"+
|
||||
" <cb:targetCurrency>USD</cb:targetCurrency>\n"+
|
||||
" </cb:exchangeRate>\n"+
|
||||
" </cb:statistics>\n"+
|
||||
" </item>\n"+
|
||||
"</rdf:RDF>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
|
||||
func TestReserveBankOfAustraliaDataSource_InvalidCurrency(t *testing.T) {
|
||||
dataSource := &ReserveBankOfAustraliaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"+
|
||||
"<rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\" xmlns:rba=\"https://www.rba.gov.au/statistics/frequency/exchange-rates.html\" xmlns:cb=\"http://www.cbwiki.net/wiki/index.php/Specification_1.2/\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:dcterms=\"http://purl.org/dc/terms/\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns=\"http://purl.org/rss/1.0/\" xsi:schemaLocation=\"http://www.w3.org/1999/02/22-rdf-syntax-ns# rdf.xsd\">\n"+
|
||||
" <channel rdf:about=\"https://www.rba.gov.au/rss/rss-cb-exchange-rates.xml\">\n"+
|
||||
" <dc:date>2021-04-01T16:45:00+11:00</dc:date>\n"+
|
||||
" </channel>\n"+
|
||||
" <item rdf:about=\"https://www.rba.gov.au/statistics/frequency/exchange-rates.html#USD\">\n"+
|
||||
" <cb:statistics rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:exchangeRate rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:observation rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:value>1</cb:value>\n"+
|
||||
" <cb:unit>AUD</cb:unit>\n"+
|
||||
" </cb:observation>\n"+
|
||||
" <cb:baseCurrency>AUD</cb:baseCurrency>\n"+
|
||||
" <cb:targetCurrency>XXX</cb:targetCurrency>\n"+
|
||||
" </cb:exchangeRate>\n"+
|
||||
" </cb:statistics>\n"+
|
||||
" </item>\n"+
|
||||
"</rdf:RDF>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
|
||||
func TestReserveBankOfAustraliaDataSource_EmptyRate(t *testing.T) {
|
||||
dataSource := &ReserveBankOfAustraliaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"+
|
||||
"<rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\" xmlns:rba=\"https://www.rba.gov.au/statistics/frequency/exchange-rates.html\" xmlns:cb=\"http://www.cbwiki.net/wiki/index.php/Specification_1.2/\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:dcterms=\"http://purl.org/dc/terms/\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns=\"http://purl.org/rss/1.0/\" xsi:schemaLocation=\"http://www.w3.org/1999/02/22-rdf-syntax-ns# rdf.xsd\">\n"+
|
||||
" <channel rdf:about=\"https://www.rba.gov.au/rss/rss-cb-exchange-rates.xml\">\n"+
|
||||
" <dc:date>2021-04-01T16:45:00+11:00</dc:date>\n"+
|
||||
" </channel>\n"+
|
||||
" <item rdf:about=\"https://www.rba.gov.au/statistics/frequency/exchange-rates.html#USD\">\n"+
|
||||
" <cb:statistics rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:exchangeRate rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:observation rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:value></cb:value>\n"+
|
||||
" <cb:unit>AUD</cb:unit>\n"+
|
||||
" </cb:observation>\n"+
|
||||
" <cb:baseCurrency>AUD</cb:baseCurrency>\n"+
|
||||
" <cb:targetCurrency>USD</cb:targetCurrency>\n"+
|
||||
" </cb:exchangeRate>\n"+
|
||||
" </cb:statistics>\n"+
|
||||
" </item>\n"+
|
||||
"</rdf:RDF>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
|
||||
func TestReserveBankOfAustraliaDataSource_InvalidRate(t *testing.T) {
|
||||
dataSource := &ReserveBankOfAustraliaDataSource{}
|
||||
context := core.NewNullContext()
|
||||
|
||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"+
|
||||
"<rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\" xmlns:rba=\"https://www.rba.gov.au/statistics/frequency/exchange-rates.html\" xmlns:cb=\"http://www.cbwiki.net/wiki/index.php/Specification_1.2/\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:dcterms=\"http://purl.org/dc/terms/\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns=\"http://purl.org/rss/1.0/\" xsi:schemaLocation=\"http://www.w3.org/1999/02/22-rdf-syntax-ns# rdf.xsd\">\n"+
|
||||
" <channel rdf:about=\"https://www.rba.gov.au/rss/rss-cb-exchange-rates.xml\">\n"+
|
||||
" <dc:date>2021-04-01T16:45:00+11:00</dc:date>\n"+
|
||||
" </channel>\n"+
|
||||
" <item rdf:about=\"https://www.rba.gov.au/statistics/frequency/exchange-rates.html#USD\">\n"+
|
||||
" <cb:statistics rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:exchangeRate rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:observation rdf:parseType=\"Resource\">\n"+
|
||||
" <cb:value>null</cb:value>\n"+
|
||||
" <cb:unit>AUD</cb:unit>\n"+
|
||||
" </cb:observation>\n"+
|
||||
" <cb:baseCurrency>AUD</cb:baseCurrency>\n"+
|
||||
" <cb:targetCurrency>USD</cb:targetCurrency>\n"+
|
||||
" </cb:exchangeRate>\n"+
|
||||
" </cb:statistics>\n"+
|
||||
" </item>\n"+
|
||||
"</rdf:RDF>"))
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
||||
}
|
||||
@@ -5,7 +5,9 @@ import (
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/llm/data"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/llm/provider"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/llm/provider/anthropic"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/llm/provider/googleai"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/llm/provider/lmstudio"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/llm/provider/ollama"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/llm/provider/openai"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||
@@ -41,10 +43,16 @@ func initializeLargeLanguageModelProvider(llmConfig *settings.LLMConfig, enableR
|
||||
return openai.NewOpenAILargeLanguageModelProvider(llmConfig, enableResponseLog), nil
|
||||
} else if llmConfig.LLMProvider == settings.OpenAICompatibleLLMProvider {
|
||||
return openai.NewOpenAICompatibleLargeLanguageModelProvider(llmConfig, enableResponseLog), nil
|
||||
} else if llmConfig.LLMProvider == settings.AnthropicLLMProvider {
|
||||
return anthropic.NewAnthropicLargeLanguageModelProvider(llmConfig, enableResponseLog), nil
|
||||
} else if llmConfig.LLMProvider == settings.AnthropicCompatibleLLMProvider {
|
||||
return anthropic.NewAnthropicCompatibleLargeLanguageModelProvider(llmConfig, enableResponseLog), nil
|
||||
} else if llmConfig.LLMProvider == settings.OpenRouterLLMProvider {
|
||||
return openai.NewOpenRouterLargeLanguageModelProvider(llmConfig, enableResponseLog), nil
|
||||
} else if llmConfig.LLMProvider == settings.OllamaLLMProvider {
|
||||
return ollama.NewOllamaLargeLanguageModelProvider(llmConfig, enableResponseLog), nil
|
||||
} else if llmConfig.LLMProvider == settings.LMStudioLLMProvider {
|
||||
return lmstudio.NewLMStudioLargeLanguageModelProvider(llmConfig, enableResponseLog), nil
|
||||
} else if llmConfig.LLMProvider == settings.GoogleAILLMProvider {
|
||||
return googleai.NewGoogleAILargeLanguageModelProvider(llmConfig, enableResponseLog), nil
|
||||
} else if llmConfig.LLMProvider == "" {
|
||||
|
||||
+196
@@ -0,0 +1,196 @@
|
||||
package anthropic
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/llm/data"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/llm/provider"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/llm/provider/common"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||
)
|
||||
|
||||
// AnthropicMessagesAPIProvider defines the structure of Anthropic messages API provider
|
||||
type AnthropicMessagesAPIProvider interface {
|
||||
// BuildMessagesHttpRequest returns the messages http request
|
||||
BuildMessagesHttpRequest(c core.Context, uid int64) (*http.Request, error)
|
||||
|
||||
// GetModelID returns the model id
|
||||
GetModelID() string
|
||||
|
||||
// GetMaxTokens returns the max tokens to generate
|
||||
GetMaxTokens() uint32
|
||||
}
|
||||
|
||||
// CommonAnthropicMessagesAPILargeLanguageModelAdapter defines the structure of Anthropic common compatible large language model adapter based on messages api
|
||||
type CommonAnthropicMessagesAPILargeLanguageModelAdapter struct {
|
||||
common.HttpLargeLanguageModelAdapter
|
||||
apiProvider AnthropicMessagesAPIProvider
|
||||
}
|
||||
|
||||
// AnthropicMessageRole defines the role of Anthropic message
|
||||
type AnthropicMessageRole string
|
||||
|
||||
// Anthropic Message Roles
|
||||
const (
|
||||
AnthropicMessageRoleUser AnthropicMessageRole = "user"
|
||||
)
|
||||
|
||||
type AnthropicThinkingType string
|
||||
|
||||
// Anthropic Thinking Types
|
||||
const (
|
||||
AnthropicThinkingTypeDisabled AnthropicThinkingType = "disabled"
|
||||
)
|
||||
|
||||
// AnthropicMessagesRequest defines the structure of Anthropic messages request
|
||||
type AnthropicMessagesRequest struct {
|
||||
Model string `json:"model"`
|
||||
MaxTokens uint32 `json:"max_tokens"`
|
||||
Stream bool `json:"stream"`
|
||||
System string `json:"system,omitempty"`
|
||||
Messages []any `json:"messages"`
|
||||
Thinking *AnthropicMessagesRequestThinkingConfigParam `json:"thinking,omitempty"`
|
||||
}
|
||||
|
||||
// AnthropicMessagesRequestMessage defines the structure of Anthropic messages request message
|
||||
type AnthropicMessagesRequestMessage[T string | []*AnthropicMessagesRequestImageBlockParam] struct {
|
||||
Role AnthropicMessageRole `json:"role"`
|
||||
Content T `json:"content"`
|
||||
}
|
||||
|
||||
// AnthropicMessagesRequestImageBlockParam defines the structure of Anthropic messages request image content block param
|
||||
type AnthropicMessagesRequestImageBlockParam struct {
|
||||
Source *AnthropicMessagesRequestBase64ImageSource `json:"source"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
// AnthropicMessagesRequestBase64ImageSource defines the structure of Anthropic messages request base64 image source
|
||||
type AnthropicMessagesRequestBase64ImageSource struct {
|
||||
Data string `json:"data"`
|
||||
MediaType string `json:"media_type"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
// AnthropicMessagesRequestThinkingConfigParam defines the structure of Anthropic messages request thinking config param
|
||||
type AnthropicMessagesRequestThinkingConfigParam struct {
|
||||
Type AnthropicThinkingType `json:"type"`
|
||||
}
|
||||
|
||||
// AnthropicMessagesResponse defines the structure of Anthropic messages response
|
||||
type AnthropicMessagesResponse struct {
|
||||
Content []*AnthropicMessagesResponseContentBlock `json:"content"`
|
||||
}
|
||||
|
||||
// AnthropicMessagesResponseContentBlock defines the structure of Anthropic messages response content block
|
||||
type AnthropicMessagesResponseContentBlock struct {
|
||||
Text *string `json:"text"`
|
||||
}
|
||||
|
||||
// BuildTextualRequest returns the http request by Anthropic common compatible adapter
|
||||
func (p *CommonAnthropicMessagesAPILargeLanguageModelAdapter) BuildTextualRequest(c core.Context, uid int64, request *data.LargeLanguageModelRequest, responseType data.LargeLanguageModelResponseFormat) (*http.Request, error) {
|
||||
requestBody, err := p.buildJsonRequestBody(c, uid, request, responseType)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
httpRequest, err := p.apiProvider.BuildMessagesHttpRequest(c, uid)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
httpRequest.Body = io.NopCloser(bytes.NewReader(requestBody))
|
||||
httpRequest.Header.Set("Content-Type", "application/json")
|
||||
|
||||
return httpRequest, nil
|
||||
}
|
||||
|
||||
// ParseTextualResponse returns the textual response by Anthropic common compatible adapter
|
||||
func (p *CommonAnthropicMessagesAPILargeLanguageModelAdapter) ParseTextualResponse(c core.Context, uid int64, body []byte, responseType data.LargeLanguageModelResponseFormat) (*data.LargeLanguageModelTextualResponse, error) {
|
||||
messagesResponse := &AnthropicMessagesResponse{}
|
||||
err := json.Unmarshal(body, &messagesResponse)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[anthropic_common_compatible_large_language_model_adapter.ParseTextualResponse] failed to parse messages response for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
if messagesResponse == nil || messagesResponse.Content == nil || len(messagesResponse.Content) < 1 || messagesResponse.Content[0].Text == nil {
|
||||
log.Errorf(c, "[anthropic_common_compatible_large_language_model_adapter.ParseTextualResponse] messages response is invalid for user \"uid:%d\"", uid)
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
textualResponse := &data.LargeLanguageModelTextualResponse{
|
||||
Content: *messagesResponse.Content[0].Text,
|
||||
}
|
||||
|
||||
return textualResponse, nil
|
||||
}
|
||||
|
||||
func (p *CommonAnthropicMessagesAPILargeLanguageModelAdapter) buildJsonRequestBody(c core.Context, uid int64, request *data.LargeLanguageModelRequest, responseType data.LargeLanguageModelResponseFormat) ([]byte, error) {
|
||||
if p.apiProvider.GetModelID() == "" {
|
||||
return nil, errs.ErrInvalidLLMModelId
|
||||
}
|
||||
|
||||
messagesRequest := &AnthropicMessagesRequest{
|
||||
Model: p.apiProvider.GetModelID(),
|
||||
MaxTokens: p.apiProvider.GetMaxTokens(),
|
||||
Stream: request.Stream,
|
||||
Messages: make([]any, 0, 1),
|
||||
Thinking: &AnthropicMessagesRequestThinkingConfigParam{
|
||||
Type: AnthropicThinkingTypeDisabled,
|
||||
},
|
||||
}
|
||||
|
||||
if request.SystemPrompt != "" {
|
||||
messagesRequest.System = request.SystemPrompt
|
||||
}
|
||||
|
||||
if len(request.UserPrompt) > 0 {
|
||||
if request.UserPromptType == data.LARGE_LANGUAGE_MODEL_REQUEST_PROMPT_TYPE_IMAGE_URL {
|
||||
imageBase64Data := base64.StdEncoding.EncodeToString(request.UserPrompt)
|
||||
messagesRequest.Messages = append(messagesRequest.Messages, &AnthropicMessagesRequestMessage[[]*AnthropicMessagesRequestImageBlockParam]{
|
||||
Role: AnthropicMessageRoleUser,
|
||||
Content: []*AnthropicMessagesRequestImageBlockParam{
|
||||
{
|
||||
Type: "image",
|
||||
Source: &AnthropicMessagesRequestBase64ImageSource{
|
||||
Data: imageBase64Data,
|
||||
MediaType: request.UserPromptContentType,
|
||||
Type: "base64",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
} else {
|
||||
messagesRequest.Messages = append(messagesRequest.Messages, &AnthropicMessagesRequestMessage[string]{
|
||||
Role: AnthropicMessageRoleUser,
|
||||
Content: string(request.UserPrompt),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
requestBodyBytes, err := json.Marshal(messagesRequest)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[anthropic_common_compatible_large_language_model_adapter.buildJsonRequestBody] failed to marshal request body for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.ErrOperationFailed
|
||||
}
|
||||
|
||||
log.Debugf(c, "[anthropic_common_compatible_large_language_model_adapter.buildJsonRequestBody] request body is %s", requestBodyBytes)
|
||||
return requestBodyBytes, nil
|
||||
}
|
||||
|
||||
func newCommonAnthropicMessagesAPILargeLanguageModelAdapter(llmConfig *settings.LLMConfig, enableResponseLog bool, apiProvider AnthropicMessagesAPIProvider) provider.LargeLanguageModelProvider {
|
||||
return common.NewCommonHttpLargeLanguageModelProvider(llmConfig, enableResponseLog, &CommonAnthropicMessagesAPILargeLanguageModelAdapter{
|
||||
apiProvider: apiProvider,
|
||||
})
|
||||
}
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
package anthropic
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/llm/data"
|
||||
)
|
||||
|
||||
func TestCommonAnthropicMessagesAPILargeLanguageModelAdapter_buildJsonRequestBody_TextualUserPrompt(t *testing.T) {
|
||||
adapter := &CommonAnthropicMessagesAPILargeLanguageModelAdapter{
|
||||
apiProvider: &AnthropicOfficialMessagesAPIProvider{
|
||||
AnthropicModelID: "test",
|
||||
AnthropicMaxTokens: 128,
|
||||
},
|
||||
}
|
||||
|
||||
request := &data.LargeLanguageModelRequest{
|
||||
SystemPrompt: "You are a helpful assistant.",
|
||||
UserPrompt: []byte("Hello, how are you?"),
|
||||
}
|
||||
|
||||
bodyBytes, err := adapter.buildJsonRequestBody(core.NewNullContext(), 0, request, data.LARGE_LANGUAGE_MODEL_RESPONSE_FORMAT_JSON)
|
||||
assert.Nil(t, err)
|
||||
|
||||
var body map[string]interface{}
|
||||
err = json.Unmarshal(bodyBytes, &body)
|
||||
assert.Nil(t, err)
|
||||
|
||||
assert.Equal(t, "{\"model\":\"test\",\"max_tokens\":128,\"stream\":false,\"system\":\"You are a helpful assistant.\",\"messages\":[{\"role\":\"user\",\"content\":\"Hello, how are you?\"}],\"thinking\":{\"type\":\"disabled\"}}", string(bodyBytes))
|
||||
}
|
||||
|
||||
func TestCommonAnthropicMessagesAPILargeLanguageModelAdapter_buildJsonRequestBody_ImageUserPrompt(t *testing.T) {
|
||||
adapter := &CommonAnthropicMessagesAPILargeLanguageModelAdapter{
|
||||
apiProvider: &AnthropicOfficialMessagesAPIProvider{
|
||||
AnthropicModelID: "test",
|
||||
AnthropicMaxTokens: 128,
|
||||
},
|
||||
}
|
||||
|
||||
request := &data.LargeLanguageModelRequest{
|
||||
SystemPrompt: "What's in this image?",
|
||||
UserPrompt: []byte("fakedata"),
|
||||
UserPromptType: data.LARGE_LANGUAGE_MODEL_REQUEST_PROMPT_TYPE_IMAGE_URL,
|
||||
UserPromptContentType: "image/png",
|
||||
}
|
||||
|
||||
bodyBytes, err := adapter.buildJsonRequestBody(core.NewNullContext(), 0, request, data.LARGE_LANGUAGE_MODEL_RESPONSE_FORMAT_JSON)
|
||||
assert.Nil(t, err)
|
||||
|
||||
var body map[string]interface{}
|
||||
err = json.Unmarshal(bodyBytes, &body)
|
||||
assert.Nil(t, err)
|
||||
|
||||
assert.Equal(t, "{\"model\":\"test\",\"max_tokens\":128,\"stream\":false,\"system\":\"What's in this image?\",\"messages\":[{\"role\":\"user\",\"content\":[{\"source\":{\"data\":\"ZmFrZWRhdGE=\",\"media_type\":\"image/png\",\"type\":\"base64\"},\"type\":\"image\"}]}],\"thinking\":{\"type\":\"disabled\"}}", string(bodyBytes))
|
||||
}
|
||||
|
||||
func TestCommonAnthropicMessagesAPILargeLanguageModelAdapter_ParseTextualResponse_ValidJsonResponse(t *testing.T) {
|
||||
adapter := &CommonAnthropicMessagesAPILargeLanguageModelAdapter{
|
||||
apiProvider: &AnthropicOfficialMessagesAPIProvider{},
|
||||
}
|
||||
|
||||
response := `{
|
||||
"id": "test-123",
|
||||
"role": "assistant",
|
||||
"type": "message",
|
||||
"model": "test",
|
||||
"usage": {
|
||||
"input_tokens": 13,
|
||||
"output_tokens": 7
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "This is a test response"
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
result, err := adapter.ParseTextualResponse(core.NewNullContext(), 0, []byte(response), data.LARGE_LANGUAGE_MODEL_RESPONSE_FORMAT_JSON)
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, "This is a test response", result.Content)
|
||||
}
|
||||
|
||||
func TestCommonAnthropicMessagesAPILargeLanguageModelAdapter_ParseTextualResponse_EmptyContentText(t *testing.T) {
|
||||
adapter := &CommonAnthropicMessagesAPILargeLanguageModelAdapter{
|
||||
apiProvider: &AnthropicOfficialMessagesAPIProvider{},
|
||||
}
|
||||
|
||||
response := `{
|
||||
"id": "test-123",
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": ""
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
result, err := adapter.ParseTextualResponse(core.NewNullContext(), 0, []byte(response), data.LARGE_LANGUAGE_MODEL_RESPONSE_FORMAT_JSON)
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, "", result.Content)
|
||||
}
|
||||
|
||||
func TestCommonAnthropicMessagesAPILargeLanguageModelAdapter_ParseTextualResponse_EmptyContent(t *testing.T) {
|
||||
adapter := &CommonAnthropicMessagesAPILargeLanguageModelAdapter{
|
||||
apiProvider: &AnthropicOfficialMessagesAPIProvider{},
|
||||
}
|
||||
|
||||
response := `{
|
||||
"id": "test-123",
|
||||
"role": "assistant",
|
||||
"content": []
|
||||
}`
|
||||
|
||||
_, err := adapter.ParseTextualResponse(core.NewNullContext(), 0, []byte(response), data.LARGE_LANGUAGE_MODEL_RESPONSE_FORMAT_JSON)
|
||||
assert.EqualError(t, err, "failed to request third party api")
|
||||
}
|
||||
|
||||
func TestCommonAnthropicMessagesAPILargeLanguageModelAdapter_ParseTextualResponse_NoContentText(t *testing.T) {
|
||||
adapter := &CommonAnthropicMessagesAPILargeLanguageModelAdapter{
|
||||
apiProvider: &AnthropicOfficialMessagesAPIProvider{},
|
||||
}
|
||||
|
||||
response := `{
|
||||
"id": "msg_123",
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
_, err := adapter.ParseTextualResponse(core.NewNullContext(), 0, []byte(response), data.LARGE_LANGUAGE_MODEL_RESPONSE_FORMAT_JSON)
|
||||
assert.EqualError(t, err, "failed to request third party api")
|
||||
}
|
||||
|
||||
func TestCommonAnthropicMessagesAPILargeLanguageModelAdapter_ParseTextualResponse_InvalidJson(t *testing.T) {
|
||||
adapter := &CommonAnthropicMessagesAPILargeLanguageModelAdapter{
|
||||
apiProvider: &AnthropicOfficialMessagesAPIProvider{},
|
||||
}
|
||||
|
||||
response := "error"
|
||||
|
||||
_, err := adapter.ParseTextualResponse(core.NewNullContext(), 0, []byte(response), data.LARGE_LANGUAGE_MODEL_RESPONSE_FORMAT_JSON)
|
||||
assert.EqualError(t, err, "failed to request third party api")
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package anthropic
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/llm/provider"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||
)
|
||||
|
||||
const anthropicCompatibleMessagesPath = "messages"
|
||||
|
||||
// AnthropicCompatibleMessagesAPIProvider defines the structure of Anthropic compatible messages API provider
|
||||
type AnthropicCompatibleMessagesAPIProvider struct {
|
||||
AnthropicMessagesAPIProvider
|
||||
AnthropicCompatibleBaseURL string
|
||||
AnthropicCompatibleAPIVersion string
|
||||
AnthropicCompatibleAPIKey string
|
||||
AnthropicCompatibleModelID string
|
||||
AnthropicCompatibleMaxTokens uint32
|
||||
}
|
||||
|
||||
// BuildMessagesHttpRequest returns the messages http request by Anthropic compatible messages API provider
|
||||
func (p *AnthropicCompatibleMessagesAPIProvider) BuildMessagesHttpRequest(c core.Context, uid int64) (*http.Request, error) {
|
||||
req, err := http.NewRequest("POST", p.getFinalMessagesRequestUrl(), nil)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if p.AnthropicCompatibleAPIVersion != "" {
|
||||
req.Header.Set("anthropic-version", p.AnthropicCompatibleAPIVersion)
|
||||
}
|
||||
|
||||
if p.AnthropicCompatibleAPIKey != "" {
|
||||
req.Header.Set("X-Api-Key", p.AnthropicCompatibleAPIKey)
|
||||
}
|
||||
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// GetModelID returns the model id of Anthropic compatible messages API provider
|
||||
func (p *AnthropicCompatibleMessagesAPIProvider) GetModelID() string {
|
||||
return p.AnthropicCompatibleModelID
|
||||
}
|
||||
|
||||
// GetMaxTokens returns the max tokens to generate of Anthropic compatible messages API provider
|
||||
func (p *AnthropicCompatibleMessagesAPIProvider) GetMaxTokens() uint32 {
|
||||
return p.AnthropicCompatibleMaxTokens
|
||||
}
|
||||
|
||||
func (p *AnthropicCompatibleMessagesAPIProvider) getFinalMessagesRequestUrl() string {
|
||||
url := p.AnthropicCompatibleBaseURL
|
||||
|
||||
if url[len(url)-1] != '/' {
|
||||
url += "/"
|
||||
}
|
||||
|
||||
url += anthropicCompatibleMessagesPath
|
||||
return url
|
||||
}
|
||||
|
||||
// NewAnthropicCompatibleLargeLanguageModelProvider creates a new Anthropic compatible large language model provider instance
|
||||
func NewAnthropicCompatibleLargeLanguageModelProvider(llmConfig *settings.LLMConfig, enableResponseLog bool) provider.LargeLanguageModelProvider {
|
||||
return newCommonAnthropicMessagesAPILargeLanguageModelAdapter(llmConfig, enableResponseLog, &AnthropicCompatibleMessagesAPIProvider{
|
||||
AnthropicCompatibleBaseURL: llmConfig.AnthropicCompatibleBaseURL,
|
||||
AnthropicCompatibleAPIVersion: llmConfig.AnthropicCompatibleAPIVersion,
|
||||
AnthropicCompatibleAPIKey: llmConfig.AnthropicCompatibleAPIKey,
|
||||
AnthropicCompatibleModelID: llmConfig.AnthropicCompatibleModelID,
|
||||
AnthropicCompatibleMaxTokens: llmConfig.AnthropicCompatibleMaxTokens,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package anthropic
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestAnthropicCompatibleMessagesAPIProvider_GetFinalRequestUrl(t *testing.T) {
|
||||
apiProvider := &AnthropicCompatibleMessagesAPIProvider{
|
||||
AnthropicCompatibleBaseURL: "https://api.example.com/v1/",
|
||||
}
|
||||
url := apiProvider.getFinalMessagesRequestUrl()
|
||||
assert.Equal(t, "https://api.example.com/v1/messages", url)
|
||||
|
||||
apiProvider = &AnthropicCompatibleMessagesAPIProvider{
|
||||
AnthropicCompatibleBaseURL: "https://api.example.com/v1",
|
||||
}
|
||||
url = apiProvider.getFinalMessagesRequestUrl()
|
||||
assert.Equal(t, "https://api.example.com/v1/messages", url)
|
||||
|
||||
apiProvider = &AnthropicCompatibleMessagesAPIProvider{
|
||||
AnthropicCompatibleBaseURL: "https://example.com/api",
|
||||
}
|
||||
url = apiProvider.getFinalMessagesRequestUrl()
|
||||
assert.Equal(t, "https://example.com/api/messages", url)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package anthropic
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/llm/provider"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||
)
|
||||
|
||||
// AnthropicOfficialMessagesAPIProvider defines the structure of Anthropic official messages API provider
|
||||
type AnthropicOfficialMessagesAPIProvider struct {
|
||||
AnthropicMessagesAPIProvider
|
||||
AnthropicAPIKey string
|
||||
AnthropicModelID string
|
||||
AnthropicMaxTokens uint32
|
||||
}
|
||||
|
||||
const anthropicMessagesUrl = "https://api.anthropic.com/v1/messages"
|
||||
const anthropicAPIVersion = "2023-06-01"
|
||||
|
||||
// BuildMessagesHttpRequest returns the messages http request by Anthropic official messages API provider
|
||||
func (p *AnthropicOfficialMessagesAPIProvider) BuildMessagesHttpRequest(c core.Context, uid int64) (*http.Request, error) {
|
||||
req, err := http.NewRequest("POST", anthropicMessagesUrl, nil)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("anthropic-version", anthropicAPIVersion)
|
||||
req.Header.Set("X-Api-Key", p.AnthropicAPIKey)
|
||||
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// GetModelID returns the model id of Anthropic official messages API provider
|
||||
func (p *AnthropicOfficialMessagesAPIProvider) GetModelID() string {
|
||||
return p.AnthropicModelID
|
||||
}
|
||||
|
||||
// GetMaxTokens returns the max tokens to generate of Anthropic official messages API provider
|
||||
func (p *AnthropicOfficialMessagesAPIProvider) GetMaxTokens() uint32 {
|
||||
return p.AnthropicMaxTokens
|
||||
}
|
||||
|
||||
// NewAnthropicLargeLanguageModelProvider creates a new Anthropic large language model provider instance
|
||||
func NewAnthropicLargeLanguageModelProvider(llmConfig *settings.LLMConfig, enableResponseLog bool) provider.LargeLanguageModelProvider {
|
||||
return newCommonAnthropicMessagesAPILargeLanguageModelAdapter(llmConfig, enableResponseLog, &AnthropicOfficialMessagesAPIProvider{
|
||||
AnthropicAPIKey: llmConfig.AnthropicAPIKey,
|
||||
AnthropicModelID: llmConfig.AnthropicModelID,
|
||||
AnthropicMaxTokens: llmConfig.AnthropicMaxTokens,
|
||||
})
|
||||
}
|
||||
@@ -83,6 +83,6 @@ func (p *CommonHttpLargeLanguageModelProvider) getTextualResponse(c core.Context
|
||||
func NewCommonHttpLargeLanguageModelProvider(llmConfig *settings.LLMConfig, enableResponseLog bool, adapter HttpLargeLanguageModelAdapter) *CommonHttpLargeLanguageModelProvider {
|
||||
return &CommonHttpLargeLanguageModelProvider{
|
||||
adapter: adapter,
|
||||
httpClient: httpclient.NewHttpClient(llmConfig.LargeLanguageModelAPIRequestTimeout, llmConfig.LargeLanguageModelAPIProxy, llmConfig.LargeLanguageModelAPISkipTLSVerify, settings.GetUserAgent(), enableResponseLog),
|
||||
httpClient: httpclient.NewHttpClient(llmConfig.LargeLanguageModelAPIRequestTimeout, llmConfig.LargeLanguageModelAPIProxy, llmConfig.LargeLanguageModelAPISkipTLSVerify, core.GetOutgoingUserAgent(), enableResponseLog),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
package lmstudio
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/llm/data"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/llm/provider"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/llm/provider/common"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||
)
|
||||
|
||||
const lmStudioChatPath = "api/v1/chat"
|
||||
|
||||
// LMStudioLargeLanguageModelAdapter defines the structure of LM Studio large language model adapter
|
||||
type LMStudioLargeLanguageModelAdapter struct {
|
||||
common.HttpLargeLanguageModelAdapter
|
||||
LMStudioServerURL string
|
||||
LMStudioToken string
|
||||
LMStudioModelID string
|
||||
}
|
||||
|
||||
// LMStudioChatRequest defines the structure of LM Studio chat request
|
||||
type LMStudioChatRequest struct {
|
||||
Model string `json:"model"`
|
||||
Stream bool `json:"stream"`
|
||||
SystemPrompt string `json:"system_prompt,omitempty"`
|
||||
Input []*LMStudioChatRequestInput `json:"input"`
|
||||
}
|
||||
|
||||
// LMStudioChatRequestInput defines the structure of LM Studio chat request message
|
||||
type LMStudioChatRequestInput struct {
|
||||
Type string `json:"type"`
|
||||
Content string `json:"content,omitempty"`
|
||||
DataUrl string `json:"data_url,omitempty"`
|
||||
}
|
||||
|
||||
// LMStudioChatResponse defines the structure of LM Studio chat response
|
||||
type LMStudioChatResponse struct {
|
||||
Output []*LMStudioChatResponseOutput `json:"output"`
|
||||
}
|
||||
|
||||
// LMStudioChatResponseOutput defines the structure of LM Studio chat response message
|
||||
type LMStudioChatResponseOutput struct {
|
||||
Content *string `json:"content"`
|
||||
}
|
||||
|
||||
// BuildTextualRequest returns the http request by LM Studio large language model adapter
|
||||
func (p *LMStudioLargeLanguageModelAdapter) BuildTextualRequest(c core.Context, uid int64, request *data.LargeLanguageModelRequest, responseType data.LargeLanguageModelResponseFormat) (*http.Request, error) {
|
||||
requestBody, err := p.buildJsonRequestBody(c, uid, request, responseType)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
httpRequest, err := http.NewRequest("POST", p.getLMStudioRequestUrl(), bytes.NewReader(requestBody))
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if p.LMStudioToken != "" {
|
||||
httpRequest.Header.Set("Authorization", "Bearer "+p.LMStudioToken)
|
||||
}
|
||||
|
||||
httpRequest.Header.Set("Content-Type", "application/json")
|
||||
|
||||
return httpRequest, nil
|
||||
}
|
||||
|
||||
// ParseTextualResponse returns the textual response by LM Studio large language model adapter
|
||||
func (p *LMStudioLargeLanguageModelAdapter) ParseTextualResponse(c core.Context, uid int64, body []byte, responseType data.LargeLanguageModelResponseFormat) (*data.LargeLanguageModelTextualResponse, error) {
|
||||
chatResponse := &LMStudioChatResponse{}
|
||||
err := json.Unmarshal(body, &chatResponse)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[lm_studio_large_language_model_adapter.ParseTextualResponse] failed to parse chat response for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
if chatResponse == nil || len(chatResponse.Output) < 1 || chatResponse.Output[0].Content == nil {
|
||||
log.Errorf(c, "[lm_studio_large_language_model_adapter.ParseTextualResponse] chat response is invalid for user \"uid:%d\"", uid)
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
textualResponse := &data.LargeLanguageModelTextualResponse{
|
||||
Content: *chatResponse.Output[0].Content,
|
||||
}
|
||||
|
||||
return textualResponse, nil
|
||||
}
|
||||
|
||||
func (p *LMStudioLargeLanguageModelAdapter) buildJsonRequestBody(c core.Context, uid int64, request *data.LargeLanguageModelRequest, responseType data.LargeLanguageModelResponseFormat) ([]byte, error) {
|
||||
if p.LMStudioModelID == "" {
|
||||
return nil, errs.ErrInvalidLLMModelId
|
||||
}
|
||||
|
||||
chatRequest := &LMStudioChatRequest{
|
||||
Model: p.LMStudioModelID,
|
||||
Stream: request.Stream,
|
||||
Input: make([]*LMStudioChatRequestInput, 0, 1),
|
||||
}
|
||||
|
||||
if request.SystemPrompt != "" {
|
||||
chatRequest.SystemPrompt = request.SystemPrompt
|
||||
}
|
||||
|
||||
if len(request.UserPrompt) > 0 {
|
||||
if request.UserPromptType == data.LARGE_LANGUAGE_MODEL_REQUEST_PROMPT_TYPE_IMAGE_URL {
|
||||
imageBase64Data := "data:" + request.UserPromptContentType + ";base64," + base64.StdEncoding.EncodeToString(request.UserPrompt)
|
||||
chatRequest.Input = append(chatRequest.Input, &LMStudioChatRequestInput{
|
||||
Type: "image",
|
||||
DataUrl: imageBase64Data,
|
||||
})
|
||||
} else {
|
||||
chatRequest.Input = append(chatRequest.Input, &LMStudioChatRequestInput{
|
||||
Type: "text",
|
||||
Content: string(request.UserPrompt),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
requestBodyBytes, err := json.Marshal(chatRequest)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[lm_studio_large_language_model_adapter.buildJsonRequestBody] failed to marshal request body for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.ErrOperationFailed
|
||||
}
|
||||
|
||||
log.Debugf(c, "[lm_studio_large_language_model_adapter.buildJsonRequestBody] request body is %s", requestBodyBytes)
|
||||
return requestBodyBytes, nil
|
||||
}
|
||||
|
||||
func (p *LMStudioLargeLanguageModelAdapter) getLMStudioRequestUrl() string {
|
||||
url := p.LMStudioServerURL
|
||||
|
||||
if url[len(url)-1] != '/' {
|
||||
url += "/"
|
||||
}
|
||||
|
||||
url += lmStudioChatPath
|
||||
return url
|
||||
}
|
||||
|
||||
// NewLMStudioLargeLanguageModelProvider creates a new LM Studio large language model provider instance
|
||||
func NewLMStudioLargeLanguageModelProvider(llmConfig *settings.LLMConfig, enableResponseLog bool) provider.LargeLanguageModelProvider {
|
||||
return common.NewCommonHttpLargeLanguageModelProvider(llmConfig, enableResponseLog, &LMStudioLargeLanguageModelAdapter{
|
||||
LMStudioServerURL: llmConfig.LMStudioServerURL,
|
||||
LMStudioToken: llmConfig.LMStudioToken,
|
||||
LMStudioModelID: llmConfig.LMStudioModelID,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package lmstudio
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/llm/data"
|
||||
)
|
||||
|
||||
func TestLMStudioLargeLanguageModelAdapter_buildJsonRequestBody_TextualUserPrompt(t *testing.T) {
|
||||
adapter := &LMStudioLargeLanguageModelAdapter{
|
||||
LMStudioModelID: "test",
|
||||
}
|
||||
|
||||
request := &data.LargeLanguageModelRequest{
|
||||
SystemPrompt: "You are a helpful assistant.",
|
||||
UserPrompt: []byte("Hello, how are you?"),
|
||||
}
|
||||
|
||||
bodyBytes, err := adapter.buildJsonRequestBody(core.NewNullContext(), 0, request, data.LARGE_LANGUAGE_MODEL_RESPONSE_FORMAT_JSON)
|
||||
assert.Nil(t, err)
|
||||
|
||||
var body map[string]interface{}
|
||||
err = json.Unmarshal(bodyBytes, &body)
|
||||
assert.Nil(t, err)
|
||||
|
||||
assert.Equal(t, "{\"model\":\"test\",\"stream\":false,\"system_prompt\":\"You are a helpful assistant.\",\"input\":[{\"type\":\"text\",\"content\":\"Hello, how are you?\"}]}", string(bodyBytes))
|
||||
}
|
||||
|
||||
func TestLMStudioLargeLanguageModelAdapter_buildJsonRequestBody_ImageUserPrompt(t *testing.T) {
|
||||
adapter := &LMStudioLargeLanguageModelAdapter{
|
||||
LMStudioModelID: "test",
|
||||
}
|
||||
|
||||
request := &data.LargeLanguageModelRequest{
|
||||
SystemPrompt: "What's in this image?",
|
||||
UserPrompt: []byte("fakedata"),
|
||||
UserPromptType: data.LARGE_LANGUAGE_MODEL_REQUEST_PROMPT_TYPE_IMAGE_URL,
|
||||
UserPromptContentType: "image/png",
|
||||
}
|
||||
|
||||
bodyBytes, err := adapter.buildJsonRequestBody(core.NewNullContext(), 0, request, data.LARGE_LANGUAGE_MODEL_RESPONSE_FORMAT_JSON)
|
||||
assert.Nil(t, err)
|
||||
|
||||
var body map[string]interface{}
|
||||
err = json.Unmarshal(bodyBytes, &body)
|
||||
assert.Nil(t, err)
|
||||
|
||||
assert.Equal(t, "{\"model\":\"test\",\"stream\":false,\"system_prompt\":\"What's in this image?\",\"input\":[{\"type\":\"image\",\"data_url\":\"data:image/png;base64,ZmFrZWRhdGE=\"}]}", string(bodyBytes))
|
||||
}
|
||||
|
||||
func TestLMStudioLargeLanguageModelAdapter_ParseTextualResponse_ValidJsonResponse(t *testing.T) {
|
||||
adapter := &LMStudioLargeLanguageModelAdapter{}
|
||||
|
||||
response := `{
|
||||
"model_instance_id": "test",
|
||||
"output": [
|
||||
{
|
||||
"type": "message",
|
||||
"content": "This is a test response"
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
result, err := adapter.ParseTextualResponse(core.NewNullContext(), 0, []byte(response), data.LARGE_LANGUAGE_MODEL_RESPONSE_FORMAT_JSON)
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, "This is a test response", result.Content)
|
||||
}
|
||||
|
||||
func TestLMStudioLargeLanguageModelAdapter_ParseTextualResponse_EmptyOutputContent(t *testing.T) {
|
||||
adapter := &LMStudioLargeLanguageModelAdapter{}
|
||||
|
||||
response := `{
|
||||
"model_instance_id": "test",
|
||||
"output": [
|
||||
{
|
||||
"type": "message",
|
||||
"content": ""
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
result, err := adapter.ParseTextualResponse(core.NewNullContext(), 0, []byte(response), data.LARGE_LANGUAGE_MODEL_RESPONSE_FORMAT_JSON)
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, "", result.Content)
|
||||
}
|
||||
|
||||
func TestLMStudioLargeLanguageModelAdapter_ParseTextualResponse_EmptyOutput(t *testing.T) {
|
||||
adapter := &LMStudioLargeLanguageModelAdapter{}
|
||||
|
||||
response := `{
|
||||
"model_instance_id": "test",
|
||||
"output": []
|
||||
}`
|
||||
|
||||
_, err := adapter.ParseTextualResponse(core.NewNullContext(), 0, []byte(response), data.LARGE_LANGUAGE_MODEL_RESPONSE_FORMAT_JSON)
|
||||
assert.EqualError(t, err, "failed to request third party api")
|
||||
}
|
||||
|
||||
func TestLMStudioLargeLanguageModelAdapter_ParseTextualResponse_NoContentFieldInOutput(t *testing.T) {
|
||||
adapter := &LMStudioLargeLanguageModelAdapter{}
|
||||
|
||||
response := `{
|
||||
"model_instance_id": "test",
|
||||
"output": [
|
||||
{
|
||||
"type": "message"
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
_, err := adapter.ParseTextualResponse(core.NewNullContext(), 0, []byte(response), data.LARGE_LANGUAGE_MODEL_RESPONSE_FORMAT_JSON)
|
||||
assert.EqualError(t, err, "failed to request third party api")
|
||||
}
|
||||
|
||||
func TestLMStudioLargeLanguageModelAdapter_ParseTextualResponse_InvalidJson(t *testing.T) {
|
||||
adapter := &LMStudioLargeLanguageModelAdapter{}
|
||||
|
||||
response := "error"
|
||||
|
||||
_, err := adapter.ParseTextualResponse(core.NewNullContext(), 0, []byte(response), data.LARGE_LANGUAGE_MODEL_RESPONSE_FORMAT_JSON)
|
||||
assert.EqualError(t, err, "failed to request third party api")
|
||||
}
|
||||
|
||||
func TestLMStudioLargeLanguageModelAdapter_GetOllamaRequestUrl(t *testing.T) {
|
||||
adapter := &LMStudioLargeLanguageModelAdapter{
|
||||
LMStudioServerURL: "http://localhost:1234/",
|
||||
}
|
||||
url := adapter.getLMStudioRequestUrl()
|
||||
assert.Equal(t, "http://localhost:1234/api/v1/chat", url)
|
||||
|
||||
adapter = &LMStudioLargeLanguageModelAdapter{
|
||||
LMStudioServerURL: "http://localhost:1234",
|
||||
}
|
||||
url = adapter.getLMStudioRequestUrl()
|
||||
assert.Equal(t, "http://localhost:1234/api/v1/chat", url)
|
||||
|
||||
adapter = &LMStudioLargeLanguageModelAdapter{
|
||||
LMStudioServerURL: "http://example.com/lmstudio/",
|
||||
}
|
||||
url = adapter.getLMStudioRequestUrl()
|
||||
assert.Equal(t, "http://example.com/lmstudio/api/v1/chat", url)
|
||||
}
|
||||
@@ -10,24 +10,24 @@ var ptBR = &LocaleTextItems{
|
||||
},
|
||||
DefaultTypes: &DefaultTypes{
|
||||
DecimalSeparator: core.DECIMAL_SEPARATOR_COMMA,
|
||||
DigitGroupingSymbol: core.DIGIT_GROUPING_SYMBOL_SPACE,
|
||||
DigitGroupingSymbol: core.DIGIT_GROUPING_SYMBOL_DOT,
|
||||
},
|
||||
DataConverterTextItems: &DataConverterTextItems{
|
||||
Alipay: "Alipay",
|
||||
WeChatWallet: "Wallet",
|
||||
},
|
||||
VerifyEmailTextItems: &VerifyEmailTextItems{
|
||||
Title: "Verificar Email",
|
||||
Title: "Verifique seu e-mail",
|
||||
SalutationFormat: "Olá %s,",
|
||||
DescriptionAboveBtn: "Por favor, clique no link abaixo para confirmar o seu endereço de e-mail.",
|
||||
VerifyEmail: "Verificar Email",
|
||||
DescriptionBelowBtnFormat: "Se você não se registrou para uma conta %s, basta ignorar este e-mail. Se não conseguir clicar no link acima, copie a URL acima e cole no seu navegador. O link para verificação de e-mail expirará após %v minutos.",
|
||||
DescriptionAboveBtn: "Clique no link abaixo para confirmar seu endereço de e-mail.",
|
||||
VerifyEmail: "Verificar e-mail",
|
||||
DescriptionBelowBtnFormat: "Se você não criou uma conta no %s, ignore este e-mail. Se não conseguir clicar no link acima, copie a URL e cole no navegador. O link de verificação de e-mail expira em %v minutos.",
|
||||
},
|
||||
ForgetPasswordMailTextItems: &ForgetPasswordMailTextItems{
|
||||
Title: "Redefinir Sua Senha",
|
||||
Title: "Redefina sua senha",
|
||||
SalutationFormat: "Olá %s,",
|
||||
DescriptionAboveBtn: "Recebemos recentemente uma solicitação para redefinir a sua senha. Você pode clicar no link abaixo para redefinir sua senha.",
|
||||
ResetPassword: "Redefinir Senha",
|
||||
DescriptionBelowBtnFormat: "Se você não solicitou a redefinição de senha, basta ignorar este e-mail. Se não conseguir clicar no link acima, copie a URL acima e cole no seu navegador. O link de redefinição de senha expirará após %v minutos.",
|
||||
DescriptionAboveBtn: "Recebemos recentemente uma solicitação para redefinir sua senha. Clique no link abaixo para redefini-la.",
|
||||
ResetPassword: "Redefinir senha",
|
||||
DescriptionBelowBtnFormat: "Se você não solicitou a redefinição da senha, ignore este e-mail. Se não conseguir clicar no link acima, copie a URL e cole no navegador. O link de redefinição de senha expira em %v minutos.",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -178,7 +178,7 @@ func (h *mcpAddTransactionToolHandler) Handle(c *core.WebContext, callToolReq *M
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
transactionEditable := user.CanEditTransactionByTransactionTime(transaction.TransactionTime, time.FixedZone("Transaction Timezone", int(transaction.TimezoneUtcOffset)*60))
|
||||
transactionEditable := user.CanEditTransactionByTransactionTime(transaction.TransactionTime, time.FixedZone("Transaction Timezone", int(transaction.TimezoneUtcOffset)*60), sourceAccount, destinationAccount)
|
||||
|
||||
if !transactionEditable {
|
||||
return nil, nil, errs.ErrCannotCreateTransactionWithThisTransactionTime
|
||||
|
||||
@@ -14,6 +14,8 @@ import (
|
||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||
)
|
||||
|
||||
const pageCountForLoadTransactions = 1000
|
||||
|
||||
// MCPQueryTransactionsRequest represents all parameters of the query transactions request
|
||||
type MCPQueryTransactionsRequest struct {
|
||||
StartTime string `json:"start_time" jsonschema:"format=date-time" jsonschema_description:"Start time for the query in RFC 3339 format (e.g. 2023-01-01T12:00:00Z)"`
|
||||
@@ -153,14 +155,14 @@ func (h *mcpQueryTransactionsToolHandler) Handle(c *core.WebContext, callToolReq
|
||||
}
|
||||
}
|
||||
|
||||
totalCount, err := services.GetTransactionService().GetTransactionCount(c, uid, maxTransactionTime, minTransactionTime, transactionType, filterCategoryIds, filterAccountIds, nil, false, "", queryTransactionsRequest.Keyword)
|
||||
totalCount, err := services.GetTransactionService().GetTransactionCount(c, uid, maxTransactionTime, minTransactionTime, transactionType, filterCategoryIds, filterAccountIds, nil, false, "", queryTransactionsRequest.Keyword, false)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.TransactionListHandler] failed to get transaction count for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
transactions, err := services.GetTransactionService().GetTransactionsByMaxTime(c, uid, maxTransactionTime, minTransactionTime, transactionType, filterCategoryIds, filterAccountIds, nil, false, "", queryTransactionsRequest.Keyword, queryTransactionsRequest.Page, queryTransactionsRequest.Count, false, true)
|
||||
transactions, err := services.GetTransactionService().GetTransactionsByMaxTimeUpToCount(c, uid, maxTransactionTime, minTransactionTime, transactionType, filterCategoryIds, filterAccountIds, nil, false, "", queryTransactionsRequest.Keyword, false, queryTransactionsRequest.Page, queryTransactionsRequest.Count, pageCountForLoadTransactions, false, true)
|
||||
structuredResponse, response, err := h.createNewMCPQueryTransactionsResponse(c, &queryTransactionsRequest, transactions, totalCount, services.GetAccountService().GetAccountMapByList(allAccounts), services.GetTransactionCategoryService().GetCategoryMapByList(allCategories))
|
||||
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package middlewares
|
||||
|
||||
import (
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||
)
|
||||
|
||||
// APITokenIpLimit limits API token access based on IP address
|
||||
func APITokenIpLimit(config *settings.Config) core.MiddlewareHandlerFunc {
|
||||
return func(c *core.WebContext) {
|
||||
claims := c.GetTokenClaims()
|
||||
|
||||
if claims == nil {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
if claims.Type != core.USER_TOKEN_TYPE_API {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
if len(config.APITokenAllowedRemoteIPs) < 1 {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
for i := 0; i < len(config.APITokenAllowedRemoteIPs); i++ {
|
||||
if config.APITokenAllowedRemoteIPs[i].Match(c.ClientIP()) {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
utils.PrintJsonErrorResult(c, errs.ErrIPForbidden)
|
||||
}
|
||||
}
|
||||
+25
-1
@@ -90,7 +90,8 @@ type Account struct {
|
||||
|
||||
// AccountExtend represents account extend data stored in database
|
||||
type AccountExtend struct {
|
||||
CreditCardStatementDate *int `json:"creditCardStatementDate"`
|
||||
LastReconciledTime *int64 `json:"lastReconciledTime"`
|
||||
CreditCardStatementDate *int `json:"creditCardStatementDate"`
|
||||
}
|
||||
|
||||
// AccountCreateRequest represents all parameters of account creation request
|
||||
@@ -119,6 +120,7 @@ type AccountModifyRequest struct {
|
||||
Currency *string `json:"currency" binding:"omitempty,len=3,validCurrency"`
|
||||
Balance *int64 `json:"balance" binding:"omitempty"`
|
||||
BalanceTime *int64 `json:"balanceTime" binding:"omitempty"`
|
||||
LastReconciledTime *int64 `json:"lastReconciledTime" binding:"omitempty"`
|
||||
Comment string `json:"comment" binding:"max=255"`
|
||||
CreditCardStatementDate int `json:"creditCardStatementDate" binding:"min=0,max=28"`
|
||||
Hidden bool `json:"hidden"`
|
||||
@@ -126,6 +128,12 @@ type AccountModifyRequest struct {
|
||||
ClientSessionId string `json:"clientSessionId"`
|
||||
}
|
||||
|
||||
// AccountUpdateLastReconciledTimeRequest represents all parameters of account updating last reconciled time request
|
||||
type AccountUpdateLastReconciledTimeRequest struct {
|
||||
Id int64 `json:"id,string" binding:"required,min=1"`
|
||||
LastReconciledTime int64 `json:"lastReconciledTime" binding:"required"`
|
||||
}
|
||||
|
||||
// AccountListRequest represents all parameters of account listing request
|
||||
type AccountListRequest struct {
|
||||
VisibleOnly bool `form:"visible_only"`
|
||||
@@ -169,6 +177,7 @@ type AccountInfoResponse struct {
|
||||
Color string `json:"color"`
|
||||
Currency string `json:"currency"`
|
||||
Balance int64 `json:"balance"`
|
||||
LastReconciledTime *int64 `json:"lastReconciledTime,omitempty"`
|
||||
Comment string `json:"comment"`
|
||||
CreditCardStatementDate *int `json:"creditCardStatementDate,omitempty"`
|
||||
DisplayOrder int32 `json:"displayOrder"`
|
||||
@@ -178,10 +187,24 @@ type AccountInfoResponse struct {
|
||||
SubAccounts AccountInfoResponseSlice `json:"subAccounts,omitempty"`
|
||||
}
|
||||
|
||||
// GetLastReconciledTime returns the last reconciled time of the account
|
||||
func (a *Account) GetLastReconciledTime() int64 {
|
||||
if a.Extend != nil && a.Extend.LastReconciledTime != nil {
|
||||
return *a.Extend.LastReconciledTime
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
// ToAccountInfoResponse returns a view-object according to database model
|
||||
func (a *Account) ToAccountInfoResponse() *AccountInfoResponse {
|
||||
var lastReconciledTime *int64
|
||||
var creditCardStatementDate *int
|
||||
|
||||
if a.Extend != nil {
|
||||
lastReconciledTime = a.Extend.LastReconciledTime
|
||||
}
|
||||
|
||||
if a.ParentAccountId == LevelOneAccountParentId && a.Category == ACCOUNT_CATEGORY_CREDIT_CARD {
|
||||
if a.Extend != nil {
|
||||
creditCardStatementDate = a.Extend.CreditCardStatementDate
|
||||
@@ -201,6 +224,7 @@ func (a *Account) ToAccountInfoResponse() *AccountInfoResponse {
|
||||
Currency: a.Currency,
|
||||
Balance: a.Balance,
|
||||
Comment: a.Comment,
|
||||
LastReconciledTime: lastReconciledTime,
|
||||
CreditCardStatementDate: creditCardStatementDate,
|
||||
DisplayOrder: a.DisplayOrder,
|
||||
IsAsset: assetAccountCategory[a.Category],
|
||||
|
||||
+91
-51
@@ -210,65 +210,69 @@ type TransactionTagFilter struct {
|
||||
|
||||
// TransactionCountRequest represents transaction count request
|
||||
type TransactionCountRequest struct {
|
||||
Type TransactionType `form:"type" binding:"min=0,max=4"`
|
||||
CategoryIds string `form:"category_ids"`
|
||||
AccountIds string `form:"account_ids"`
|
||||
TagFilter string `form:"tag_filter" binding:"validTagFilter"`
|
||||
AmountFilter string `form:"amount_filter" binding:"validAmountFilter"`
|
||||
Keyword string `form:"keyword"`
|
||||
MaxTime int64 `form:"max_time" binding:"min=0"` // Transaction time sequence id
|
||||
MinTime int64 `form:"min_time" binding:"min=0"` // Transaction time sequence id
|
||||
Type TransactionType `form:"type" binding:"min=0,max=4"`
|
||||
CategoryIds string `form:"category_ids"`
|
||||
AccountIds string `form:"account_ids"`
|
||||
TagFilter string `form:"tag_filter" binding:"validTagFilter"`
|
||||
AmountFilter string `form:"amount_filter" binding:"validAmountFilter"`
|
||||
Keyword string `form:"keyword"`
|
||||
MustHavePictures bool `form:"must_have_pictures"`
|
||||
MaxTime int64 `form:"max_time" binding:"min=0"` // Transaction time sequence id
|
||||
MinTime int64 `form:"min_time" binding:"min=0"` // Transaction time sequence id
|
||||
}
|
||||
|
||||
// TransactionListByMaxTimeRequest represents all parameters of transaction listing by max time request
|
||||
type TransactionListByMaxTimeRequest struct {
|
||||
Type TransactionType `form:"type" binding:"min=0,max=4"`
|
||||
CategoryIds string `form:"category_ids"`
|
||||
AccountIds string `form:"account_ids"`
|
||||
TagFilter string `form:"tag_filter" binding:"validTagFilter"`
|
||||
AmountFilter string `form:"amount_filter" binding:"validAmountFilter"`
|
||||
Keyword string `form:"keyword"`
|
||||
MaxTime int64 `form:"max_time" binding:"min=0"` // Transaction time sequence id
|
||||
MinTime int64 `form:"min_time" binding:"min=0"` // Transaction time sequence id
|
||||
Page int32 `form:"page" binding:"min=0"`
|
||||
Count int32 `form:"count" binding:"required,min=1,max=50"`
|
||||
WithCount bool `form:"with_count"`
|
||||
WithPictures bool `form:"with_pictures"`
|
||||
TrimAccount bool `form:"trim_account"`
|
||||
TrimCategory bool `form:"trim_category"`
|
||||
TrimTag bool `form:"trim_tag"`
|
||||
Type TransactionType `form:"type" binding:"min=0,max=4"`
|
||||
CategoryIds string `form:"category_ids"`
|
||||
AccountIds string `form:"account_ids"`
|
||||
TagFilter string `form:"tag_filter" binding:"validTagFilter"`
|
||||
AmountFilter string `form:"amount_filter" binding:"validAmountFilter"`
|
||||
Keyword string `form:"keyword"`
|
||||
MustHavePictures bool `form:"must_have_pictures"`
|
||||
MaxTime int64 `form:"max_time" binding:"min=0"` // Transaction time sequence id
|
||||
MinTime int64 `form:"min_time" binding:"min=0"` // Transaction time sequence id
|
||||
Page int32 `form:"page" binding:"min=0"`
|
||||
Count int32 `form:"count" binding:"required,min=1,max=50"`
|
||||
WithCount bool `form:"with_count"`
|
||||
WithPictures bool `form:"with_pictures"`
|
||||
TrimAccount bool `form:"trim_account"`
|
||||
TrimCategory bool `form:"trim_category"`
|
||||
TrimTag bool `form:"trim_tag"`
|
||||
}
|
||||
|
||||
// TransactionListInMonthByPageRequest represents all parameters of transaction listing by month request
|
||||
type TransactionListInMonthByPageRequest struct {
|
||||
Year int32 `form:"year" binding:"required,min=1"`
|
||||
Month int32 `form:"month" binding:"required,min=1"`
|
||||
Type TransactionType `form:"type" binding:"min=0,max=4"`
|
||||
CategoryIds string `form:"category_ids"`
|
||||
AccountIds string `form:"account_ids"`
|
||||
TagFilter string `form:"tag_filter" binding:"validTagFilter"`
|
||||
AmountFilter string `form:"amount_filter" binding:"validAmountFilter"`
|
||||
Keyword string `form:"keyword"`
|
||||
WithPictures bool `form:"with_pictures"`
|
||||
TrimAccount bool `form:"trim_account"`
|
||||
TrimCategory bool `form:"trim_category"`
|
||||
TrimTag bool `form:"trim_tag"`
|
||||
Year int32 `form:"year" binding:"required,min=1"`
|
||||
Month int32 `form:"month" binding:"required,min=1"`
|
||||
Type TransactionType `form:"type" binding:"min=0,max=4"`
|
||||
CategoryIds string `form:"category_ids"`
|
||||
AccountIds string `form:"account_ids"`
|
||||
TagFilter string `form:"tag_filter" binding:"validTagFilter"`
|
||||
AmountFilter string `form:"amount_filter" binding:"validAmountFilter"`
|
||||
Keyword string `form:"keyword"`
|
||||
MustHavePictures bool `form:"must_have_pictures"`
|
||||
WithPictures bool `form:"with_pictures"`
|
||||
TrimAccount bool `form:"trim_account"`
|
||||
TrimCategory bool `form:"trim_category"`
|
||||
TrimTag bool `form:"trim_tag"`
|
||||
}
|
||||
|
||||
// TransactionAllListRequest represents all parameters of all transaction listing request
|
||||
type TransactionAllListRequest struct {
|
||||
Type TransactionType `form:"type" binding:"min=0,max=4"`
|
||||
CategoryIds string `form:"category_ids"`
|
||||
AccountIds string `form:"account_ids"`
|
||||
TagFilter string `form:"tag_filter" binding:"validTagFilter"`
|
||||
AmountFilter string `form:"amount_filter" binding:"validAmountFilter"`
|
||||
Keyword string `form:"keyword"`
|
||||
StartTime int64 `form:"start_time" binding:"min=0"`
|
||||
EndTime int64 `form:"end_time" binding:"min=0"`
|
||||
WithPictures bool `form:"with_pictures"`
|
||||
TrimAccount bool `form:"trim_account"`
|
||||
TrimCategory bool `form:"trim_category"`
|
||||
TrimTag bool `form:"trim_tag"`
|
||||
Type TransactionType `form:"type" binding:"min=0,max=4"`
|
||||
CategoryIds string `form:"category_ids"`
|
||||
AccountIds string `form:"account_ids"`
|
||||
TagFilter string `form:"tag_filter" binding:"validTagFilter"`
|
||||
AmountFilter string `form:"amount_filter" binding:"validAmountFilter"`
|
||||
Keyword string `form:"keyword"`
|
||||
MustHavePictures bool `form:"must_have_pictures"`
|
||||
StartTime int64 `form:"start_time" binding:"min=0"`
|
||||
EndTime int64 `form:"end_time" binding:"min=0"`
|
||||
WithPictures bool `form:"with_pictures"`
|
||||
TrimAccount bool `form:"trim_account"`
|
||||
TrimCategory bool `form:"trim_category"`
|
||||
TrimTag bool `form:"trim_tag"`
|
||||
}
|
||||
|
||||
// TransactionReconciliationStatementRequest represents all parameters of transaction reconciliation statement request
|
||||
@@ -325,6 +329,36 @@ type TransactionGetRequest struct {
|
||||
TrimTag bool `form:"trim_tag"`
|
||||
}
|
||||
|
||||
// TransactionBatchUpdateCategoryRequest represents all parameters of transaction batch update category request
|
||||
type TransactionBatchUpdateCategoryRequest struct {
|
||||
TransactionIds []string `json:"transactionIds" binding:"required"`
|
||||
CategoryId int64 `json:"categoryId,string" binding:"required"`
|
||||
}
|
||||
|
||||
// TransactionBatchUpdateAccountRequest represents all parameters of transaction batch update account request
|
||||
type TransactionBatchUpdateAccountRequest struct {
|
||||
TransactionIds []string `json:"transactionIds" binding:"required"`
|
||||
AccountId int64 `json:"accountId,string" binding:"required"`
|
||||
IsDestinationAccount bool `json:"isDestinationAccount"`
|
||||
}
|
||||
|
||||
// TransactionBatchAddTagsRequest represents all parameters of transaction batch add tags request
|
||||
type TransactionBatchAddTagsRequest struct {
|
||||
TransactionIds []string `json:"transactionIds" binding:"required"`
|
||||
TagIds []string `json:"tagIds" binding:"required"`
|
||||
}
|
||||
|
||||
// TransactionBatchRemoveTagsRequest represents all parameters of transaction batch remove tags request
|
||||
type TransactionBatchRemoveTagsRequest struct {
|
||||
TransactionIds []string `json:"transactionIds" binding:"required"`
|
||||
TagIds []string `json:"tagIds" binding:"required"`
|
||||
}
|
||||
|
||||
// TransactionBatchClearTagsRequest represents all parameters of transaction batch clear tags request
|
||||
type TransactionBatchClearTagsRequest struct {
|
||||
TransactionIds []string `json:"transactionIds" binding:"required"`
|
||||
}
|
||||
|
||||
// TransactionMoveBetweenAccountsRequest represents all parameters of moving all transactions between accounts request
|
||||
type TransactionMoveBetweenAccountsRequest struct {
|
||||
FromAccountId int64 `json:"fromAccountId,string" binding:"required,min=1"`
|
||||
@@ -336,6 +370,12 @@ type TransactionDeleteRequest struct {
|
||||
Id int64 `json:"id,string" binding:"required,min=1"`
|
||||
}
|
||||
|
||||
// TransactionBatchDeleteRequest represents all parameters of transaction batch deleting request
|
||||
type TransactionBatchDeleteRequest struct {
|
||||
Ids []string `json:"ids,string" binding:"required"`
|
||||
Password string `json:"password" binding:"omitempty,min=6,max=128"`
|
||||
}
|
||||
|
||||
// YearMonthRangeRequest represents all parameters of a request with year and month range
|
||||
type YearMonthRangeRequest struct {
|
||||
StartYearMonth string `form:"start_year_month"`
|
||||
@@ -513,10 +553,6 @@ func ParseTransactionTagFilter(tagFilterStr string) ([]*TransactionTagFilter, er
|
||||
|
||||
// IsEditable returns whether this transaction can be edited
|
||||
func (t *Transaction) IsEditable(currentUser *User, clientTimezone *time.Location, account *Account, relatedAccount *Account) bool {
|
||||
if currentUser == nil || !currentUser.CanEditTransactionByTransactionTime(t.TransactionTime, clientTimezone) {
|
||||
return false
|
||||
}
|
||||
|
||||
if account == nil || account.Hidden {
|
||||
return false
|
||||
}
|
||||
@@ -527,6 +563,10 @@ func (t *Transaction) IsEditable(currentUser *User, clientTimezone *time.Locatio
|
||||
}
|
||||
}
|
||||
|
||||
if currentUser == nil || !currentUser.CanEditTransactionByTransactionTime(t.TransactionTime, clientTimezone, account, relatedAccount) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,8 @@ const (
|
||||
TRANSACTION_SCHEDULE_FREQUENCY_TYPE_DISABLED TransactionScheduleFrequencyType = 0
|
||||
TRANSACTION_SCHEDULE_FREQUENCY_TYPE_WEEKLY TransactionScheduleFrequencyType = 1
|
||||
TRANSACTION_SCHEDULE_FREQUENCY_TYPE_MONTHLY TransactionScheduleFrequencyType = 2
|
||||
TRANSACTION_SCHEDULE_FREQUENCY_TYPE_DAILY TransactionScheduleFrequencyType = 3
|
||||
TRANSACTION_SCHEDULE_FREQUENCY_TYPE_YEARLY TransactionScheduleFrequencyType = 4
|
||||
)
|
||||
|
||||
// TransactionTemplate represents transaction template stored in database
|
||||
|
||||
+38
-15
@@ -13,14 +13,15 @@ type TransactionEditScope byte
|
||||
|
||||
// Editable Transaction Ranges
|
||||
const (
|
||||
TRANSACTION_EDIT_SCOPE_NONE TransactionEditScope = 0
|
||||
TRANSACTION_EDIT_SCOPE_ALL TransactionEditScope = 1
|
||||
TRANSACTION_EDIT_SCOPE_TODAY_OR_LATER TransactionEditScope = 2
|
||||
TRANSACTION_EDIT_SCOPE_LAST_24H_OR_LATER TransactionEditScope = 3
|
||||
TRANSACTION_EDIT_SCOPE_THIS_WEEK_OR_LATER TransactionEditScope = 4
|
||||
TRANSACTION_EDIT_SCOPE_THIS_MONTH_OR_LATER TransactionEditScope = 5
|
||||
TRANSACTION_EDIT_SCOPE_THIS_YEAR_OR_LATER TransactionEditScope = 6
|
||||
TRANSACTION_EDIT_SCOPE_INVALID TransactionEditScope = 255
|
||||
TRANSACTION_EDIT_SCOPE_NONE TransactionEditScope = 0
|
||||
TRANSACTION_EDIT_SCOPE_ALL TransactionEditScope = 1
|
||||
TRANSACTION_EDIT_SCOPE_TODAY_OR_LATER TransactionEditScope = 2
|
||||
TRANSACTION_EDIT_SCOPE_LAST_24H_OR_LATER TransactionEditScope = 3
|
||||
TRANSACTION_EDIT_SCOPE_THIS_WEEK_OR_LATER TransactionEditScope = 4
|
||||
TRANSACTION_EDIT_SCOPE_THIS_MONTH_OR_LATER TransactionEditScope = 5
|
||||
TRANSACTION_EDIT_SCOPE_THIS_YEAR_OR_LATER TransactionEditScope = 6
|
||||
TRANSACTION_EDIT_SCOPE_LAST_RECONCILED_TIME_OR_LATER TransactionEditScope = 7
|
||||
TRANSACTION_EDIT_SCOPE_INVALID TransactionEditScope = 255
|
||||
)
|
||||
|
||||
// String returns a textual representation of the editable transaction ranges enum
|
||||
@@ -40,6 +41,8 @@ func (s TransactionEditScope) String() string {
|
||||
return "ThisMonthOrLater"
|
||||
case TRANSACTION_EDIT_SCOPE_THIS_YEAR_OR_LATER:
|
||||
return "ThisYearOrLater"
|
||||
case TRANSACTION_EDIT_SCOPE_LAST_RECONCILED_TIME_OR_LATER:
|
||||
return "LastReconciledTimeOrLater"
|
||||
case TRANSACTION_EDIT_SCOPE_INVALID:
|
||||
return "Invalid"
|
||||
default:
|
||||
@@ -90,6 +93,7 @@ type User struct {
|
||||
Salt string `xorm:"VARCHAR(10) NOT NULL"`
|
||||
CustomAvatarType string `xorm:"VARCHAR(10)"`
|
||||
DefaultAccountId int64
|
||||
UseLastReconciledTime bool
|
||||
TransactionEditScope TransactionEditScope `xorm:"TINYINT NOT NULL"`
|
||||
Language string `xorm:"VARCHAR(10)"`
|
||||
DefaultCurrency string `xorm:"VARCHAR(3) NOT NULL"`
|
||||
@@ -128,6 +132,7 @@ type UserBasicInfo struct {
|
||||
AvatarUrl string `json:"avatar"`
|
||||
AvatarProvider string `json:"avatarProvider,omitempty"`
|
||||
DefaultAccountId int64 `json:"defaultAccountId,string"`
|
||||
UseLastReconciledTime bool `json:"useLastReconciledTime"`
|
||||
TransactionEditScope TransactionEditScope `json:"transactionEditScope"`
|
||||
Language string `json:"language"`
|
||||
DefaultCurrency string `json:"defaultCurrency"`
|
||||
@@ -194,7 +199,8 @@ type UserProfileUpdateRequest struct {
|
||||
Password string `json:"password" binding:"omitempty,min=6,max=128"`
|
||||
OldPassword string `json:"oldPassword" binding:"omitempty,min=6,max=128"`
|
||||
DefaultAccountId int64 `json:"defaultAccountId,string" binding:"omitempty,min=1"`
|
||||
TransactionEditScope *TransactionEditScope `json:"transactionEditScope" binding:"omitempty,min=0,max=6"`
|
||||
UseLastReconciledTime *bool `json:"useLastReconciledTime" binding:"omitempty"`
|
||||
TransactionEditScope *TransactionEditScope `json:"transactionEditScope" binding:"omitempty,min=0,max=7"`
|
||||
Language string `json:"language" binding:"omitempty,min=2,max=16"`
|
||||
DefaultCurrency string `json:"defaultCurrency" binding:"omitempty,len=3,validCurrency"`
|
||||
FirstDayOfWeek *core.WeekDay `json:"firstDayOfWeek" binding:"omitempty,min=0,max=6"`
|
||||
@@ -230,7 +236,7 @@ type UserProfileResponse struct {
|
||||
}
|
||||
|
||||
// CanEditTransactionByTransactionTime returns whether this user can edit transaction with specified transaction time
|
||||
func (u *User) CanEditTransactionByTransactionTime(transactionTime int64, clientTimezone *time.Location) bool {
|
||||
func (u *User) CanEditTransactionByTransactionTime(transactionTime int64, clientTimezone *time.Location, account *Account, destinationAccount *Account) bool {
|
||||
if u.TransactionEditScope == TRANSACTION_EDIT_SCOPE_NONE {
|
||||
return false
|
||||
} else if u.TransactionEditScope == TRANSACTION_EDIT_SCOPE_ALL {
|
||||
@@ -242,14 +248,14 @@ func (u *User) CanEditTransactionByTransactionTime(transactionTime int64, client
|
||||
transactionUnixTime := utils.GetUnixTimeFromTransactionTime(transactionTime)
|
||||
|
||||
if u.TransactionEditScope == TRANSACTION_EDIT_SCOPE_LAST_24H_OR_LATER {
|
||||
return transactionUnixTime >= now.Add(-24*time.Hour).Unix()
|
||||
return transactionUnixTime > now.Add(-24*time.Hour).Unix()
|
||||
}
|
||||
|
||||
clientNow := now.In(clientTimezone)
|
||||
clientTodayStartTime := utils.GetStartOfDay(clientNow)
|
||||
|
||||
if u.TransactionEditScope == TRANSACTION_EDIT_SCOPE_TODAY_OR_LATER {
|
||||
return transactionUnixTime >= clientTodayStartTime.Unix()
|
||||
return transactionUnixTime > clientTodayStartTime.Unix()
|
||||
} else if u.TransactionEditScope == TRANSACTION_EDIT_SCOPE_THIS_WEEK_OR_LATER {
|
||||
dayOfWeek := int(now.Weekday()) - int(u.FirstDayOfWeek)
|
||||
|
||||
@@ -258,13 +264,29 @@ func (u *User) CanEditTransactionByTransactionTime(transactionTime int64, client
|
||||
}
|
||||
|
||||
clientWeekStartTime := clientTodayStartTime.AddDate(0, 0, -dayOfWeek)
|
||||
return transactionUnixTime >= clientWeekStartTime.Unix()
|
||||
return transactionUnixTime > clientWeekStartTime.Unix()
|
||||
} else if u.TransactionEditScope == TRANSACTION_EDIT_SCOPE_THIS_MONTH_OR_LATER {
|
||||
clientMonthStartTime := clientTodayStartTime.AddDate(0, 0, -(now.Day() - 1))
|
||||
return transactionUnixTime >= clientMonthStartTime.Unix()
|
||||
return transactionUnixTime > clientMonthStartTime.Unix()
|
||||
} else if u.TransactionEditScope == TRANSACTION_EDIT_SCOPE_THIS_YEAR_OR_LATER {
|
||||
clientYearStartTime := clientTodayStartTime.AddDate(0, 0, -(now.YearDay() - 1))
|
||||
return transactionUnixTime >= clientYearStartTime.Unix()
|
||||
return transactionUnixTime > clientYearStartTime.Unix()
|
||||
} else if u.TransactionEditScope == TRANSACTION_EDIT_SCOPE_LAST_RECONCILED_TIME_OR_LATER && u.UseLastReconciledTime {
|
||||
minAccountLastReconciledTime := int64(0)
|
||||
|
||||
if account != nil {
|
||||
minAccountLastReconciledTime = account.GetLastReconciledTime()
|
||||
}
|
||||
|
||||
if destinationAccount != nil {
|
||||
destinationAccountLastReconciledTime := destinationAccount.GetLastReconciledTime()
|
||||
|
||||
if destinationAccountLastReconciledTime > minAccountLastReconciledTime {
|
||||
minAccountLastReconciledTime = destinationAccountLastReconciledTime
|
||||
}
|
||||
}
|
||||
|
||||
return transactionUnixTime > minAccountLastReconciledTime
|
||||
}
|
||||
|
||||
return false
|
||||
@@ -285,6 +307,7 @@ func (u *User) ToUserBasicInfo(avatarProvider core.UserAvatarProviderType, avata
|
||||
AvatarUrl: avatarUrl,
|
||||
AvatarProvider: string(avatarProvider),
|
||||
DefaultAccountId: u.DefaultAccountId,
|
||||
UseLastReconciledTime: u.UseLastReconciledTime,
|
||||
TransactionEditScope: u.TransactionEditScope,
|
||||
Language: u.Language,
|
||||
DefaultCurrency: u.DefaultCurrency,
|
||||
|
||||
@@ -15,7 +15,10 @@ const (
|
||||
|
||||
var ALL_ALLOWED_CLOUD_SYNC_APP_SETTING_KEY_TYPES = map[string]UserApplicationCloudSettingType{
|
||||
// Basic Settings
|
||||
"showAccountBalance": USER_APPLICATION_CLOUD_SETTING_TYPE_BOOLEAN,
|
||||
"showAccountBalance": USER_APPLICATION_CLOUD_SETTING_TYPE_BOOLEAN,
|
||||
"autoUpdateExchangeRatesData": USER_APPLICATION_CLOUD_SETTING_TYPE_BOOLEAN,
|
||||
// Navigation Bar
|
||||
"showAddTransactionButtonInDesktopNavbar": USER_APPLICATION_CLOUD_SETTING_TYPE_BOOLEAN,
|
||||
// Overview Page
|
||||
"showAmountInHomePage": USER_APPLICATION_CLOUD_SETTING_TYPE_BOOLEAN,
|
||||
"timezoneUsedForStatisticsInHomePage": USER_APPLICATION_CLOUD_SETTING_TYPE_NUMBER,
|
||||
@@ -26,6 +29,8 @@ var ALL_ALLOWED_CLOUD_SYNC_APP_SETTING_KEY_TYPES = map[string]UserApplicationClo
|
||||
"showTotalAmountInTransactionListPage": USER_APPLICATION_CLOUD_SETTING_TYPE_BOOLEAN,
|
||||
"showTagInTransactionListPage": USER_APPLICATION_CLOUD_SETTING_TYPE_BOOLEAN,
|
||||
// Transaction Edit Page
|
||||
"quickSaveButtonStyleInMobileTransactionListPage": USER_APPLICATION_CLOUD_SETTING_TYPE_NUMBER,
|
||||
"quickAddButtonActionInMobileTransactionEditPage": USER_APPLICATION_CLOUD_SETTING_TYPE_NUMBER,
|
||||
"autoSaveTransactionDraft": USER_APPLICATION_CLOUD_SETTING_TYPE_STRING,
|
||||
"autoGetCurrentGeoLocation": USER_APPLICATION_CLOUD_SETTING_TYPE_BOOLEAN,
|
||||
"alwaysShowTransactionPicturesInMobileTransactionEditPage": USER_APPLICATION_CLOUD_SETTING_TYPE_BOOLEAN,
|
||||
@@ -36,11 +41,16 @@ var ALL_ALLOWED_CLOUD_SYNC_APP_SETTING_KEY_TYPES = map[string]UserApplicationClo
|
||||
"insightsExplorerDefaultDateRangeType": USER_APPLICATION_CLOUD_SETTING_TYPE_NUMBER,
|
||||
"showTagInInsightsExplorerPage": USER_APPLICATION_CLOUD_SETTING_TYPE_BOOLEAN,
|
||||
// Account List Page
|
||||
"totalAmountExcludeAccountIds": USER_APPLICATION_CLOUD_SETTING_TYPE_STRING_BOOLEAN_MAP,
|
||||
"accountCategoryOrders": USER_APPLICATION_CLOUD_SETTING_TYPE_STRING,
|
||||
"hideCategoriesWithoutAccounts": USER_APPLICATION_CLOUD_SETTING_TYPE_BOOLEAN,
|
||||
"totalAmountExcludeAccountIds": USER_APPLICATION_CLOUD_SETTING_TYPE_STRING_BOOLEAN_MAP,
|
||||
"accountCategoryOrders": USER_APPLICATION_CLOUD_SETTING_TYPE_STRING,
|
||||
"hideCategoriesWithoutAccounts": USER_APPLICATION_CLOUD_SETTING_TYPE_BOOLEAN,
|
||||
"reconciliationStatementButtonDefaultDateRangeTypeInDesktop": USER_APPLICATION_CLOUD_SETTING_TYPE_NUMBER,
|
||||
"reconciliationStatementPageDefaultDateRangeTypeInMobile": USER_APPLICATION_CLOUD_SETTING_TYPE_NUMBER,
|
||||
// Exchange Rates Data Page
|
||||
"currencySortByInExchangeRatesPage": USER_APPLICATION_CLOUD_SETTING_TYPE_NUMBER,
|
||||
// Browser Cache Management
|
||||
"mapCacheExpiration": USER_APPLICATION_CLOUD_SETTING_TYPE_NUMBER,
|
||||
"exchangeRatesDataCacheExpiration": USER_APPLICATION_CLOUD_SETTING_TYPE_NUMBER,
|
||||
// Statistics Settings
|
||||
"statistics.defaultChartDataType": USER_APPLICATION_CLOUD_SETTING_TYPE_NUMBER,
|
||||
"statistics.defaultTimezoneType": USER_APPLICATION_CLOUD_SETTING_TYPE_NUMBER,
|
||||
|
||||
+85
-16
@@ -16,7 +16,7 @@ func TestUserCanEditTransactionByTransactionTime_ScopeIsNone(t *testing.T) {
|
||||
}
|
||||
|
||||
timezone := time.FixedZone("Timezone", int(utils.GetServerTimezoneOffsetMinutes())*60)
|
||||
assert.Equal(t, false, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(time.Now().Unix()), timezone))
|
||||
assert.Equal(t, false, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(time.Now().Unix()), timezone, nil, nil))
|
||||
}
|
||||
|
||||
func TestUserCanEditTransactionByTransactionTime_ScopeIsAll(t *testing.T) {
|
||||
@@ -25,7 +25,7 @@ func TestUserCanEditTransactionByTransactionTime_ScopeIsAll(t *testing.T) {
|
||||
}
|
||||
|
||||
timezone := time.FixedZone("Timezone", int(utils.GetServerTimezoneOffsetMinutes())*60)
|
||||
assert.Equal(t, true, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(time.Now().Unix()), timezone))
|
||||
assert.Equal(t, true, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(time.Now().Unix()), timezone, nil, nil))
|
||||
}
|
||||
|
||||
func TestUserCanEditTransactionByTransactionTime_ScopeIsTodayOrLater(t *testing.T) {
|
||||
@@ -39,9 +39,10 @@ func TestUserCanEditTransactionByTransactionTime_ScopeIsTodayOrLater(t *testing.
|
||||
yesterdayLastDatetime := todayFirstDatetime.Add(-1 * time.Second)
|
||||
todayLastDatetime := yesterdayLastDatetime.Add(24 * time.Hour)
|
||||
|
||||
assert.Equal(t, true, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(todayFirstDatetime.Unix()), timezone))
|
||||
assert.Equal(t, true, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(todayLastDatetime.Unix()), timezone))
|
||||
assert.Equal(t, false, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(yesterdayLastDatetime.Unix()), timezone))
|
||||
assert.Equal(t, false, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(todayFirstDatetime.Unix()), timezone, nil, nil))
|
||||
assert.Equal(t, true, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(todayFirstDatetime.Add(1*time.Second).Unix()), timezone, nil, nil))
|
||||
assert.Equal(t, true, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(todayLastDatetime.Unix()), timezone, nil, nil))
|
||||
assert.Equal(t, false, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(yesterdayLastDatetime.Unix()), timezone, nil, nil))
|
||||
}
|
||||
|
||||
func TestUserCanEditTransactionByTransactionTime_ScopeIsLast24HourOrLater(t *testing.T) {
|
||||
@@ -53,8 +54,9 @@ func TestUserCanEditTransactionByTransactionTime_ScopeIsLast24HourOrLater(t *tes
|
||||
timezone := time.FixedZone("Timezone", int(utils.GetServerTimezoneOffsetMinutes())*60)
|
||||
twentyfourHourBeforeDatetime := now.Add(-24 * time.Hour).Add(-1 * time.Second)
|
||||
|
||||
assert.Equal(t, false, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(twentyfourHourBeforeDatetime.Unix()), timezone))
|
||||
assert.Equal(t, true, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(twentyfourHourBeforeDatetime.Add(1*time.Second).Unix()), timezone))
|
||||
assert.Equal(t, false, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(twentyfourHourBeforeDatetime.Unix()), timezone, nil, nil))
|
||||
assert.Equal(t, false, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(twentyfourHourBeforeDatetime.Add(1*time.Second).Unix()), timezone, nil, nil))
|
||||
assert.Equal(t, true, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(twentyfourHourBeforeDatetime.Add(2*time.Second).Unix()), timezone, nil, nil))
|
||||
}
|
||||
|
||||
func TestUserCanEditTransactionByTransactionTime_ScopeIsThisWeekOrLater(t *testing.T) {
|
||||
@@ -76,9 +78,10 @@ func TestUserCanEditTransactionByTransactionTime_ScopeIsThisWeekOrLater(t *testi
|
||||
lastWeekLastDatetime := thisWeekFirstDatetime.Add(-1 * time.Second)
|
||||
thisWeekLastDatetime := lastWeekLastDatetime.Add(24 * time.Hour)
|
||||
|
||||
assert.Equal(t, true, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(thisWeekFirstDatetime.Unix()), timezone))
|
||||
assert.Equal(t, true, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(thisWeekLastDatetime.Unix()), timezone))
|
||||
assert.Equal(t, false, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(lastWeekLastDatetime.Unix()), timezone))
|
||||
assert.Equal(t, false, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(thisWeekFirstDatetime.Unix()), timezone, nil, nil))
|
||||
assert.Equal(t, true, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(thisWeekFirstDatetime.Add(1*time.Second).Unix()), timezone, nil, nil))
|
||||
assert.Equal(t, true, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(thisWeekLastDatetime.Unix()), timezone, nil, nil))
|
||||
assert.Equal(t, false, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(lastWeekLastDatetime.Unix()), timezone, nil, nil))
|
||||
}
|
||||
|
||||
func TestUserCanEditTransactionByTransactionTime_ScopeIsThisMonthOrLater(t *testing.T) {
|
||||
@@ -92,9 +95,10 @@ func TestUserCanEditTransactionByTransactionTime_ScopeIsThisMonthOrLater(t *test
|
||||
lastMonthLastDatetime := thisMonthFirstDatetime.Add(-1 * time.Second)
|
||||
thisMonthLastDatetime := lastMonthLastDatetime.Add(24 * time.Hour)
|
||||
|
||||
assert.Equal(t, true, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(thisMonthFirstDatetime.Unix()), timezone))
|
||||
assert.Equal(t, true, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(thisMonthLastDatetime.Unix()), timezone))
|
||||
assert.Equal(t, false, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(lastMonthLastDatetime.Unix()), timezone))
|
||||
assert.Equal(t, false, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(thisMonthFirstDatetime.Unix()), timezone, nil, nil))
|
||||
assert.Equal(t, true, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(thisMonthFirstDatetime.Add(1*time.Second).Unix()), timezone, nil, nil))
|
||||
assert.Equal(t, true, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(thisMonthLastDatetime.Unix()), timezone, nil, nil))
|
||||
assert.Equal(t, false, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(lastMonthLastDatetime.Unix()), timezone, nil, nil))
|
||||
}
|
||||
|
||||
func TestUserCanEditTransactionByTransactionTime_ScopeIsThisYearOrLater(t *testing.T) {
|
||||
@@ -108,7 +112,72 @@ func TestUserCanEditTransactionByTransactionTime_ScopeIsThisYearOrLater(t *testi
|
||||
lastYearLastDatetime := thisYearFirstDatetime.Add(-1 * time.Second)
|
||||
thisYearLastDatetime := lastYearLastDatetime.Add(24 * time.Hour)
|
||||
|
||||
assert.Equal(t, true, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(thisYearFirstDatetime.Unix()), timezone))
|
||||
assert.Equal(t, true, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(thisYearLastDatetime.Unix()), timezone))
|
||||
assert.Equal(t, false, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(lastYearLastDatetime.Unix()), timezone))
|
||||
assert.Equal(t, false, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(thisYearFirstDatetime.Unix()), timezone, nil, nil))
|
||||
assert.Equal(t, true, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(thisYearFirstDatetime.Add(1*time.Second).Unix()), timezone, nil, nil))
|
||||
assert.Equal(t, true, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(thisYearLastDatetime.Unix()), timezone, nil, nil))
|
||||
assert.Equal(t, false, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(lastYearLastDatetime.Unix()), timezone, nil, nil))
|
||||
}
|
||||
|
||||
func TestUserCanEditTransactionByTransactionTime_ScopeIsLastReconciledTimeOrLater(t *testing.T) {
|
||||
user := &User{
|
||||
TransactionEditScope: TRANSACTION_EDIT_SCOPE_LAST_RECONCILED_TIME_OR_LATER,
|
||||
UseLastReconciledTime: true,
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
timezone := time.FixedZone("Timezone", int(utils.GetServerTimezoneOffsetMinutes())*60)
|
||||
sourceAccountLastReconciledTime := now.Add(-24 * time.Hour)
|
||||
sourceAccountLastRecondiledUnixTime := sourceAccountLastReconciledTime.Unix()
|
||||
sourceAccount := &Account{
|
||||
Extend: &AccountExtend{
|
||||
LastReconciledTime: &sourceAccountLastRecondiledUnixTime,
|
||||
},
|
||||
}
|
||||
destinationAccountLastReconciledTime := now.Add(-20 * time.Hour)
|
||||
destinationAccountLastReconciledUnixTime := destinationAccountLastReconciledTime.Unix()
|
||||
destinationAccount := &Account{
|
||||
Extend: &AccountExtend{
|
||||
LastReconciledTime: &destinationAccountLastReconciledUnixTime,
|
||||
},
|
||||
}
|
||||
|
||||
assert.Equal(t, false, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(sourceAccountLastReconciledTime.Add(-1*time.Second).Unix()), timezone, sourceAccount, nil))
|
||||
assert.Equal(t, false, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(sourceAccountLastReconciledTime.Unix()), timezone, sourceAccount, nil))
|
||||
assert.Equal(t, true, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(sourceAccountLastReconciledTime.Add(1*time.Second).Unix()), timezone, sourceAccount, nil))
|
||||
|
||||
assert.Equal(t, false, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(destinationAccountLastReconciledTime.Add(-1*time.Second).Unix()), timezone, sourceAccount, destinationAccount))
|
||||
assert.Equal(t, false, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(destinationAccountLastReconciledTime.Unix()), timezone, sourceAccount, destinationAccount))
|
||||
assert.Equal(t, true, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(destinationAccountLastReconciledTime.Add(1*time.Second).Unix()), timezone, sourceAccount, destinationAccount))
|
||||
}
|
||||
|
||||
func TestUserCanEditTransactionByTransactionTime_ScopeIsLastReconciledTimeOrLaterButUserDoesNotUseLastReconciledTime(t *testing.T) {
|
||||
user := &User{
|
||||
TransactionEditScope: TRANSACTION_EDIT_SCOPE_LAST_RECONCILED_TIME_OR_LATER,
|
||||
UseLastReconciledTime: false,
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
timezone := time.FixedZone("Timezone", int(utils.GetServerTimezoneOffsetMinutes())*60)
|
||||
sourceAccountLastReconciledTime := now.Add(-24 * time.Hour)
|
||||
sourceAccountLastRecondiledUnixTime := sourceAccountLastReconciledTime.Unix()
|
||||
sourceAccount := &Account{
|
||||
Extend: &AccountExtend{
|
||||
LastReconciledTime: &sourceAccountLastRecondiledUnixTime,
|
||||
},
|
||||
}
|
||||
destinationAccountLastReconciledTime := now.Add(-20 * time.Hour)
|
||||
destinationAccountLastReconciledUnixTime := destinationAccountLastReconciledTime.Unix()
|
||||
destinationAccount := &Account{
|
||||
Extend: &AccountExtend{
|
||||
LastReconciledTime: &destinationAccountLastReconciledUnixTime,
|
||||
},
|
||||
}
|
||||
|
||||
assert.Equal(t, false, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(sourceAccountLastReconciledTime.Add(-1*time.Second).Unix()), timezone, sourceAccount, nil))
|
||||
assert.Equal(t, false, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(sourceAccountLastReconciledTime.Unix()), timezone, sourceAccount, nil))
|
||||
assert.Equal(t, false, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(sourceAccountLastReconciledTime.Add(1*time.Second).Unix()), timezone, sourceAccount, nil))
|
||||
|
||||
assert.Equal(t, false, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(destinationAccountLastReconciledTime.Add(-1*time.Second).Unix()), timezone, sourceAccount, destinationAccount))
|
||||
assert.Equal(t, false, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(destinationAccountLastReconciledTime.Unix()), timezone, sourceAccount, destinationAccount))
|
||||
assert.Equal(t, false, user.CanEditTransactionByTransactionTime(utils.GetMinTransactionTimeFromUnixTime(destinationAccountLastReconciledTime.Add(1*time.Second).Unix()), timezone, sourceAccount, destinationAccount))
|
||||
}
|
||||
|
||||
@@ -592,6 +592,27 @@ func (s *AccountService) ModifyAccounts(c core.Context, mainAccount *models.Acco
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateAccountExtend updates extend field of given account
|
||||
func (s *AccountService) UpdateAccountExtend(c core.Context, uid int64, account *models.Account) error {
|
||||
if uid <= 0 {
|
||||
return errs.ErrUserIdInvalid
|
||||
}
|
||||
|
||||
account.UpdatedUnixTime = time.Now().Unix()
|
||||
|
||||
return s.UserDataDB(uid).DoTransaction(c, func(sess *xorm.Session) error {
|
||||
updatedRows, err := sess.ID(account.AccountId).Cols("extend", "updated_unix_time").Where("uid=? AND deleted=?", uid, false).Update(account)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
} else if updatedRows < 1 {
|
||||
return errs.ErrAccountNotFound
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// HideAccount updates hidden field of given accounts
|
||||
func (s *AccountService) HideAccount(c core.Context, uid int64, ids []int64, hidden bool) error {
|
||||
if uid <= 0 {
|
||||
|
||||
+2
-11
@@ -20,15 +20,6 @@ import (
|
||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||
)
|
||||
|
||||
// TokenUserAgentCreatedViaCli is the user agent of token created via cli
|
||||
const TokenUserAgentCreatedViaCli = core.ApplicationName + " Cli"
|
||||
|
||||
// TokenUserAgentForAPI is the user agent for API token
|
||||
const TokenUserAgentForAPI = core.ApplicationName + " API"
|
||||
|
||||
// TokenUserAgentForMCP is the user agent for MCP token
|
||||
const TokenUserAgentForMCP = core.ApplicationName + " MCP"
|
||||
|
||||
const tokenMaxExpiredAtUnixTime = int64(253402300799) // 9999-12-31 23:59:59 UTC
|
||||
|
||||
// TokenService represents user token service
|
||||
@@ -140,7 +131,7 @@ func (s *TokenService) CreateAPITokenViaCli(c *core.CliContext, user *models.Use
|
||||
tokenExpiredTimeDuration = time.Unix(tokenMaxExpiredAtUnixTime, 0).Sub(time.Now())
|
||||
}
|
||||
|
||||
token, _, tokenRecord, err := s.createToken(c, user, core.USER_TOKEN_TYPE_API, TokenUserAgentCreatedViaCli, "", tokenExpiredTimeDuration)
|
||||
token, _, tokenRecord, err := s.createToken(c, user, core.USER_TOKEN_TYPE_API, core.TokenUserAgentCreatedViaCli, "", tokenExpiredTimeDuration)
|
||||
return token, tokenRecord, err
|
||||
}
|
||||
|
||||
@@ -168,7 +159,7 @@ func (s *TokenService) CreateMCPTokenViaCli(c *core.CliContext, user *models.Use
|
||||
tokenExpiredTimeDuration = time.Unix(tokenMaxExpiredAtUnixTime, 0).Sub(time.Now())
|
||||
}
|
||||
|
||||
token, _, tokenRecord, err := s.createToken(c, user, core.USER_TOKEN_TYPE_MCP, TokenUserAgentCreatedViaCli, "", tokenExpiredTimeDuration)
|
||||
token, _, tokenRecord, err := s.createToken(c, user, core.USER_TOKEN_TYPE_MCP, core.TokenUserAgentCreatedViaCli, "", tokenExpiredTimeDuration)
|
||||
return token, tokenRecord, err
|
||||
}
|
||||
|
||||
|
||||
@@ -132,6 +132,23 @@ func (s *TransactionPictureService) GetPictureInfosByTransactionIds(c core.Conte
|
||||
return pictureInfoMap, err
|
||||
}
|
||||
|
||||
// GetAllPictureInfosOfAllTransactions returns all transaction picture info models
|
||||
func (s *TransactionPictureService) GetAllPictureInfosOfAllTransactions(c core.Context, uid int64) (map[int64][]*models.TransactionPictureInfo, error) {
|
||||
if uid <= 0 {
|
||||
return nil, errs.ErrUserIdInvalid
|
||||
}
|
||||
|
||||
var pictureInfos []*models.TransactionPictureInfo
|
||||
err := s.UserDataDB(uid).NewSession(c).Where("uid=? AND deleted=?", uid, false).OrderBy("picture_id asc").Find(&pictureInfos)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pictureInfoMap := s.GetPictureInfoListMapByList(pictureInfos)
|
||||
return pictureInfoMap, err
|
||||
}
|
||||
|
||||
// GetPictureByPictureId returns the transaction picture data according to transaction picture id
|
||||
func (s *TransactionPictureService) GetPictureByPictureId(c core.Context, uid int64, pictureId int64, fileExtension string) ([]byte, error) {
|
||||
if uid <= 0 {
|
||||
|
||||
+362
-39
@@ -55,7 +55,7 @@ func (s *TransactionService) GetAllTransactions(c core.Context, uid int64, pageC
|
||||
var allTransactions []*models.Transaction
|
||||
|
||||
for maxTransactionTime > 0 {
|
||||
transactions, err := s.GetAllTransactionsByMaxTime(c, uid, maxTransactionTime, pageCount, noDuplicated)
|
||||
transactions, err := s.GetTransactionsByMaxTime(c, uid, maxTransactionTime, 0, 0, nil, nil, nil, false, "", "", false, 1, pageCount, false, noDuplicated)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -74,13 +74,8 @@ func (s *TransactionService) GetAllTransactions(c core.Context, uid int64, pageC
|
||||
return allTransactions, nil
|
||||
}
|
||||
|
||||
// GetAllTransactionsByMaxTime returns all transactions before given time
|
||||
func (s *TransactionService) GetAllTransactionsByMaxTime(c core.Context, uid int64, maxTransactionTime int64, count int32, noDuplicated bool) ([]*models.Transaction, error) {
|
||||
return s.GetTransactionsByMaxTime(c, uid, maxTransactionTime, 0, 0, nil, nil, nil, false, "", "", 1, count, false, noDuplicated)
|
||||
}
|
||||
|
||||
// GetAllSpecifiedTransactions returns all transactions that match given conditions
|
||||
func (s *TransactionService) GetAllSpecifiedTransactions(c core.Context, uid int64, maxTransactionTime int64, minTransactionTime int64, transactionType models.TransactionType, categoryIds []int64, accountIds []int64, tagFilters []*models.TransactionTagFilter, noTags bool, amountFilter string, keyword string, pageCount int32, noDuplicated bool) ([]*models.Transaction, error) {
|
||||
func (s *TransactionService) GetAllSpecifiedTransactions(c core.Context, uid int64, maxTransactionTime int64, minTransactionTime int64, transactionType models.TransactionType, categoryIds []int64, accountIds []int64, tagFilters []*models.TransactionTagFilter, noTags bool, amountFilter string, keyword string, mustHavePictures bool, pageCount int32, noDuplicated bool) ([]*models.Transaction, error) {
|
||||
if maxTransactionTime <= 0 {
|
||||
maxTransactionTime = utils.GetMaxTransactionTimeFromUnixTime(time.Now().Unix())
|
||||
}
|
||||
@@ -88,7 +83,7 @@ func (s *TransactionService) GetAllSpecifiedTransactions(c core.Context, uid int
|
||||
var allTransactions []*models.Transaction
|
||||
|
||||
for maxTransactionTime > 0 {
|
||||
transactions, err := s.GetTransactionsByMaxTime(c, uid, maxTransactionTime, minTransactionTime, transactionType, categoryIds, accountIds, tagFilters, noTags, amountFilter, keyword, 1, pageCount, false, noDuplicated)
|
||||
transactions, err := s.GetTransactionsByMaxTime(c, uid, maxTransactionTime, minTransactionTime, transactionType, categoryIds, accountIds, tagFilters, noTags, amountFilter, keyword, mustHavePictures, 1, pageCount, false, noDuplicated)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -116,7 +111,7 @@ func (s *TransactionService) GetAllTransactionsInOneAccountWithAccountBalanceByM
|
||||
var allTransactions []*models.Transaction
|
||||
|
||||
for maxTransactionTime > 0 {
|
||||
transactions, err := s.GetTransactionsByMaxTime(c, uid, maxTransactionTime, 0, 0, nil, []int64{accountId}, nil, false, "", "", 1, pageCount, false, true)
|
||||
transactions, err := s.GetTransactionsByMaxTime(c, uid, maxTransactionTime, 0, 0, nil, []int64{accountId}, nil, false, "", "", false, 1, pageCount, false, true)
|
||||
|
||||
if err != nil {
|
||||
return nil, 0, 0, 0, 0, err
|
||||
@@ -206,7 +201,7 @@ func (s *TransactionService) GetAllAccountsDailyOpeningAndClosingBalance(c core.
|
||||
var allTransactions []*models.Transaction
|
||||
|
||||
for maxTransactionTime > 0 {
|
||||
transactions, err := s.GetTransactionsByMaxTime(c, uid, maxTransactionTime, 0, 0, nil, nil, nil, false, "", "", 1, pageCountForLoadTransactionAmounts, false, false)
|
||||
transactions, err := s.GetTransactionsByMaxTime(c, uid, maxTransactionTime, 0, 0, nil, nil, nil, false, "", "", false, 1, pageCountForLoadTransactionAmounts, false, false)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -322,8 +317,103 @@ func (s *TransactionService) GetAllAccountsDailyOpeningAndClosingBalance(c core.
|
||||
return accountDailyBalances, nil
|
||||
}
|
||||
|
||||
// GetTransactionsByMaxTimeUpToCount returns transactions before given time and up to given count
|
||||
func (s *TransactionService) GetTransactionsByMaxTimeUpToCount(c core.Context, uid int64, maxTransactionTime int64, minTransactionTime int64, transactionType models.TransactionType, categoryIds []int64, accountIds []int64, tagFilters []*models.TransactionTagFilter, noTags bool, amountFilter string, keyword string, mustHavePictures bool, page int32, count int32, pageCount int32, needOneMoreItem bool, noDuplicated bool) ([]*models.Transaction, error) {
|
||||
if maxTransactionTime <= 0 {
|
||||
maxTransactionTime = utils.GetMaxTransactionTimeFromUnixTime(time.Now().Unix())
|
||||
}
|
||||
|
||||
if page < 0 {
|
||||
return nil, errs.ErrPageIndexInvalid
|
||||
} else if page == 0 {
|
||||
page = 1
|
||||
}
|
||||
|
||||
if count < 1 {
|
||||
return nil, errs.ErrPageCountInvalid
|
||||
}
|
||||
|
||||
finalExpectedCount := int(count)
|
||||
|
||||
if needOneMoreItem {
|
||||
finalExpectedCount++
|
||||
}
|
||||
|
||||
var allTransactions []*models.Transaction
|
||||
startOffset := int((page - 1) * count)
|
||||
firstFetchCount := int(pageCount)
|
||||
|
||||
if finalExpectedCount < firstFetchCount {
|
||||
firstFetchCount = finalExpectedCount
|
||||
}
|
||||
|
||||
transactions, err := s.getTransactionsByMaxTimeWithOffset(c, uid, maxTransactionTime, minTransactionTime, transactionType, categoryIds, accountIds, tagFilters, noTags, amountFilter, keyword, mustHavePictures, startOffset, firstFetchCount, noDuplicated)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
allTransactions = append(allTransactions, transactions...)
|
||||
|
||||
if len(transactions) < firstFetchCount {
|
||||
return allTransactions, nil
|
||||
}
|
||||
|
||||
maxTransactionTime = transactions[len(transactions)-1].TransactionTime - 1
|
||||
|
||||
for len(allTransactions) < finalExpectedCount && maxTransactionTime > 0 {
|
||||
remainingCount := finalExpectedCount - len(allTransactions)
|
||||
fetchCount := int(pageCount)
|
||||
|
||||
if remainingCount < fetchCount {
|
||||
fetchCount = remainingCount
|
||||
}
|
||||
|
||||
transactions, err := s.GetTransactionsByMaxTime(c, uid, maxTransactionTime, minTransactionTime, transactionType, categoryIds, accountIds, tagFilters, noTags, amountFilter, keyword, mustHavePictures, 1, int32(fetchCount), false, noDuplicated)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
allTransactions = append(allTransactions, transactions...)
|
||||
|
||||
if len(transactions) < fetchCount {
|
||||
break
|
||||
}
|
||||
|
||||
maxTransactionTime = transactions[len(transactions)-1].TransactionTime - 1
|
||||
}
|
||||
|
||||
return allTransactions, nil
|
||||
}
|
||||
|
||||
// GetTransactionsByMaxTime returns transactions before given time
|
||||
func (s *TransactionService) GetTransactionsByMaxTime(c core.Context, uid int64, maxTransactionTime int64, minTransactionTime int64, transactionType models.TransactionType, categoryIds []int64, accountIds []int64, tagFilters []*models.TransactionTagFilter, noTags bool, amountFilter string, keyword string, page int32, count int32, needOneMoreItem bool, noDuplicated bool) ([]*models.Transaction, error) {
|
||||
func (s *TransactionService) GetTransactionsByMaxTime(c core.Context, uid int64, maxTransactionTime int64, minTransactionTime int64, transactionType models.TransactionType, categoryIds []int64, accountIds []int64, tagFilters []*models.TransactionTagFilter, noTags bool, amountFilter string, keyword string, mustHavePictures bool, page int32, count int32, needOneMoreItem bool, noDuplicated bool) ([]*models.Transaction, error) {
|
||||
if uid <= 0 {
|
||||
return nil, errs.ErrUserIdInvalid
|
||||
}
|
||||
|
||||
if page < 0 {
|
||||
return nil, errs.ErrPageIndexInvalid
|
||||
} else if page == 0 {
|
||||
page = 1
|
||||
}
|
||||
|
||||
if count < 1 {
|
||||
return nil, errs.ErrPageCountInvalid
|
||||
}
|
||||
|
||||
finalCount := int(count)
|
||||
|
||||
if needOneMoreItem {
|
||||
finalCount++
|
||||
}
|
||||
|
||||
return s.getTransactionsByMaxTimeWithOffset(c, uid, maxTransactionTime, minTransactionTime, transactionType, categoryIds, accountIds, tagFilters, noTags, amountFilter, keyword, mustHavePictures, int(count*(page-1)), finalCount, noDuplicated)
|
||||
}
|
||||
|
||||
// getTransactionsByMaxTimeWithOffset returns transactions before given time with explicit offset and limit
|
||||
func (s *TransactionService) getTransactionsByMaxTimeWithOffset(c core.Context, uid int64, maxTransactionTime int64, minTransactionTime int64, transactionType models.TransactionType, categoryIds []int64, accountIds []int64, tagFilters []*models.TransactionTagFilter, noTags bool, amountFilter string, keyword string, mustHavePictures bool, offset int, limit int, noDuplicated bool) ([]*models.Transaction, error) {
|
||||
if uid <= 0 {
|
||||
return nil, errs.ErrUserIdInvalid
|
||||
}
|
||||
@@ -339,35 +429,20 @@ func (s *TransactionService) GetTransactionsByMaxTime(c core.Context, uid int64,
|
||||
}
|
||||
}
|
||||
|
||||
if page < 0 {
|
||||
return nil, errs.ErrPageIndexInvalid
|
||||
} else if page == 0 {
|
||||
page = 1
|
||||
}
|
||||
|
||||
if count < 1 {
|
||||
return nil, errs.ErrPageCountInvalid
|
||||
}
|
||||
|
||||
var transactions []*models.Transaction
|
||||
|
||||
actualCount := count
|
||||
|
||||
if needOneMoreItem {
|
||||
actualCount++
|
||||
}
|
||||
|
||||
condition, conditionParams := s.buildTransactionQueryCondition(uid, maxTransactionTime, minTransactionTime, transactionDbType, categoryIds, accountIds, tagFilters, amountFilter, keyword, noDuplicated)
|
||||
sess := s.UserDataDB(uid).NewSession(c).Where(condition, conditionParams...)
|
||||
sess = s.appendFilterTagIdsConditionToQuery(sess, uid, maxTransactionTime, minTransactionTime, tagFilters, noTags)
|
||||
sess = s.appendFilterPicturesConditionToQuery(sess, uid, mustHavePictures)
|
||||
|
||||
err = sess.Limit(int(actualCount), int(count*(page-1))).OrderBy("transaction_time desc").Find(&transactions)
|
||||
err = sess.Limit(limit, offset).OrderBy("transaction_time desc").Find(&transactions)
|
||||
|
||||
return transactions, err
|
||||
}
|
||||
|
||||
// GetTransactionsInMonthByPage returns all transactions in given year and month
|
||||
func (s *TransactionService) GetTransactionsInMonthByPage(c core.Context, uid int64, year int32, month int32, transactionType models.TransactionType, categoryIds []int64, accountIds []int64, tagFilters []*models.TransactionTagFilter, noTags bool, amountFilter string, keyword string) ([]*models.Transaction, error) {
|
||||
func (s *TransactionService) GetTransactionsInMonthByPage(c core.Context, uid int64, year int32, month int32, transactionType models.TransactionType, categoryIds []int64, accountIds []int64, tagFilters []*models.TransactionTagFilter, noTags bool, amountFilter string, keyword string, mustHavePictures bool) ([]*models.Transaction, error) {
|
||||
if uid <= 0 {
|
||||
return nil, errs.ErrUserIdInvalid
|
||||
}
|
||||
@@ -394,6 +469,7 @@ func (s *TransactionService) GetTransactionsInMonthByPage(c core.Context, uid in
|
||||
condition, conditionParams := s.buildTransactionQueryCondition(uid, maxTransactionTime, minTransactionTime, transactionDbType, categoryIds, accountIds, tagFilters, amountFilter, keyword, true)
|
||||
sess := s.UserDataDB(uid).NewSession(c).Where(condition, conditionParams...)
|
||||
sess = s.appendFilterTagIdsConditionToQuery(sess, uid, maxTransactionTime, minTransactionTime, tagFilters, noTags)
|
||||
sess = s.appendFilterPicturesConditionToQuery(sess, uid, mustHavePictures)
|
||||
|
||||
err = sess.OrderBy("transaction_time desc").Find(&transactions)
|
||||
|
||||
@@ -434,13 +510,29 @@ func (s *TransactionService) GetTransactionByTransactionId(c core.Context, uid i
|
||||
return transaction, nil
|
||||
}
|
||||
|
||||
// GetTransactionsByTransactionIds returns transaction models according to transaction ids
|
||||
func (s *TransactionService) GetTransactionsByTransactionIds(c core.Context, uid int64, transactionIds []int64) ([]*models.Transaction, error) {
|
||||
if uid <= 0 {
|
||||
return nil, errs.ErrUserIdInvalid
|
||||
}
|
||||
|
||||
if len(transactionIds) <= 0 {
|
||||
return nil, errs.ErrTransactionIdInvalid
|
||||
}
|
||||
|
||||
var transactions []*models.Transaction
|
||||
err := s.UserDataDB(uid).NewSession(c).Where("uid=? AND deleted=?", uid, false).In("transaction_id", transactionIds).Find(&transactions)
|
||||
|
||||
return transactions, err
|
||||
}
|
||||
|
||||
// GetAllTransactionCount returns total count of transactions
|
||||
func (s *TransactionService) GetAllTransactionCount(c core.Context, uid int64) (int64, error) {
|
||||
return s.GetTransactionCount(c, uid, 0, 0, 0, nil, nil, nil, false, "", "")
|
||||
return s.GetTransactionCount(c, uid, 0, 0, 0, nil, nil, nil, false, "", "", false)
|
||||
}
|
||||
|
||||
// GetTransactionCount returns count of transactions
|
||||
func (s *TransactionService) GetTransactionCount(c core.Context, uid int64, maxTransactionTime int64, minTransactionTime int64, transactionType models.TransactionType, categoryIds []int64, accountIds []int64, tagFilters []*models.TransactionTagFilter, noTags bool, amountFilter string, keyword string) (int64, error) {
|
||||
func (s *TransactionService) GetTransactionCount(c core.Context, uid int64, maxTransactionTime int64, minTransactionTime int64, transactionType models.TransactionType, categoryIds []int64, accountIds []int64, tagFilters []*models.TransactionTagFilter, noTags bool, amountFilter string, keyword string, mustHavePictures bool) (int64, error) {
|
||||
if uid <= 0 {
|
||||
return 0, errs.ErrUserIdInvalid
|
||||
}
|
||||
@@ -459,6 +551,7 @@ func (s *TransactionService) GetTransactionCount(c core.Context, uid int64, maxT
|
||||
condition, conditionParams := s.buildTransactionQueryCondition(uid, maxTransactionTime, minTransactionTime, transactionDbType, categoryIds, accountIds, tagFilters, amountFilter, keyword, true)
|
||||
sess := s.UserDataDB(uid).NewSession(c).Where(condition, conditionParams...)
|
||||
sess = s.appendFilterTagIdsConditionToQuery(sess, uid, maxTransactionTime, minTransactionTime, tagFilters, noTags)
|
||||
sess = s.appendFilterPicturesConditionToQuery(sess, uid, mustHavePictures)
|
||||
|
||||
return sess.Count(&models.Transaction{})
|
||||
}
|
||||
@@ -683,7 +776,20 @@ func (s *TransactionService) CreateScheduledTransactions(c core.Context, current
|
||||
|
||||
for i := 0; i < s.UserDataDBCount(); i++ {
|
||||
var templates []*models.TransactionTemplate
|
||||
err := s.UserDataDBByIndex(i).NewSession(c).Where("deleted=? AND template_type=? AND (scheduled_frequency_type=? OR scheduled_frequency_type=?) AND (scheduled_start_time IS NULL OR scheduled_start_time<=?) AND (scheduled_end_time IS NULL OR scheduled_end_time>=?) AND scheduled_at>=? AND scheduled_at<?", false, models.TRANSACTION_TEMPLATE_TYPE_SCHEDULE, models.TRANSACTION_SCHEDULE_FREQUENCY_TYPE_WEEKLY, models.TRANSACTION_SCHEDULE_FREQUENCY_TYPE_MONTHLY, startTime.Unix(), startTime.Unix(), minScheduledAt, maxScheduledAt).Find(&templates)
|
||||
err := s.UserDataDBByIndex(i).NewSession(c).Where("deleted=?"+
|
||||
" AND template_type=?"+
|
||||
" AND (scheduled_frequency_type=? OR scheduled_frequency_type=? OR scheduled_frequency_type=? OR scheduled_frequency_type=?)"+
|
||||
" AND (scheduled_start_time IS NULL OR scheduled_start_time<=?)"+
|
||||
" AND (scheduled_end_time IS NULL OR scheduled_end_time>=?)"+
|
||||
" AND scheduled_at>=?"+
|
||||
" AND scheduled_at<?",
|
||||
false,
|
||||
models.TRANSACTION_TEMPLATE_TYPE_SCHEDULE,
|
||||
models.TRANSACTION_SCHEDULE_FREQUENCY_TYPE_WEEKLY, models.TRANSACTION_SCHEDULE_FREQUENCY_TYPE_MONTHLY, models.TRANSACTION_SCHEDULE_FREQUENCY_TYPE_DAILY, models.TRANSACTION_SCHEDULE_FREQUENCY_TYPE_YEARLY,
|
||||
startTime.Unix(),
|
||||
startTime.Unix(),
|
||||
minScheduledAt,
|
||||
maxScheduledAt).Find(&templates)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -712,7 +818,9 @@ func (s *TransactionService) CreateScheduledTransactions(c core.Context, current
|
||||
}
|
||||
|
||||
if (template.ScheduledFrequencyType != models.TRANSACTION_SCHEDULE_FREQUENCY_TYPE_WEEKLY &&
|
||||
template.ScheduledFrequencyType != models.TRANSACTION_SCHEDULE_FREQUENCY_TYPE_MONTHLY) ||
|
||||
template.ScheduledFrequencyType != models.TRANSACTION_SCHEDULE_FREQUENCY_TYPE_MONTHLY &&
|
||||
template.ScheduledFrequencyType != models.TRANSACTION_SCHEDULE_FREQUENCY_TYPE_DAILY &&
|
||||
template.ScheduledFrequencyType != models.TRANSACTION_SCHEDULE_FREQUENCY_TYPE_YEARLY) ||
|
||||
template.ScheduledFrequency == "" {
|
||||
skipCount++
|
||||
log.Warnf(c, "[transactions.CreateScheduledTransactions] transaction template \"id:%d\" has invalid scheduled transaction frequency", template.TemplateId)
|
||||
@@ -727,6 +835,16 @@ func (s *TransactionService) CreateScheduledTransactions(c core.Context, current
|
||||
continue
|
||||
}
|
||||
|
||||
if template.ScheduledFrequencyType == models.TRANSACTION_SCHEDULE_FREQUENCY_TYPE_MONTHLY {
|
||||
maxDayInMonth := utils.GetMaxDayOfMonth(currentTime.Year(), currentTime.Month())
|
||||
|
||||
for i := 0; i < len(frequencyValues); i++ {
|
||||
if frequencyValues[i] < 0 {
|
||||
frequencyValues[i] = int64(maxDayInMonth) + frequencyValues[i] + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
frequencyValueSet := utils.ToSet(frequencyValues)
|
||||
templateTimeZone := time.FixedZone("Template Timezone", int(template.ScheduledTimezoneUtcOffset)*60)
|
||||
transactionUnixTime := todayFirstUnixTimeInUTC + int64(template.ScheduledAt)*60
|
||||
@@ -740,6 +858,10 @@ func (s *TransactionService) CreateScheduledTransactions(c core.Context, current
|
||||
skipCount++
|
||||
log.Infof(c, "[transactions.CreateScheduledTransactions] transaction template \"id:%d\" does not need to create transaction, today is %d of month", template.TemplateId, startTimeInUTC.Day())
|
||||
continue
|
||||
} else if template.ScheduledFrequencyType == models.TRANSACTION_SCHEDULE_FREQUENCY_TYPE_YEARLY && !frequencyValueSet[int64(transactionTime.Month())*100+int64(transactionTime.Day())] {
|
||||
skipCount++
|
||||
log.Infof(c, "[transactions.CreateScheduledTransactions] transaction template \"id:%d\" does not need to create transaction, today is %d-%d of year", template.TemplateId, startTimeInUTC.Month(), startTimeInUTC.Day())
|
||||
continue
|
||||
}
|
||||
|
||||
if template.ScheduledStartTime != nil && *template.ScheduledStartTime > transactionUnixTime {
|
||||
@@ -778,7 +900,7 @@ func (s *TransactionService) CreateScheduledTransactions(c core.Context, current
|
||||
Amount: template.Amount,
|
||||
HideAmount: template.HideAmount,
|
||||
Comment: template.Comment,
|
||||
CreatedIp: "127.0.0.1",
|
||||
CreatedIp: c.ClientIP(),
|
||||
ScheduledCreated: true,
|
||||
}
|
||||
|
||||
@@ -988,7 +1110,7 @@ func (s *TransactionService) ModifyTransaction(c core.Context, transaction *mode
|
||||
}
|
||||
|
||||
// Get and verify tags
|
||||
err = s.isTagsValid(sess, transaction, transactionTagIndexes, addTagIds)
|
||||
err = s.isTagsValid(sess, transaction.Uid, transactionTagIndexes, addTagIds)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -1293,6 +1415,196 @@ func (s *TransactionService) ModifyTransaction(c core.Context, transaction *mode
|
||||
return nil
|
||||
}
|
||||
|
||||
// BatchUpdateTransactionsCategory batch updates the categories of transactions
|
||||
func (s *TransactionService) BatchUpdateTransactionsCategory(c core.Context, uid int64, transactionIds []int64, newCategoryId int64) error {
|
||||
if uid <= 0 {
|
||||
return errs.ErrUserIdInvalid
|
||||
}
|
||||
|
||||
if len(transactionIds) < 1 {
|
||||
return errs.ErrTransactionIdInvalid
|
||||
}
|
||||
|
||||
if newCategoryId <= 0 {
|
||||
return errs.ErrTransactionCategoryIdInvalid
|
||||
}
|
||||
|
||||
uniqueTransactionIds := utils.ToUniqueInt64Slice(transactionIds)
|
||||
now := time.Now().Unix()
|
||||
|
||||
updateModel := &models.Transaction{
|
||||
CategoryId: newCategoryId,
|
||||
UpdatedUnixTime: now,
|
||||
}
|
||||
|
||||
return s.UserDataDB(uid).DoTransaction(c, func(sess *xorm.Session) error {
|
||||
updatedRows, err := sess.Cols("category_id", "updated_unix_time").Where("uid=? AND deleted=?", uid, false).In("transaction_id", uniqueTransactionIds).Update(updateModel)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
} else if updatedRows < int64(len(uniqueTransactionIds)) {
|
||||
return errs.ErrTransactionNotFound
|
||||
}
|
||||
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
// BatchAddTagsToTransactions batch adds tags to transactions
|
||||
func (s *TransactionService) BatchAddTagsToTransactions(c core.Context, uid int64, transactions []*models.Transaction, addTransactionTagIds map[int64][]int64) error {
|
||||
if uid <= 0 {
|
||||
return errs.ErrUserIdInvalid
|
||||
}
|
||||
|
||||
if len(addTransactionTagIds) < 1 {
|
||||
return errs.ErrTransactionIdInvalid
|
||||
}
|
||||
|
||||
now := time.Now().Unix()
|
||||
transactionTagIndexes := make([]*models.TransactionTagIndex, 0, len(addTransactionTagIds))
|
||||
transactionsMap := make(map[int64]*models.Transaction, len(transactions))
|
||||
transactionTagIdsMap := make(map[int64]bool, 0)
|
||||
|
||||
for i := 0; i < len(transactions); i++ {
|
||||
transaction := transactions[i]
|
||||
transactionsMap[transaction.TransactionId] = transaction
|
||||
}
|
||||
|
||||
for transactionId, tagIds := range addTransactionTagIds {
|
||||
if transactionId <= 0 {
|
||||
return errs.ErrTransactionIdInvalid
|
||||
}
|
||||
|
||||
transaction, exists := transactionsMap[transactionId]
|
||||
|
||||
if !exists || transaction == nil {
|
||||
return errs.ErrTransactionNotFound
|
||||
}
|
||||
|
||||
tagIds = utils.ToUniqueInt64Slice(tagIds)
|
||||
|
||||
for i := 0; i < len(tagIds); i++ {
|
||||
tagId := tagIds[i]
|
||||
|
||||
if tagId <= 0 {
|
||||
return errs.ErrTransactionTagIdInvalid
|
||||
}
|
||||
|
||||
transactionTagIndexes = append(transactionTagIndexes, &models.TransactionTagIndex{
|
||||
Uid: uid,
|
||||
Deleted: false,
|
||||
TransactionTime: transaction.TransactionTime,
|
||||
TagId: tagId,
|
||||
TransactionId: transactionId,
|
||||
CreatedUnixTime: now,
|
||||
UpdatedUnixTime: now,
|
||||
})
|
||||
|
||||
transactionTagIdsMap[tagId] = true
|
||||
}
|
||||
}
|
||||
|
||||
tagIndexUuids := s.GenerateUuids(uuid.UUID_TYPE_TAG_INDEX, uint16(len(transactionTagIndexes)))
|
||||
|
||||
if len(tagIndexUuids) < len(transactionTagIndexes) {
|
||||
return errs.ErrCannotAddTagsToTooManyTransactionsOneTime
|
||||
}
|
||||
|
||||
for i := 0; i < len(transactionTagIndexes); i++ {
|
||||
transactionTagIndexes[i].TagIndexId = tagIndexUuids[i]
|
||||
}
|
||||
|
||||
tagIds := make([]int64, 0, len(transactionTagIdsMap))
|
||||
|
||||
for tagId := range transactionTagIdsMap {
|
||||
tagIds = append(tagIds, tagId)
|
||||
}
|
||||
|
||||
return s.UserDataDB(uid).DoTransaction(c, func(sess *xorm.Session) error {
|
||||
// Get and verify tags
|
||||
err := s.isTagsValid(sess, uid, transactionTagIndexes, tagIds)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for i := 0; i < len(transactionTagIndexes); i++ {
|
||||
transactionTagIndex := transactionTagIndexes[i]
|
||||
_, err := sess.Insert(transactionTagIndex)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// BatchRemoveTagsFromTransactions batch removes tags from transactions
|
||||
func (s *TransactionService) BatchRemoveTagsFromTransactions(c core.Context, uid int64, transactionIds []int64, tagIds []int64) error {
|
||||
if uid <= 0 {
|
||||
return errs.ErrUserIdInvalid
|
||||
}
|
||||
|
||||
if len(transactionIds) < 1 {
|
||||
return errs.ErrTransactionIdInvalid
|
||||
}
|
||||
|
||||
uniqueTransactionIds := utils.ToUniqueInt64Slice(transactionIds)
|
||||
uniqueTagIds := utils.ToUniqueInt64Slice(tagIds)
|
||||
now := time.Now().Unix()
|
||||
|
||||
tagIndexUpdateModel := &models.TransactionTagIndex{
|
||||
Deleted: true,
|
||||
DeletedUnixTime: now,
|
||||
}
|
||||
|
||||
return s.UserDataDB(uid).DoTransaction(c, func(sess *xorm.Session) error {
|
||||
deletedRows, err := sess.Cols("deleted", "deleted_unix_time").Where("uid=? AND deleted=?", uid, false).In("transaction_id", uniqueTransactionIds).In("tag_id", uniqueTagIds).Update(tagIndexUpdateModel)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
} else if deletedRows < 1 {
|
||||
return errs.ErrTransactionTagNotFound
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// BatchClearAllTagsFromTransactions batch clears all tags from transactions
|
||||
func (s *TransactionService) BatchClearAllTagsFromTransactions(c core.Context, uid int64, transactionIds []int64) error {
|
||||
if uid <= 0 {
|
||||
return errs.ErrUserIdInvalid
|
||||
}
|
||||
|
||||
if len(transactionIds) < 1 {
|
||||
return errs.ErrTransactionIdInvalid
|
||||
}
|
||||
|
||||
uniqueTransactionIds := utils.ToUniqueInt64Slice(transactionIds)
|
||||
now := time.Now().Unix()
|
||||
|
||||
tagIndexUpdateModel := &models.TransactionTagIndex{
|
||||
Deleted: true,
|
||||
DeletedUnixTime: now,
|
||||
}
|
||||
|
||||
return s.UserDataDB(uid).DoTransaction(c, func(sess *xorm.Session) error {
|
||||
deletedRows, err := sess.Cols("deleted", "deleted_unix_time").Where("uid=? AND deleted=?", uid, false).In("transaction_id", uniqueTransactionIds).Update(tagIndexUpdateModel)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
} else if deletedRows < 1 {
|
||||
return errs.ErrTransactionTagNotFound
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// MoveAllTransactionsBetweenAccounts moves all transactions from one account to another account, and combine balance modification transactions if necessary
|
||||
func (s *TransactionService) MoveAllTransactionsBetweenAccounts(c core.Context, uid int64, fromAccountId int64, toAccountId int64) error {
|
||||
if uid <= 0 {
|
||||
return errs.ErrUserIdInvalid
|
||||
@@ -1729,7 +2041,7 @@ func (s *TransactionService) DeleteAllTransactionsOfAccount(c core.Context, uid
|
||||
return errs.ErrAccountIdInvalid
|
||||
}
|
||||
|
||||
transactions, err := s.GetAllSpecifiedTransactions(c, uid, 0, 0, 0, nil, []int64{accountId}, nil, false, "", "", pageCount, true)
|
||||
transactions, err := s.GetAllSpecifiedTransactions(c, uid, 0, 0, 0, nil, []int64{accountId}, nil, false, "", "", false, pageCount, true)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -2236,7 +2548,7 @@ func (s *TransactionService) doCreateTransaction(c core.Context, database *datas
|
||||
}
|
||||
|
||||
// Get and verify tags
|
||||
err = s.isTagsValid(sess, transaction, transactionTagIndexes, tagIds)
|
||||
err = s.isTagsValid(sess, transaction.Uid, transactionTagIndexes, tagIds)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -2662,6 +2974,17 @@ func (s *TransactionService) appendFilterTagIdsConditionToQuery(sess *xorm.Sessi
|
||||
return sess
|
||||
}
|
||||
|
||||
func (s *TransactionService) appendFilterPicturesConditionToQuery(sess *xorm.Session, uid int64, mustHavePictures bool) *xorm.Session {
|
||||
if !mustHavePictures {
|
||||
return sess
|
||||
}
|
||||
|
||||
subQuery := builder.Select("transaction_id").From("transaction_picture_info").Where(builder.And(builder.Eq{"uid": uid}, builder.Eq{"deleted": false}, builder.Neq{"transaction_id": models.TransactionPictureNewPictureTransactionId}))
|
||||
sess.And(builder.Or(builder.In("transaction_id", subQuery), builder.In("related_id", subQuery)))
|
||||
|
||||
return sess
|
||||
}
|
||||
|
||||
func (s *TransactionService) isAccountIdValid(transaction *models.Transaction) error {
|
||||
if transaction.Type == models.TRANSACTION_DB_TYPE_MODIFY_BALANCE {
|
||||
if transaction.RelatedAccountId != 0 && transaction.RelatedAccountId != transaction.AccountId {
|
||||
@@ -2873,10 +3196,10 @@ func (s *TransactionService) isCategoryValid(sess *xorm.Session, transaction *mo
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *TransactionService) isTagsValid(sess *xorm.Session, transaction *models.Transaction, transactionTagIndexes []*models.TransactionTagIndex, tagIds []int64) error {
|
||||
func (s *TransactionService) isTagsValid(sess *xorm.Session, uid int64, transactionTagIndexes []*models.TransactionTagIndex, tagIds []int64) error {
|
||||
if len(transactionTagIndexes) > 0 {
|
||||
var tags []*models.TransactionTag
|
||||
err := sess.Where("uid=? AND deleted=?", transaction.Uid, false).In("tag_id", tagIds).Find(&tags)
|
||||
err := sess.Where("uid=? AND deleted=?", uid, false).In("tag_id", tagIds).Find(&tags)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
+32
-2
@@ -234,7 +234,7 @@ func (s *UserService) CreateUser(c core.Context, user *models.User, noPassword b
|
||||
}
|
||||
|
||||
// UpdateUser saves an existed user model to database
|
||||
func (s *UserService) UpdateUser(c core.Context, user *models.User, modifyUserLanguage bool) (keyProfileUpdated bool, emailSetToUnverified bool, err error) {
|
||||
func (s *UserService) UpdateUser(c core.Context, user *models.User, modifyUserLanguage bool, modifyUseLastReconciledTime bool) (keyProfileUpdated bool, emailSetToUnverified bool, err error) {
|
||||
if user.Uid <= 0 {
|
||||
return false, false, errs.ErrUserIdInvalid
|
||||
}
|
||||
@@ -277,7 +277,11 @@ func (s *UserService) UpdateUser(c core.Context, user *models.User, modifyUserLa
|
||||
updateCols = append(updateCols, "default_account_id")
|
||||
}
|
||||
|
||||
if models.TRANSACTION_EDIT_SCOPE_NONE <= user.TransactionEditScope && user.TransactionEditScope <= models.TRANSACTION_EDIT_SCOPE_THIS_YEAR_OR_LATER {
|
||||
if modifyUseLastReconciledTime {
|
||||
updateCols = append(updateCols, "use_last_reconciled_time")
|
||||
}
|
||||
|
||||
if models.TRANSACTION_EDIT_SCOPE_NONE <= user.TransactionEditScope && user.TransactionEditScope <= models.TRANSACTION_EDIT_SCOPE_LAST_RECONCILED_TIME_OR_LATER {
|
||||
updateCols = append(updateCols, "transaction_edit_scope")
|
||||
}
|
||||
|
||||
@@ -379,6 +383,32 @@ func (s *UserService) UpdateUser(c core.Context, user *models.User, modifyUserLa
|
||||
return keyProfileUpdated, emailSetToUnverified, nil
|
||||
}
|
||||
|
||||
// UpdateUserPassword updates the password of specified user
|
||||
func (s *UserService) UpdateUserPassword(c core.Context, user *models.User) error {
|
||||
if user.Uid <= 0 {
|
||||
return errs.ErrUserIdInvalid
|
||||
}
|
||||
|
||||
if user.Password == "" {
|
||||
return errs.ErrPasswordIsEmpty
|
||||
}
|
||||
|
||||
user.Password = utils.EncodePassword(user.Password, user.Salt)
|
||||
user.UpdatedUnixTime = time.Now().Unix()
|
||||
|
||||
return s.UserDB().DoTransaction(c, func(sess *xorm.Session) error {
|
||||
updatedRows, err := sess.ID(user.Uid).Cols("password", "updated_unix_time").Where("deleted=?", false).Update(user)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
} else if updatedRows < 1 {
|
||||
return errs.ErrUserNotFound
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateUserAvatar updates the custom avatar type of specified user
|
||||
func (s *UserService) UpdateUserAvatar(c core.Context, uid int64, avatarFile multipart.File, fileExtension string, oldFileExtension string) error {
|
||||
if uid <= 0 {
|
||||
|
||||
+102
-47
@@ -69,11 +69,14 @@ const (
|
||||
)
|
||||
|
||||
const (
|
||||
OpenAILLMProvider string = "openai"
|
||||
OpenAICompatibleLLMProvider string = "openai_compatible"
|
||||
OpenRouterLLMProvider string = "openrouter"
|
||||
OllamaLLMProvider string = "ollama"
|
||||
GoogleAILLMProvider string = "google_ai"
|
||||
OpenAILLMProvider string = "openai"
|
||||
OpenAICompatibleLLMProvider string = "openai_compatible"
|
||||
AnthropicLLMProvider string = "anthropic"
|
||||
AnthropicCompatibleLLMProvider string = "anthropic_compatible"
|
||||
OpenRouterLLMProvider string = "openrouter"
|
||||
OllamaLLMProvider string = "ollama"
|
||||
LMStudioLLMProvider string = "lm_studio"
|
||||
GoogleAILLMProvider string = "google_ai"
|
||||
)
|
||||
|
||||
// Uuid generator types
|
||||
@@ -125,23 +128,23 @@ const (
|
||||
|
||||
// Exchange rates data source types
|
||||
const (
|
||||
ReserveBankOfAustraliaDataSource string = "reserve_bank_of_australia"
|
||||
BankOfCanadaDataSource string = "bank_of_canada"
|
||||
CzechNationalBankDataSource string = "czech_national_bank"
|
||||
DanmarksNationalbankDataSource string = "danmarks_national_bank"
|
||||
EuroCentralBankDataSource string = "euro_central_bank"
|
||||
NationalBankOfGeorgiaDataSource string = "national_bank_of_georgia"
|
||||
CentralBankOfHungaryDataSource string = "central_bank_of_hungary"
|
||||
BankOfIsraelDataSource string = "bank_of_israel"
|
||||
CentralBankOfMyanmarDataSource string = "central_bank_of_myanmar"
|
||||
NorgesBankDataSource string = "norges_bank"
|
||||
NationalBankOfPolandDataSource string = "national_bank_of_poland"
|
||||
NationalBankOfRomaniaDataSource string = "national_bank_of_romania"
|
||||
BankOfRussiaDataSource string = "bank_of_russia"
|
||||
SwissNationalBankDataSource string = "swiss_national_bank"
|
||||
NationalBankOfUkraineDataSource string = "national_bank_of_ukraine"
|
||||
CentralBankOfUzbekistanDataSource string = "central_bank_of_uzbekistan"
|
||||
UserCustomExchangeRatesDataSource string = "user_custom"
|
||||
BankOfCanadaDataSource string = "bank_of_canada"
|
||||
CzechNationalBankDataSource string = "czech_national_bank"
|
||||
DanmarksNationalbankDataSource string = "danmarks_national_bank"
|
||||
EuroCentralBankDataSource string = "euro_central_bank"
|
||||
NationalBankOfGeorgiaDataSource string = "national_bank_of_georgia"
|
||||
CentralBankOfHungaryDataSource string = "central_bank_of_hungary"
|
||||
BankOfIsraelDataSource string = "bank_of_israel"
|
||||
NationalBankOfKazakhstanDataSource string = "national_bank_of_kazakhstan"
|
||||
CentralBankOfMyanmarDataSource string = "central_bank_of_myanmar"
|
||||
NorgesBankDataSource string = "norges_bank"
|
||||
NationalBankOfPolandDataSource string = "national_bank_of_poland"
|
||||
NationalBankOfRomaniaDataSource string = "national_bank_of_romania"
|
||||
BankOfRussiaDataSource string = "bank_of_russia"
|
||||
SwissNationalBankDataSource string = "swiss_national_bank"
|
||||
NationalBankOfUkraineDataSource string = "national_bank_of_ukraine"
|
||||
CentralBankOfUzbekistanDataSource string = "central_bank_of_uzbekistan"
|
||||
UserCustomExchangeRatesDataSource string = "user_custom"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -161,8 +164,9 @@ const (
|
||||
|
||||
defaultWebDAVRequestTimeout uint32 = 10000 // 10 seconds
|
||||
|
||||
defaultAIRecognitionPictureMaxSize uint32 = 10485760 // 10MB
|
||||
defaultLargeLanguageModelAPIRequestTimeout uint32 = 60000 // 60 seconds
|
||||
defaultAIRecognitionPictureMaxSize uint32 = 10485760 // 10MB
|
||||
defaultAnthropicLargeLanguageModelAPIMaximumTokens uint32 = 1024
|
||||
defaultLargeLanguageModelAPIRequestTimeout uint32 = 60000 // 60 seconds
|
||||
|
||||
defaultInMemoryDuplicateCheckerCleanupInterval uint32 = 60 // 1 minutes
|
||||
defaultDuplicateSubmissionsInterval uint32 = 300 // 5 minutes
|
||||
@@ -244,10 +248,21 @@ type LLMConfig struct {
|
||||
OpenAICompatibleBaseURL string
|
||||
OpenAICompatibleAPIKey string
|
||||
OpenAICompatibleModelID string
|
||||
AnthropicAPIKey string
|
||||
AnthropicModelID string
|
||||
AnthropicMaxTokens uint32
|
||||
AnthropicCompatibleBaseURL string
|
||||
AnthropicCompatibleAPIVersion string
|
||||
AnthropicCompatibleAPIKey string
|
||||
AnthropicCompatibleModelID string
|
||||
AnthropicCompatibleMaxTokens uint32
|
||||
OpenRouterAPIKey string
|
||||
OpenRouterModelID string
|
||||
OllamaServerURL string
|
||||
OllamaModelID string
|
||||
LMStudioServerURL string
|
||||
LMStudioToken string
|
||||
LMStudioModelID string
|
||||
GoogleAIAPIKey string
|
||||
GoogleAIModelID string
|
||||
LargeLanguageModelAPIRequestTimeout uint32
|
||||
@@ -356,6 +371,7 @@ type Config struct {
|
||||
PasswordResetTokenExpiredTime uint32
|
||||
PasswordResetTokenExpiredTimeDuration time.Duration
|
||||
EnableAPIToken bool
|
||||
APITokenAllowedRemoteIPs []*core.IPPattern
|
||||
MaxFailuresPerIpPerMinute uint32
|
||||
MaxFailuresPerUserPerMinute uint32
|
||||
|
||||
@@ -653,29 +669,13 @@ func loadServerConfiguration(config *Config, configFile *ini.File, sectionName s
|
||||
}
|
||||
|
||||
func loadMCPServerConfiguration(config *Config, configFile *ini.File, sectionName string) error {
|
||||
var err error
|
||||
|
||||
config.EnableMCPServer = getConfigItemBoolValue(configFile, sectionName, "enable_mcp", false)
|
||||
mcpAllowedRemoteIps := getConfigItemStringValue(configFile, sectionName, "mcp_allowed_remote_ips", "")
|
||||
config.MCPAllowedRemoteIPs, err = getIPPatterns(configFile, sectionName, "mcp_allowed_remote_ips", "")
|
||||
|
||||
if mcpAllowedRemoteIps != "" {
|
||||
remoteIPs := strings.Split(mcpAllowedRemoteIps, ",")
|
||||
config.MCPAllowedRemoteIPs = make([]*core.IPPattern, 0, len(remoteIPs))
|
||||
|
||||
for i := 0; i < len(remoteIPs); i++ {
|
||||
ip := strings.TrimSpace(remoteIPs[i])
|
||||
pattern, err := core.ParseIPPattern(ip)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if pattern == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
config.MCPAllowedRemoteIPs = append(config.MCPAllowedRemoteIPs, pattern)
|
||||
}
|
||||
} else {
|
||||
config.MCPAllowedRemoteIPs = nil
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -860,10 +860,16 @@ func loadLLMConfiguration(configFile *ini.File, sectionName string) (*LLMConfig,
|
||||
llmConfig.LLMProvider = OpenAILLMProvider
|
||||
} else if llmProvider == OpenAICompatibleLLMProvider {
|
||||
llmConfig.LLMProvider = OpenAICompatibleLLMProvider
|
||||
} else if llmProvider == AnthropicLLMProvider {
|
||||
llmConfig.LLMProvider = AnthropicLLMProvider
|
||||
} else if llmProvider == AnthropicCompatibleLLMProvider {
|
||||
llmConfig.LLMProvider = AnthropicCompatibleLLMProvider
|
||||
} else if llmProvider == OpenRouterLLMProvider {
|
||||
llmConfig.LLMProvider = OpenRouterLLMProvider
|
||||
} else if llmProvider == OllamaLLMProvider {
|
||||
llmConfig.LLMProvider = OllamaLLMProvider
|
||||
} else if llmProvider == LMStudioLLMProvider {
|
||||
llmConfig.LLMProvider = LMStudioLLMProvider
|
||||
} else if llmProvider == GoogleAILLMProvider {
|
||||
llmConfig.LLMProvider = GoogleAILLMProvider
|
||||
} else {
|
||||
@@ -877,12 +883,26 @@ func loadLLMConfiguration(configFile *ini.File, sectionName string) (*LLMConfig,
|
||||
llmConfig.OpenAICompatibleAPIKey = getConfigItemStringValue(configFile, sectionName, "openai_compatible_api_key")
|
||||
llmConfig.OpenAICompatibleModelID = getConfigItemStringValue(configFile, sectionName, "openai_compatible_model_id")
|
||||
|
||||
llmConfig.AnthropicAPIKey = getConfigItemStringValue(configFile, sectionName, "anthropic_api_key")
|
||||
llmConfig.AnthropicModelID = getConfigItemStringValue(configFile, sectionName, "anthropic_model_id")
|
||||
llmConfig.AnthropicMaxTokens = getConfigItemUint32Value(configFile, sectionName, "anthropic_max_tokens", defaultAnthropicLargeLanguageModelAPIMaximumTokens)
|
||||
|
||||
llmConfig.AnthropicCompatibleBaseURL = getConfigItemStringValue(configFile, sectionName, "anthropic_compatible_base_url")
|
||||
llmConfig.AnthropicCompatibleAPIVersion = getConfigItemStringValue(configFile, sectionName, "anthropic_compatible_api_version")
|
||||
llmConfig.AnthropicCompatibleAPIKey = getConfigItemStringValue(configFile, sectionName, "anthropic_compatible_api_key")
|
||||
llmConfig.AnthropicCompatibleModelID = getConfigItemStringValue(configFile, sectionName, "anthropic_compatible_model_id")
|
||||
llmConfig.AnthropicCompatibleMaxTokens = getConfigItemUint32Value(configFile, sectionName, "anthropic_compatible_max_tokens", defaultAnthropicLargeLanguageModelAPIMaximumTokens)
|
||||
|
||||
llmConfig.OpenRouterAPIKey = getConfigItemStringValue(configFile, sectionName, "openrouter_api_key")
|
||||
llmConfig.OpenRouterModelID = getConfigItemStringValue(configFile, sectionName, "openrouter_model_id")
|
||||
|
||||
llmConfig.OllamaServerURL = getConfigItemStringValue(configFile, sectionName, "ollama_server_url")
|
||||
llmConfig.OllamaModelID = getConfigItemStringValue(configFile, sectionName, "ollama_model_id")
|
||||
|
||||
llmConfig.LMStudioServerURL = getConfigItemStringValue(configFile, sectionName, "lm_studio_server_url")
|
||||
llmConfig.LMStudioToken = getConfigItemStringValue(configFile, sectionName, "lm_studio_token")
|
||||
llmConfig.LMStudioModelID = getConfigItemStringValue(configFile, sectionName, "lm_studio_model_id")
|
||||
|
||||
llmConfig.GoogleAIAPIKey = getConfigItemStringValue(configFile, sectionName, "google_ai_api_key")
|
||||
llmConfig.GoogleAIModelID = getConfigItemStringValue(configFile, sectionName, "google_ai_model_id")
|
||||
|
||||
@@ -942,6 +962,8 @@ func loadCronConfiguration(config *Config, configFile *ini.File, sectionName str
|
||||
}
|
||||
|
||||
func loadSecurityConfiguration(config *Config, configFile *ini.File, sectionName string) error {
|
||||
var err error
|
||||
|
||||
config.SecretKeyNoSet = !getConfigItemIsSet(configFile, sectionName, "secret_key")
|
||||
config.SecretKey = getConfigItemStringValue(configFile, sectionName, "secret_key", defaultSecretKey)
|
||||
|
||||
@@ -984,6 +1006,11 @@ func loadSecurityConfiguration(config *Config, configFile *ini.File, sectionName
|
||||
config.PasswordResetTokenExpiredTimeDuration = time.Duration(config.PasswordResetTokenExpiredTime) * time.Second
|
||||
|
||||
config.EnableAPIToken = getConfigItemBoolValue(configFile, sectionName, "enable_api_token", false)
|
||||
config.APITokenAllowedRemoteIPs, err = getIPPatterns(configFile, sectionName, "api_token_allowed_remote_ips", "")
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
config.MaxFailuresPerIpPerMinute = getConfigItemUint32Value(configFile, sectionName, "max_failures_per_ip_per_minute", defaultMaxFailuresPerIpPerMinute)
|
||||
config.MaxFailuresPerUserPerMinute = getConfigItemUint32Value(configFile, sectionName, "max_failures_per_user_per_minute", defaultMaxFailuresPerUserPerMinute)
|
||||
@@ -1163,14 +1190,14 @@ func loadMapConfiguration(config *Config, configFile *ini.File, sectionName stri
|
||||
func loadExchangeRatesConfiguration(config *Config, configFile *ini.File, sectionName string) error {
|
||||
dataSource := getConfigItemStringValue(configFile, sectionName, "data_source")
|
||||
|
||||
if dataSource == ReserveBankOfAustraliaDataSource ||
|
||||
dataSource == BankOfCanadaDataSource ||
|
||||
if dataSource == BankOfCanadaDataSource ||
|
||||
dataSource == CzechNationalBankDataSource ||
|
||||
dataSource == DanmarksNationalbankDataSource ||
|
||||
dataSource == EuroCentralBankDataSource ||
|
||||
dataSource == NationalBankOfGeorgiaDataSource ||
|
||||
dataSource == CentralBankOfHungaryDataSource ||
|
||||
dataSource == BankOfIsraelDataSource ||
|
||||
dataSource == NationalBankOfKazakhstanDataSource ||
|
||||
dataSource == CentralBankOfMyanmarDataSource ||
|
||||
dataSource == NorgesBankDataSource ||
|
||||
dataSource == NationalBankOfPolandDataSource ||
|
||||
@@ -1227,6 +1254,34 @@ func getFinalPath(workingPath, p string) (string, error) {
|
||||
return p, err
|
||||
}
|
||||
|
||||
func getIPPatterns(configFile *ini.File, sectionName string, itemName string, defaultValue string) ([]*core.IPPattern, error) {
|
||||
configValue := getConfigItemStringValue(configFile, sectionName, itemName, defaultValue)
|
||||
|
||||
if configValue == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
remoteIPs := strings.Split(configValue, ",")
|
||||
ipPatterns := make([]*core.IPPattern, 0, len(remoteIPs))
|
||||
|
||||
for i := 0; i < len(remoteIPs); i++ {
|
||||
ip := strings.TrimSpace(remoteIPs[i])
|
||||
pattern, err := core.ParseIPPattern(ip)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if pattern == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
ipPatterns = append(ipPatterns, pattern)
|
||||
}
|
||||
|
||||
return ipPatterns, nil
|
||||
}
|
||||
|
||||
func getMultiLanguageContentConfig(configFile *ini.File, sectionName string, enableKey string, contentKey string) MultiLanguageContentConfig {
|
||||
config := MultiLanguageContentConfig{
|
||||
Enabled: getConfigItemBoolValue(configFile, sectionName, enableKey, false),
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
)
|
||||
|
||||
// ConfigContainer contains the current setting config
|
||||
type ConfigContainer struct {
|
||||
current *Config
|
||||
@@ -13,10 +7,7 @@ type ConfigContainer struct {
|
||||
|
||||
// Initialize a config container singleton instance
|
||||
var (
|
||||
Version string
|
||||
CommitHash string
|
||||
BuildTime string
|
||||
Container = &ConfigContainer{}
|
||||
Container = &ConfigContainer{}
|
||||
)
|
||||
|
||||
// SetCurrentConfig sets the current config by a given config
|
||||
@@ -28,11 +19,3 @@ func SetCurrentConfig(config *Config) {
|
||||
func (c *ConfigContainer) GetCurrentConfig() *Config {
|
||||
return c.current
|
||||
}
|
||||
|
||||
func GetUserAgent() string {
|
||||
if Version == "" {
|
||||
return core.ApplicationName
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s/%s", core.ApplicationName, Version)
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ func NewWebDAVObjectStorage(config *settings.Config, pathPrefix string) (*WebDAV
|
||||
webDavConfig := config.WebDAVConfig
|
||||
|
||||
storage := &WebDAVObjectStorage{
|
||||
httpClient: httpclient.NewHttpClient(webDavConfig.RequestTimeout, webDavConfig.Proxy, webDavConfig.SkipTLSVerify, settings.GetUserAgent(), false),
|
||||
httpClient: httpclient.NewHttpClient(webDavConfig.RequestTimeout, webDavConfig.Proxy, webDavConfig.SkipTLSVerify, core.GetOutgoingUserAgent(), false),
|
||||
webDavConfig: webDavConfig,
|
||||
rootPath: webDavConfig.RootPath,
|
||||
}
|
||||
|
||||
@@ -286,6 +286,12 @@ func IsUnixTimeEqualsYearAndMonth(unixTime int64, timezone *time.Location, year
|
||||
return date.Year() == int(year) && int(date.Month()) == int(month)
|
||||
}
|
||||
|
||||
// GetMaxDayOfMonth returns the maximum day of the month for the specified year and month
|
||||
func GetMaxDayOfMonth(year int, month time.Month) int {
|
||||
t := time.Date(year, month+1, 0, 0, 0, 0, 0, time.UTC)
|
||||
return t.Day()
|
||||
}
|
||||
|
||||
// GetTimezoneOffsetMinutes returns offset minutes according specified timezone
|
||||
func GetTimezoneOffsetMinutes(unixTime int64, timezone *time.Location) int16 {
|
||||
_, tzOffset := parseFromUnixTime(unixTime).In(timezone).Zone()
|
||||
|
||||
@@ -333,6 +333,32 @@ func TestIsUnixTimeEqualsYearAndMonth(t *testing.T) {
|
||||
assert.Equal(t, false, actualValue)
|
||||
}
|
||||
|
||||
func TestGetMaxDayOfMonth(t *testing.T) {
|
||||
expectedValue := 31
|
||||
actualValue := GetMaxDayOfMonth(2023, 1)
|
||||
assert.Equal(t, expectedValue, actualValue)
|
||||
|
||||
expectedValue = 28
|
||||
actualValue = GetMaxDayOfMonth(2023, 2)
|
||||
assert.Equal(t, expectedValue, actualValue)
|
||||
|
||||
expectedValue = 29
|
||||
actualValue = GetMaxDayOfMonth(2024, 2)
|
||||
assert.Equal(t, expectedValue, actualValue)
|
||||
|
||||
expectedValue = 30
|
||||
actualValue = GetMaxDayOfMonth(2023, 4)
|
||||
assert.Equal(t, expectedValue, actualValue)
|
||||
|
||||
expectedValue = 31
|
||||
actualValue = GetMaxDayOfMonth(2023, 12)
|
||||
assert.Equal(t, expectedValue, actualValue)
|
||||
|
||||
expectedValue = 28
|
||||
actualValue = GetMaxDayOfMonth(2100, 2)
|
||||
assert.Equal(t, expectedValue, actualValue)
|
||||
}
|
||||
|
||||
func TestGetTimezoneOffsetMinutes_FixedTimezone(t *testing.T) {
|
||||
timezone := time.FixedZone("Test Timezone", 120*60)
|
||||
expectedValue := int16(120)
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
---
|
||||
name: ezbookkeeping
|
||||
description: Use ezBookkeeping API Tools script to record new transactions, query transactions, retrieve account information, retrieve categories, retrieve tags, and retrieve exchange rate data in the self hosted personal finance application ezBookkeeping.
|
||||
---
|
||||
|
||||
# ezBookkeeping API Tools
|
||||
|
||||
## Usage
|
||||
|
||||
### List all supported commands
|
||||
|
||||
Linux / macOS
|
||||
|
||||
```bash
|
||||
sh scripts/ebktools.sh list
|
||||
```
|
||||
|
||||
Windows
|
||||
|
||||
```powershell
|
||||
scripts\ebktools.ps1 list
|
||||
```
|
||||
|
||||
### Show help for a specific command
|
||||
|
||||
Linux / macOS
|
||||
|
||||
```bash
|
||||
sh scripts/ebktools.sh help <command>
|
||||
```
|
||||
|
||||
Windows
|
||||
|
||||
```powershell
|
||||
scripts\ebktools.ps1 help <command>
|
||||
```
|
||||
|
||||
### Call API
|
||||
|
||||
Linux / macOS
|
||||
|
||||
```bash
|
||||
sh scripts/ebktools.sh [global-options] <command> [command-options]
|
||||
```
|
||||
|
||||
Windows
|
||||
|
||||
```powershell
|
||||
scripts\ebktools.ps1 [global-options] <command> [command-options]
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If the script reports that the environment variable `EBKTOOL_SERVER_BASEURL` or `EBKTOOL_TOKEN` is not set, user can define them as system environment variables, or create a `.env` file in the user home directory that contains these two variables and place it there.
|
||||
|
||||
The meanings of these environment variables are as follows:
|
||||
|
||||
| Variable | Required | Description |
|
||||
| --- | --- | --- |
|
||||
| `EBKTOOL_SERVER_BASEURL` | Required | ezBookkeeping server base URL (e.g., `http://localhost:8080`) |
|
||||
| `EBKTOOL_TOKEN` | Required | ezBookkeeping API token |
|
||||
|
||||
## Reference
|
||||
|
||||
ezBookkeeping: [https://ezbookkeeping.mayswind.net](https://ezbookkeeping.mayswind.net)
|
||||
Executable
+1533
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user