Compare commits
336 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c8bb5a0b7 | |||
| d3abb279e3 | |||
| 952731a2d4 | |||
| df23cb8cdd | |||
| 87a21a1a4f | |||
| 7c3c1bbd6a | |||
| 03c342f6f6 | |||
| b0e01d36ab | |||
| bb84e8af13 | |||
| f42ee9cf67 | |||
| 8a0232aedf | |||
| f3ccd3b66d | |||
| b690316aa7 | |||
| 8a0777be4c | |||
| 013f44f64a | |||
| 9f8dbf77df | |||
| 274fb8b4e2 | |||
| 48a06c6570 | |||
| 5485242baf | |||
| ec7c4c7461 | |||
| 2259719935 | |||
| f8fc955408 | |||
| 4684de9705 | |||
| 52bab6f726 | |||
| 765e64d96f | |||
| f93610b5e0 | |||
| 5d1480cabc | |||
| 5faf3bfe66 | |||
| 5cb7eca340 | |||
| 9a2f682379 | |||
| fd4036f0c8 | |||
| c854dbaab4 | |||
| 0c75ed47ac | |||
| fa467e72f9 | |||
| 93e05d5634 | |||
| 745efe1222 | |||
| e1dcf56ca9 | |||
| 3aa33a48e9 | |||
| 29547bccb1 | |||
| 4823760fd1 | |||
| 8584e84af9 | |||
| af586a0432 | |||
| ce752c992c | |||
| 7b49a9f142 | |||
| 2fc5e91cc4 | |||
| f6d03bf5df | |||
| a17a2cc377 | |||
| beea6fe733 | |||
| 85b05f9e7e | |||
| d3ab2b94b7 | |||
| b21fff5b15 | |||
| 234e7a55ff | |||
| d4cf8fe077 | |||
| 2b2a266533 | |||
| 4b35103e34 | |||
| 81a5585029 | |||
| d2b89e629a | |||
| cab86eec68 | |||
| 295f5cc14a | |||
| 6395e3b5c1 | |||
| a42c5fa988 | |||
| 46e275d843 | |||
| 13ada3575a | |||
| 3b0e0f1a3f | |||
| 512acc5a49 | |||
| 1f101fea3e | |||
| 83bd8f23f4 | |||
| af56c3057c | |||
| 53a8ad71c6 | |||
| 600ae2bd58 | |||
| 60b6ed51cd | |||
| 8a947ef224 | |||
| d936b64cf9 | |||
| ab828ebdab | |||
| b444e8ee31 | |||
| 45f1177a73 | |||
| 64e7dc5e12 | |||
| e62bebb7fa | |||
| 3990a072ca | |||
| a5bd12945d | |||
| 7938e7c7c8 | |||
| 130a157abc | |||
| cce19ae957 | |||
| e90340fec4 | |||
| 22061e535a | |||
| 23a85d6162 | |||
| 2cb47bfd75 | |||
| 3ce7f6e99a | |||
| 77b083c41b | |||
| b8fcdacb84 | |||
| d893193e73 | |||
| 73f8446d07 | |||
| 5692bec216 | |||
| e88491268b | |||
| 94cd5dc21a | |||
| 697f69d5d7 | |||
| 76fff27b3f | |||
| 4f664dbfc3 | |||
| d9b726cdf9 | |||
| cc792b9c0f | |||
| b916217b28 | |||
| 6c4b7059ed | |||
| 9d0e294ed2 | |||
| dc6d8398b1 | |||
| a50d2e7e72 | |||
| ea5cfe60f2 | |||
| 5b37ea4d78 | |||
| 46dd2888a6 | |||
| 564c9e1d95 | |||
| 4d0d3959a9 | |||
| e9e6644e7f | |||
| 6a19131ea1 | |||
| 4d9643dcb2 | |||
| 5dc4ad60ba | |||
| 2e5dd7d513 | |||
| efe088f591 | |||
| d5016e853e | |||
| d334bd7b9a | |||
| 21edf0157a | |||
| 388167705a | |||
| 2423b37cbb | |||
| 786796d457 | |||
| 0ed9216260 | |||
| eb13f10121 | |||
| 76ce6f6f9c | |||
| eb305139f5 | |||
| 8df73f202a | |||
| c22751de6f | |||
| c3f1cb0c61 | |||
| fc1fc58aa1 | |||
| e4b5e96534 | |||
| 66303a8965 | |||
| 9589dd2486 | |||
| 3d5b887e23 | |||
| b967a214cb | |||
| 5a9877588f | |||
| fc5f8e4633 | |||
| 028bca50ea | |||
| 6853bbfb68 | |||
| d4fee27a3d | |||
| 245fdd78e4 | |||
| cbe784172e | |||
| bf21e45cba | |||
| 359c430a39 | |||
| 669a217180 | |||
| e9507241ed | |||
| f2536749f6 | |||
| 118558d25b | |||
| d9cd270ff4 | |||
| 9dee449f10 | |||
| ae19ca4383 | |||
| 32fed8d6fb | |||
| ec325c9e6b | |||
| 5fbb29abd3 | |||
| f06c6523a2 | |||
| 02514fc457 | |||
| 5d88287ae2 | |||
| 00f1d0418f | |||
| 18b270debb | |||
| d947164eb6 | |||
| 1a1bb6077c | |||
| 05b5cab12b | |||
| a82fdd4946 | |||
| 4def7ed60c | |||
| d50ce0140f | |||
| 51678aee04 | |||
| 019689087d | |||
| 0c1d77f7ae | |||
| 8de51e6e71 | |||
| dc993da218 | |||
| 983f7fec0f | |||
| ce74c4817b | |||
| bc363438f1 | |||
| 979b16d520 | |||
| 9686eb020f | |||
| 88dea9acaa | |||
| c75fdfea1c | |||
| 538d2b8205 | |||
| 30d36a3b07 | |||
| 95bcd8e4c8 | |||
| 1a8ce7d58d | |||
| 4700446ca0 | |||
| 67bc81d3e2 | |||
| 878a3a018e | |||
| e463c2dc95 | |||
| 422cf49517 | |||
| 77d2426c14 | |||
| 1c4dc55bb6 | |||
| ba72f421dc | |||
| 36d1e01008 | |||
| e52c7037c7 | |||
| f5235ba08e | |||
| adc4899ea6 | |||
| 34c5a1750e | |||
| c75a902d84 | |||
| 7e2e1a4ad3 | |||
| d4603a1892 | |||
| 642e51bc0c | |||
| 5591abdb3b | |||
| ce9378c43f | |||
| 3ae72623ad | |||
| affc02655b | |||
| a469d66358 | |||
| 757f9e5b02 | |||
| 8368b02be8 | |||
| e15a5617e6 | |||
| f604b2c766 | |||
| d6dc9f8170 | |||
| a71be1bf05 | |||
| bcf11631d6 | |||
| 989183c8be | |||
| 8bd0fd88af | |||
| 20e2444307 | |||
| 8154bd712b | |||
| 4d0e376568 | |||
| 32cf41a7a0 | |||
| e85a4701ed | |||
| b79ffafaee | |||
| 8f6adaa417 | |||
| 0e634d83f4 | |||
| af8cbe0b15 | |||
| 411130db4e | |||
| c099443783 | |||
| 23ffdbb163 | |||
| 0b48502a10 | |||
| 25681f622d | |||
| f196ce969b | |||
| 0408c470fc | |||
| 01aeb945ff | |||
| 601a1f83c6 | |||
| 2a470742e0 | |||
| 8ba1e1997f | |||
| 27ae401a7f | |||
| 81727d3b1e | |||
| 06a0501633 | |||
| 781c2d9044 | |||
| 15e4ad00ee | |||
| 8064a00252 | |||
| f2d0fe407b | |||
| 9589657fd5 | |||
| 790837076f | |||
| 6d923027a0 | |||
| 13d5759e84 | |||
| efe39c7390 | |||
| c00770201b | |||
| 4eff3a337f | |||
| 451385011e | |||
| cd4d230d29 | |||
| ab6d4ee6fc | |||
| 274aa6a17c | |||
| 2f8d4ad5e4 | |||
| fe59d3b280 | |||
| e2c99c4f04 | |||
| 127393b64a | |||
| f3d240442b | |||
| 55bf8b9e30 | |||
| eadcf7768f | |||
| 876bf8cc31 | |||
| 6b5aac0111 | |||
| dc4a4e1463 | |||
| 0677ed07db | |||
| ecf6fbd187 | |||
| 351cebe169 | |||
| 0f94a90882 | |||
| 04996d784f | |||
| aafcfeda84 | |||
| 7283b724b1 | |||
| 0d55912f6c | |||
| 60108e26c7 | |||
| be129cd3c6 | |||
| f210bfa9f4 | |||
| 263113a67f | |||
| 3b29303237 | |||
| 6e5f857e97 | |||
| 791c0ea26e | |||
| 84523d8b8a | |||
| d35e127b9e | |||
| ebe00d3271 | |||
| 14b4e40039 | |||
| 15d1d269ae | |||
| e90b76c80e | |||
| e28e27080a | |||
| 2268496dcb | |||
| 3781327c58 | |||
| 51c33d7e83 | |||
| 975a56e7d9 | |||
| 29a87dcfaf | |||
| cad53d0bfc | |||
| 56a3905df1 | |||
| 428a1f2156 | |||
| b5233399e6 | |||
| f8878c5405 | |||
| 8dcaa457f9 | |||
| b24ebdb83e | |||
| d41a2141a7 | |||
| 09a1dd0358 | |||
| 531c4a44d5 | |||
| ceecff8c24 | |||
| f32cc4ab04 | |||
| 8fa46281e0 | |||
| f7bc4b3ab6 | |||
| ad4f5bd88d | |||
| e4cb66718d | |||
| 175b272fa0 | |||
| ca0fb9446b | |||
| 6eb749dca2 | |||
| 880b614636 | |||
| d146a99c65 | |||
| fd99c784b3 | |||
| 22f9c5243a | |||
| 67f5aaa5ee | |||
| 713b621169 | |||
| 80df5f95aa | |||
| 1e492d8724 | |||
| 602f15fe2e | |||
| 3335533a18 | |||
| d385358aa3 | |||
| d6ee8a416f | |||
| c5aa37037f | |||
| 6050f5deab | |||
| 5d07d1a70d | |||
| bae330c6f3 | |||
| ea17994c6c | |||
| c3d29ee2f8 | |||
| 515b9af61a | |||
| 4ba3893b83 | |||
| bcb6c4f419 | |||
| 53f101fb60 | |||
| 8da4f65048 | |||
| 428bcba56e | |||
| 68e896d8eb | |||
| eef62722a4 | |||
| e3dcb2ce0c | |||
| 0cf89562cd | |||
| 8b06731cdb | |||
| 36abd1acec |
@@ -10,7 +10,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
@@ -25,14 +25,15 @@ jobs:
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
with:
|
||||
image: tonistiigi/binfmt:qemu-v8.1.5
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Set up the environment
|
||||
id: setup
|
||||
run: |
|
||||
echo "build_unix_time=$(date '+%s')" >> "$GITHUB_OUTPUT"
|
||||
echo "build_date=$(date '+%Y%m%d')" >> "$GITHUB_OUTPUT"
|
||||
sed -r -i 's#FROM( --.*)? (.*:.*)?#FROM\1 ${{ secrets.DOCKER_REPO }}/mirrors/\2#g' Dockerfile
|
||||
cat >> docker/custom-backend-pre-setup.sh <<EOF
|
||||
#!/bin/sh
|
||||
@@ -55,6 +56,8 @@ jobs:
|
||||
build-args: |
|
||||
RELEASE_BUILD=1
|
||||
BUILD_PIPELINE=1
|
||||
BUILD_UNIXTIME=${{ steps.setup.outputs.build_unix_time }}
|
||||
BUILD_DATE=${{ steps.setup.outputs.build_date }}
|
||||
CHECK_3RD_API=${{ vars.CHECK_3RD_API }}
|
||||
SKIP_TESTS=${{ vars.SKIP_TESTS }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
|
||||
@@ -10,7 +10,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
@@ -25,14 +25,15 @@ jobs:
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
with:
|
||||
image: tonistiigi/binfmt:qemu-v8.1.5
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Set up the environment
|
||||
id: setup
|
||||
run: |
|
||||
echo "build_unix_time=$(date '+%s')" >> "$GITHUB_OUTPUT"
|
||||
echo "build_date=$(date '+%Y%m%d')" >> "$GITHUB_OUTPUT"
|
||||
sed -r -i 's#FROM( --.*)? (.*:.*)?#FROM\1 ${{ secrets.DOCKER_REPO }}/mirrors/\2#g' Dockerfile
|
||||
cat >> docker/custom-backend-pre-setup.sh <<EOF
|
||||
#!/bin/sh
|
||||
@@ -54,6 +55,8 @@ jobs:
|
||||
push: true
|
||||
build-args: |
|
||||
BUILD_PIPELINE=1
|
||||
BUILD_UNIXTIME=${{ steps.setup.outputs.build_unix_time }}
|
||||
BUILD_DATE=${{ steps.setup.outputs.build_date }}
|
||||
CHECK_3RD_API=${{ vars.CHECK_3RD_API }}
|
||||
SKIP_TESTS=${{ vars.SKIP_TESTS }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
name: Bug Report
|
||||
description: Report a bug in ezBookkeeping
|
||||
body:
|
||||
- type: checkboxes
|
||||
id: checkboxes
|
||||
attributes:
|
||||
label: Before You Submit
|
||||
description: Please check whether the following items have been completed.
|
||||
options:
|
||||
- label: I've already checked this bug hasn't been raised in [issues](https://github.com/mayswind/ezbookkeeping/issues)
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Description
|
||||
description: Please provide a brief description of this bug.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: reproduction-steps
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: Please describe the steps to reproduce this bug.
|
||||
placeholder: |
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: ezbookkeeping-version
|
||||
attributes:
|
||||
label: ezBookkeeping Version
|
||||
description: ezBookkeeping version and commit hash of your instance, e.g. "v1.0.0 (20e2444)"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: server-os
|
||||
attributes:
|
||||
label: Server Operating System
|
||||
description: The operating system information you are using to deploy ezBookkeeping, e.g "Debian GNU/Linux 11 amd64"
|
||||
|
||||
- type: input
|
||||
id: server-database
|
||||
attributes:
|
||||
label: Database
|
||||
description: The database system you are using, e.g. "MariaDB v11.7.2"
|
||||
|
||||
- type: dropdown
|
||||
id: reproduce-on-demo-site
|
||||
attributes:
|
||||
label: Can you reproduce this bug on the ezBookkeeping demo site?
|
||||
description: |
|
||||
ezBookkeeping demo site: https://ezbookkeeping-demo.mayswind.net/
|
||||
options:
|
||||
- "No"
|
||||
- "Yes"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: Additional information
|
||||
description: If you can, provide any related screenshots or logs here.
|
||||
@@ -0,0 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Frequently Asked Questions
|
||||
url: https://ezbookkeeping.mayswind.net/faq
|
||||
about: Please check whether your question has already been mentioned here.
|
||||
- name: Usage Questions
|
||||
url: https://github.com/mayswind/ezbookkeeping/discussions/categories/q-a
|
||||
about: Questions about using ezBookkeeping can be discussed in GitHub Discussions.
|
||||
@@ -0,0 +1,25 @@
|
||||
name: Feature Request
|
||||
description: Request a feature or enhancement for ezBookkeeping
|
||||
body:
|
||||
- type: checkboxes
|
||||
id: checkboxes
|
||||
attributes:
|
||||
label: Before You Submit
|
||||
description: Please check whether the following items have been completed.
|
||||
options:
|
||||
- label: I've already checked this request hasn't been raised in [issues](https://github.com/mayswind/ezbookkeeping/issues)
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Feature Description
|
||||
description: Please describe your feature request.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: Additional information
|
||||
description: If you can, provide any other context or screenshots about this feature request here.
|
||||
@@ -0,0 +1,124 @@
|
||||
name: Build docker image and package for linux
|
||||
|
||||
inputs:
|
||||
release-build:
|
||||
required: false
|
||||
type: string
|
||||
build-unix-time:
|
||||
required: false
|
||||
type: string
|
||||
build-date:
|
||||
required: false
|
||||
type: string
|
||||
check-3rd-api:
|
||||
required: false
|
||||
type: string
|
||||
skip-tests:
|
||||
required: false
|
||||
type: string
|
||||
platform:
|
||||
required: true
|
||||
type: string
|
||||
platform-name:
|
||||
required: true
|
||||
type: string
|
||||
docker-push:
|
||||
required: true
|
||||
type: boolean
|
||||
docker-image-name:
|
||||
required: true
|
||||
type: string
|
||||
docker-username:
|
||||
required: false
|
||||
type: string
|
||||
docker-password:
|
||||
required: false
|
||||
type: string
|
||||
docker-bake-meta-file-path:
|
||||
required: true
|
||||
type: string
|
||||
docker-bake-meta-artifact-name:
|
||||
required: true
|
||||
type: string
|
||||
docker-bake-digests-file-path:
|
||||
required: true
|
||||
type: string
|
||||
docker-bake-digests-artifact-name-prefix:
|
||||
required: true
|
||||
type: string
|
||||
package-file-name-prefix:
|
||||
required: true
|
||||
type: string
|
||||
package-artifact-name-prefix:
|
||||
required: true
|
||||
type: string
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Download docker bake meta artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: ${{ inputs.docker-bake-meta-artifact-name }}
|
||||
path: ${{ runner.temp }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to DockerHub
|
||||
if: ${{ inputs.docker-username != '' && inputs.docker-password != '' }}
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ inputs.docker-username }}
|
||||
password: ${{ inputs.docker-password }}
|
||||
|
||||
- name: Build docker for ${{ inputs.platform-name }}
|
||||
id: bake
|
||||
uses: docker/bake-action@v6
|
||||
with:
|
||||
files: |
|
||||
./docker-bake.hcl
|
||||
cwd://${{ inputs.docker-bake-meta-file-path }}
|
||||
source: .
|
||||
targets: image
|
||||
set: |
|
||||
*.tags=${{ inputs.docker-image-name }}
|
||||
*.platform=${{ inputs.platform }}
|
||||
*.args.RELEASE_BUILD=${{ inputs.release-build }}
|
||||
*.args.BUILD_PIPELINE=1
|
||||
*.args.BUILD_UNIXTIME=${{ inputs.build-unix-time }}
|
||||
*.args.BUILD_DATE=${{ inputs.build-date }}
|
||||
*.args.CHECK_3RD_API=${{ inputs.check-3rd-api }}
|
||||
*.args.SKIP_TESTS=${{ inputs.skip-tests }}
|
||||
*.output=type=image,push-by-digest=true,name-canonical=true,push=${{ inputs.docker-push }}
|
||||
*.output+=type=local,dest=${{ runner.temp }}/package
|
||||
|
||||
- name: Export digests file for ${{ inputs.platform-name }}
|
||||
shell: bash
|
||||
run: |
|
||||
digest="${{ fromJSON(steps.bake.outputs.metadata).image['containerimage.digest'] }}"
|
||||
mkdir -p ${{ inputs.docker-bake-digests-file-path }}
|
||||
touch "${{ inputs.docker-bake-digests-file-path }}/${digest#sha256:}"
|
||||
|
||||
- name: Build package file for ${{ inputs.platform-name }}
|
||||
shell: bash
|
||||
run: |
|
||||
cd ${{ runner.temp }}/package/ezbookkeeping
|
||||
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
|
||||
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
|
||||
with:
|
||||
name: ${{ inputs.package-artifact-name-prefix }}-${{ inputs.platform-name }}
|
||||
path: ${{ runner.temp }}/${{ inputs.package-file-name-prefix }}-${{ inputs.platform-name }}.tar.gz
|
||||
if-no-files-found: error
|
||||
@@ -0,0 +1,76 @@
|
||||
name: Build backend file for windows
|
||||
|
||||
inputs:
|
||||
go-version:
|
||||
required: false
|
||||
default: "1.25.3"
|
||||
mingw-version:
|
||||
required: false
|
||||
default: "14.2.0"
|
||||
mingw-revison:
|
||||
required: false
|
||||
default: "v12-rev2"
|
||||
release-build:
|
||||
required: false
|
||||
type: string
|
||||
build-unix-time:
|
||||
required: false
|
||||
type: string
|
||||
build-date:
|
||||
required: false
|
||||
type: string
|
||||
check-3rd-api:
|
||||
required: false
|
||||
type: string
|
||||
skip-tests:
|
||||
required: false
|
||||
type: string
|
||||
backend-artifact-name-prefix:
|
||||
required: true
|
||||
type: string
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: ${{ inputs.go-version }}
|
||||
|
||||
- name: Install MinGW
|
||||
shell: pwsh
|
||||
run: |
|
||||
$mingwVersion = "${{ inputs.mingw-version }}"
|
||||
$mingwRevision = "${{ inputs.mingw-revison }}"
|
||||
$url = "https://github.com/niXman/mingw-builds-binaries/releases/download/${mingwVersion}-rt_${mingwRevision}/x86_64-${mingwVersion}-release-posix-seh-ucrt-rt_${mingwRevision}.7z"
|
||||
$archive = "C:\mingw.7z"
|
||||
$mingwDir = "C:\mingw64"
|
||||
|
||||
Write-Host "Downloading MinGW from ${url}"
|
||||
Invoke-WebRequest -Uri ${url} -OutFile ${archive}
|
||||
|
||||
Remove-Item -Recurse -Force ${mingwDir}
|
||||
New-Item -ItemType Directory -Path ${mingwDir}
|
||||
|
||||
Write-Host "Extracting MinGW to ${mingwDir}"
|
||||
7z x ${archive} -oC:\
|
||||
"${mingwDir}\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
||||
|
||||
- name: Build backend for windows-x64
|
||||
shell: pwsh
|
||||
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.ps1 backend
|
||||
|
||||
- name: Upload windows backend artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ inputs.backend-artifact-name-prefix }}-windows-x64
|
||||
path: ezbookkeeping.exe
|
||||
if-no-files-found: error
|
||||
@@ -0,0 +1,57 @@
|
||||
name: Build packages for windows
|
||||
|
||||
inputs:
|
||||
package-file-name-prefix:
|
||||
required: true
|
||||
type: string
|
||||
package-artifact-name-prefix:
|
||||
required: true
|
||||
type: string
|
||||
backend-artifact-name-prefix:
|
||||
required: true
|
||||
type: string
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Download windows-x64 backend file
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: ${{ inputs.backend-artifact-name-prefix }}-windows-x64
|
||||
path: ${{ runner.temp }}\backend
|
||||
|
||||
- name: Download linux-amd64 packaged files
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: ${{ inputs.package-artifact-name-prefix }}-linux-amd64
|
||||
path: ${{ runner.temp }}\package
|
||||
|
||||
- name: Extract frontend files from linux-amd64 package
|
||||
shell: pwsh
|
||||
run: |
|
||||
New-Item -ItemType Directory -Path package
|
||||
tar -xzf (Get-ChildItem ${{ runner.temp }}\package\${{ inputs.package-file-name-prefix }}-linux-amd64.tar.gz) -C package
|
||||
|
||||
- name: Package windows-x64 package
|
||||
shell: pwsh
|
||||
run: |
|
||||
New-Item -ItemType Directory -Path "ezbookkeeping"
|
||||
New-Item -ItemType Directory -Path "ezbookkeeping\data"
|
||||
New-Item -ItemType Directory -Path "ezbookkeeping\storage"
|
||||
New-Item -ItemType Directory -Path "ezbookkeeping\log"
|
||||
Copy-Item ${{ runner.temp }}\backend\ezbookkeeping.exe -Destination ezbookkeeping\
|
||||
Copy-Item -Recurse -Force package\public -Destination ezbookkeeping\public
|
||||
Copy-Item -Recurse -Force conf -Destination ezbookkeeping\conf
|
||||
Copy-Item -Recurse -Force templates -Destination ezbookkeeping\templates
|
||||
Copy-Item .\LICENSE -Destination ezbookkeeping\
|
||||
Push-Location ezbookkeeping
|
||||
7z a -r -tzip -mx9 ..\${{ inputs.package-file-name-prefix }}-windows-x64.zip *
|
||||
Pop-Location
|
||||
Remove-Item -Recurse -Force ezbookkeeping
|
||||
|
||||
- name: Upload windows artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ inputs.package-artifact-name-prefix }}-windows-x64
|
||||
path: ${{ inputs.package-file-name-prefix }}-windows-x64.zip
|
||||
if-no-files-found: error
|
||||
@@ -0,0 +1,58 @@
|
||||
name: Push linux docker multi-arch image to registry
|
||||
|
||||
inputs:
|
||||
docker-image-name:
|
||||
required: true
|
||||
type: string
|
||||
docker-username:
|
||||
required: true
|
||||
type: string
|
||||
docker-password:
|
||||
required: true
|
||||
type: string
|
||||
docker-bake-meta-file-path:
|
||||
required: true
|
||||
type: string
|
||||
docker-bake-meta-artifact-name:
|
||||
required: true
|
||||
type: string
|
||||
docker-bake-digests-file-path:
|
||||
required: true
|
||||
type: string
|
||||
docker-bake-digests-artifact-name-prefix:
|
||||
required: true
|
||||
type: string
|
||||
docker-image-tags:
|
||||
required: true
|
||||
type: string
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Download docker bake meta artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: ${{ inputs.docker-bake-meta-artifact-name }}
|
||||
path: ${{ runner.temp }}
|
||||
|
||||
- name: Download digests artifact
|
||||
uses: actions/download-artifact@v4
|
||||
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
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ inputs.docker-username }}
|
||||
password: ${{ inputs.docker-password }}
|
||||
|
||||
- name: Create manifest and push
|
||||
shell: bash
|
||||
working-directory: ${{ inputs.docker-bake-digests-file-path }}
|
||||
run: |
|
||||
docker buildx imagetools create $(echo "${{ inputs.docker-image-tags }}" | xargs -I {} echo -n " -t {}") $(printf '${{ inputs.docker-image-name }}@sha256:%s ' *)
|
||||
@@ -0,0 +1,149 @@
|
||||
name: Build for Non-Main Branches
|
||||
|
||||
on:
|
||||
push:
|
||||
branches-ignore:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
setup:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
build-unix-time: ${{ steps.variable.outputs.build_unix_time }}
|
||||
build-date: ${{ steps.variable.outputs.build_date }}
|
||||
docker-tags: ${{ steps.meta.outputs.tags }}
|
||||
docker-labels: ${{ steps.meta.outputs.labels }}
|
||||
ezbookkeeping-docker-bake-meta-file-path: ${{ steps.variable.outputs.ezbookkeeping_docker_bake_meta_file_path }}
|
||||
ezbookkeeping-docker-bake-meta-artifact-name: ${{ steps.variable.outputs.ezbookkeeping_docker_bake_meta_artifact_name }}
|
||||
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
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ vars.DOCKER_IMAGE_NAME }}
|
||||
tags: |
|
||||
type=raw,value=dev-${{ github.run_id }}
|
||||
|
||||
- name: Set up variables
|
||||
id: variable
|
||||
run: |
|
||||
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"
|
||||
echo "ezbookkeeping_docker_bake_meta_artifact_name=ezbookkeeping-build-dev-docker-bake-meta-${{ github.run_id }}" >> "$GITHUB_OUTPUT"
|
||||
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
|
||||
run: |
|
||||
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
|
||||
with:
|
||||
name: ${{ steps.variable.outputs.ezbookkeeping_docker_bake_meta_artifact_name }}
|
||||
path: ${{ steps.variable.outputs.ezbookkeeping_docker_bake_meta_file_path }}
|
||||
if-no-files-found: error
|
||||
|
||||
build-linux-docker-and-package-x86:
|
||||
runs-on: ubuntu-24.04
|
||||
needs:
|
||||
- setup
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- uses: ./.github/actions/build-linux-docker-and-package
|
||||
with:
|
||||
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 }}
|
||||
platform: linux/amd64
|
||||
platform-name: linux-amd64
|
||||
docker-push: false
|
||||
docker-image-name: ${{ vars.DOCKER_IMAGE_NAME }}
|
||||
docker-bake-meta-file-path: ${{ needs.setup.outputs.ezbookkeeping-docker-bake-meta-file-path }}
|
||||
docker-bake-meta-artifact-name: ${{ needs.setup.outputs.ezbookkeeping-docker-bake-meta-artifact-name }}
|
||||
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
|
||||
needs:
|
||||
- setup
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/arm64/v8
|
||||
platform-name: linux-arm64
|
||||
- platform: linux/arm/v7
|
||||
platform-name: linux-armv7
|
||||
- platform: linux/arm/v6
|
||||
platform-name: linux-armv6
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- uses: ./.github/actions/build-linux-docker-and-package
|
||||
with:
|
||||
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 }}
|
||||
platform: ${{ matrix.platform }}
|
||||
platform-name: ${{ matrix.platform-name }}
|
||||
docker-push: false
|
||||
docker-image-name: ${{ vars.DOCKER_IMAGE_NAME }}
|
||||
docker-bake-meta-file-path: ${{ needs.setup.outputs.ezbookkeeping-docker-bake-meta-file-path }}
|
||||
docker-bake-meta-artifact-name: ${{ needs.setup.outputs.ezbookkeeping-docker-bake-meta-artifact-name }}
|
||||
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:
|
||||
- setup
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- uses: ./.github/actions/build-windows-backend
|
||||
with:
|
||||
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
|
||||
- build-windows-backend
|
||||
- build-linux-docker-and-package-x86
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- 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 }}
|
||||
@@ -0,0 +1,204 @@
|
||||
name: Build Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
|
||||
jobs:
|
||||
setup:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
build-unix-time: ${{ steps.variable.outputs.build_unix_time }}
|
||||
build-date: ${{ steps.variable.outputs.build_date }}
|
||||
docker-version: ${{ steps.meta.outputs.version }}
|
||||
docker-tags: ${{ steps.meta.outputs.tags }}
|
||||
docker-labels: ${{ steps.meta.outputs.labels }}
|
||||
ezbookkeeping-docker-bake-meta-file-path: ${{ steps.variable.outputs.ezbookkeeping_docker_bake_meta_file_path }}
|
||||
ezbookkeeping-docker-bake-meta-artifact-name: ${{ steps.variable.outputs.ezbookkeeping_docker_bake_meta_artifact_name }}
|
||||
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
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ vars.DOCKER_IMAGE_NAME }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Set up variables
|
||||
id: variable
|
||||
run: |
|
||||
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"
|
||||
echo "ezbookkeeping_docker_bake_meta_artifact_name=ezbookkeeping-build-release-docker-bake-meta-${{ github.run_id }}" >> "$GITHUB_OUTPUT"
|
||||
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
|
||||
run: |
|
||||
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
|
||||
with:
|
||||
name: ${{ steps.variable.outputs.ezbookkeeping_docker_bake_meta_artifact_name }}
|
||||
path: ${{ steps.variable.outputs.ezbookkeeping_docker_bake_meta_file_path }}
|
||||
if-no-files-found: error
|
||||
|
||||
build-linux-docker-and-package-x86:
|
||||
runs-on: ubuntu-24.04
|
||||
needs:
|
||||
- setup
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- uses: ./.github/actions/build-linux-docker-and-package
|
||||
with:
|
||||
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 }}
|
||||
platform: linux/amd64
|
||||
platform-name: linux-amd64
|
||||
docker-push: true
|
||||
docker-image-name: ${{ vars.DOCKER_IMAGE_NAME }}
|
||||
docker-username: ${{ vars.DOCKER_USERNAME }}
|
||||
docker-password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
docker-bake-meta-file-path: ${{ needs.setup.outputs.ezbookkeeping-docker-bake-meta-file-path }}
|
||||
docker-bake-meta-artifact-name: ${{ needs.setup.outputs.ezbookkeeping-docker-bake-meta-artifact-name }}
|
||||
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
|
||||
needs:
|
||||
- setup
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/arm64/v8
|
||||
platform-name: linux-arm64
|
||||
- platform: linux/arm/v7
|
||||
platform-name: linux-armv7
|
||||
- platform: linux/arm/v6
|
||||
platform-name: linux-armv6
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- uses: ./.github/actions/build-linux-docker-and-package
|
||||
with:
|
||||
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 }}
|
||||
platform: ${{ matrix.platform }}
|
||||
platform-name: ${{ matrix.platform-name }}
|
||||
docker-push: true
|
||||
docker-image-name: ${{ vars.DOCKER_IMAGE_NAME }}
|
||||
docker-username: ${{ vars.DOCKER_USERNAME }}
|
||||
docker-password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
docker-bake-meta-file-path: ${{ needs.setup.outputs.ezbookkeeping-docker-bake-meta-file-path }}
|
||||
docker-bake-meta-artifact-name: ${{ needs.setup.outputs.ezbookkeeping-docker-bake-meta-artifact-name }}
|
||||
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:
|
||||
needs:
|
||||
- setup
|
||||
- build-linux-docker-and-package-x86
|
||||
- build-linux-docker-and-package-arm
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- uses: ./.github/actions/push-linux-docker
|
||||
with:
|
||||
docker-image-name: ${{ vars.DOCKER_IMAGE_NAME }}
|
||||
docker-username: ${{ vars.DOCKER_USERNAME }}
|
||||
docker-password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
docker-bake-meta-file-path: ${{ needs.setup.outputs.ezbookkeeping-docker-bake-meta-file-path }}
|
||||
docker-bake-meta-artifact-name: ${{ needs.setup.outputs.ezbookkeeping-docker-bake-meta-artifact-name }}
|
||||
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 }}
|
||||
docker-image-tags: ${{ needs.setup.outputs.docker-tags }}
|
||||
|
||||
build-windows-backend:
|
||||
needs:
|
||||
- setup
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- uses: ./.github/actions/build-windows-backend
|
||||
with:
|
||||
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
|
||||
- build-windows-backend
|
||||
- build-linux-docker-and-package-x86
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- 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 }}
|
||||
|
||||
publish-release:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- setup
|
||||
- build-linux-docker-and-package-x86
|
||||
- build-linux-docker-and-package-arm
|
||||
- build-windows-package
|
||||
- push-linux-docker
|
||||
steps:
|
||||
- name: Download all packaged files
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: ${{ needs.setup.outputs.ezbookkeeping-package-artifact-name-prefix }}-*
|
||||
merge-multiple: true
|
||||
path: release-files
|
||||
|
||||
- name: Publish Release ${{ github.ref_name }}
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
name: ${{ github.ref_name }}
|
||||
tag_name: ${{ github.ref_name }}
|
||||
files: ./release-files/*
|
||||
draft: true
|
||||
@@ -0,0 +1,177 @@
|
||||
name: Build Snapshot
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
setup:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
build-unix-time: ${{ steps.variable.outputs.build_unix_time }}
|
||||
build-date: ${{ steps.variable.outputs.build_date }}
|
||||
docker-version: ${{ steps.meta.outputs.version }}
|
||||
docker-tags: ${{ steps.meta.outputs.tags }}
|
||||
docker-labels: ${{ steps.meta.outputs.labels }}
|
||||
ezbookkeeping-docker-bake-meta-file-path: ${{ steps.variable.outputs.ezbookkeeping_docker_bake_meta_file_path }}
|
||||
ezbookkeeping-docker-bake-meta-artifact-name: ${{ steps.variable.outputs.ezbookkeeping_docker_bake_meta_artifact_name }}
|
||||
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
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ vars.DOCKER_IMAGE_NAME }}
|
||||
tags: |
|
||||
type=raw,value=SNAPSHOT-{{date 'YYYYMMDD'}}-${{ github.run_id }}
|
||||
type=raw,value=SNAPSHOT-{{date 'YYYYMMDD'}}
|
||||
type=raw,value=latest-snapshot
|
||||
|
||||
- name: Set up variables
|
||||
id: variable
|
||||
run: |
|
||||
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"
|
||||
echo "ezbookkeeping_docker_bake_meta_artifact_name=ezbookkeeping-build-dev-docker-bake-meta-${{ github.run_id }}" >> "$GITHUB_OUTPUT"
|
||||
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
|
||||
run: |
|
||||
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
|
||||
with:
|
||||
name: ${{ steps.variable.outputs.ezbookkeeping_docker_bake_meta_artifact_name }}
|
||||
path: ${{ steps.variable.outputs.ezbookkeeping_docker_bake_meta_file_path }}
|
||||
if-no-files-found: error
|
||||
|
||||
build-linux-docker-and-package-x86:
|
||||
runs-on: ubuntu-24.04
|
||||
needs:
|
||||
- setup
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- uses: ./.github/actions/build-linux-docker-and-package
|
||||
with:
|
||||
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 }}
|
||||
platform: linux/amd64
|
||||
platform-name: linux-amd64
|
||||
docker-push: true
|
||||
docker-image-name: ${{ vars.DOCKER_IMAGE_NAME }}
|
||||
docker-username: ${{ vars.DOCKER_USERNAME }}
|
||||
docker-password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
docker-bake-meta-file-path: ${{ needs.setup.outputs.ezbookkeeping-docker-bake-meta-file-path }}
|
||||
docker-bake-meta-artifact-name: ${{ needs.setup.outputs.ezbookkeeping-docker-bake-meta-artifact-name }}
|
||||
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
|
||||
needs:
|
||||
- setup
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/arm64/v8
|
||||
platform-name: linux-arm64
|
||||
- platform: linux/arm/v7
|
||||
platform-name: linux-armv7
|
||||
- platform: linux/arm/v6
|
||||
platform-name: linux-armv6
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- uses: ./.github/actions/build-linux-docker-and-package
|
||||
with:
|
||||
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 }}
|
||||
platform: ${{ matrix.platform }}
|
||||
platform-name: ${{ matrix.platform-name }}
|
||||
docker-push: true
|
||||
docker-image-name: ${{ vars.DOCKER_IMAGE_NAME }}
|
||||
docker-username: ${{ vars.DOCKER_USERNAME }}
|
||||
docker-password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
docker-bake-meta-file-path: ${{ needs.setup.outputs.ezbookkeeping-docker-bake-meta-file-path }}
|
||||
docker-bake-meta-artifact-name: ${{ needs.setup.outputs.ezbookkeeping-docker-bake-meta-artifact-name }}
|
||||
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:
|
||||
needs:
|
||||
- setup
|
||||
- build-linux-docker-and-package-x86
|
||||
- build-linux-docker-and-package-arm
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- uses: ./.github/actions/push-linux-docker
|
||||
with:
|
||||
docker-image-name: ${{ vars.DOCKER_IMAGE_NAME }}
|
||||
docker-username: ${{ vars.DOCKER_USERNAME }}
|
||||
docker-password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
docker-bake-meta-file-path: ${{ needs.setup.outputs.ezbookkeeping-docker-bake-meta-file-path }}
|
||||
docker-bake-meta-artifact-name: ${{ needs.setup.outputs.ezbookkeeping-docker-bake-meta-artifact-name }}
|
||||
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 }}
|
||||
docker-image-tags: ${{ needs.setup.outputs.docker-tags }}
|
||||
|
||||
build-windows-backend:
|
||||
needs:
|
||||
- setup
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- uses: ./.github/actions/build-windows-backend
|
||||
with:
|
||||
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
|
||||
- build-windows-backend
|
||||
- build-linux-docker-and-package-x86
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- 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 }}
|
||||
@@ -1,57 +0,0 @@
|
||||
name: Docker Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- v*
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ secrets.DOCKER_USERNAME }}/ezbookkeeping
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
with:
|
||||
image: tonistiigi/binfmt:qemu-v8.1.5
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
file: Dockerfile
|
||||
context: .
|
||||
platforms: |
|
||||
linux/amd64
|
||||
linux/arm64/v8
|
||||
linux/arm/v7
|
||||
linux/arm/v6
|
||||
push: true
|
||||
build-args: |
|
||||
RELEASE_BUILD=1
|
||||
BUILD_PIPELINE=1
|
||||
CHECK_3RD_API=${{ vars.CHECK_3RD_API }}
|
||||
SKIP_TESTS=${{ vars.SKIP_TESTS }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
@@ -1,55 +0,0 @@
|
||||
name: Docker Snapshot
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ secrets.DOCKER_USERNAME }}/ezbookkeeping
|
||||
tags: |
|
||||
type=raw,value=SNAPSHOT-{{date 'YYYYMMDD'}}
|
||||
type=raw,value=latest-snapshot
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
with:
|
||||
image: tonistiigi/binfmt:qemu-v8.1.5
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
file: Dockerfile
|
||||
context: .
|
||||
platforms: |
|
||||
linux/amd64
|
||||
linux/arm64/v8
|
||||
linux/arm/v7
|
||||
linux/arm/v6
|
||||
push: true
|
||||
build-args: |
|
||||
BUILD_PIPELINE=1
|
||||
CHECK_3RD_API=${{ vars.CHECK_3RD_API }}
|
||||
SKIP_TESTS=${{ vars.SKIP_TESTS }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
@@ -1,32 +0,0 @@
|
||||
name: Docker Snapshot
|
||||
|
||||
on:
|
||||
push:
|
||||
branches-ignore:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
-
|
||||
name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
-
|
||||
name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
-
|
||||
name: Build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
file: Dockerfile
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
push: false
|
||||
build-args: |
|
||||
BUILD_PIPELINE=1
|
||||
CHECK_3RD_API=${{ vars.CHECK_3RD_API }}
|
||||
SKIP_TESTS=${{ vars.SKIP_TESTS }}
|
||||
+11
-3
@@ -1,11 +1,15 @@
|
||||
# Build backend binary file
|
||||
FROM golang:1.24.4-alpine3.22 AS be-builder
|
||||
FROM golang:1.25.3-alpine3.22 AS be-builder
|
||||
ARG RELEASE_BUILD
|
||||
ARG BUILD_PIPELINE
|
||||
ARG BUILD_UNIXTIME
|
||||
ARG BUILD_DATE
|
||||
ARG CHECK_3RD_API
|
||||
ARG SKIP_TESTS
|
||||
ENV RELEASE_BUILD=$RELEASE_BUILD
|
||||
ENV BUILD_PIPELINE=$BUILD_PIPELINE
|
||||
ENV BUILD_UNIXTIME=$BUILD_UNIXTIME
|
||||
ENV BUILD_DATE=$BUILD_DATE
|
||||
ENV CHECK_3RD_API=$CHECK_3RD_API
|
||||
ENV SKIP_TESTS=$SKIP_TESTS
|
||||
WORKDIR /go/src/github.com/mayswind/ezbookkeeping
|
||||
@@ -15,11 +19,15 @@ RUN apk add git gcc g++ libc-dev
|
||||
RUN ./build.sh backend
|
||||
|
||||
# Build frontend files
|
||||
FROM --platform=$BUILDPLATFORM node:22.16.0-alpine3.22 AS fe-builder
|
||||
FROM --platform=$BUILDPLATFORM node:24.10.0-alpine3.22 AS fe-builder
|
||||
ARG RELEASE_BUILD
|
||||
ARG BUILD_PIPELINE
|
||||
ARG BUILD_UNIXTIME
|
||||
ARG BUILD_DATE
|
||||
ENV RELEASE_BUILD=$RELEASE_BUILD
|
||||
ENV BUILD_PIPELINE=$BUILD_PIPELINE
|
||||
ENV BUILD_UNIXTIME=$BUILD_UNIXTIME
|
||||
ENV BUILD_DATE=$BUILD_DATE
|
||||
WORKDIR /go/src/github.com/mayswind/ezbookkeeping
|
||||
COPY . .
|
||||
RUN docker/frontend-build-pre-setup.sh
|
||||
@@ -27,7 +35,7 @@ RUN apk add git
|
||||
RUN ./build.sh frontend
|
||||
|
||||
# Package docker image
|
||||
FROM alpine:3.22.0
|
||||
FROM alpine:3.22.2
|
||||
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
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
# ezBookkeeping
|
||||
[](https://github.com/mayswind/ezbookkeeping/blob/master/LICENSE)
|
||||
[](https://github.com/mayswind/ezbookkeeping/actions)
|
||||
[](https://goreportcard.com/report/github.com/mayswind/ezbookkeeping)
|
||||
[](https://hub.docker.com/r/mayswind/ezbookkeeping)
|
||||
[](https://github.com/mayswind/ezbookkeeping/releases)
|
||||
[](https://github.com/mayswind/ezbookkeeping/actions)
|
||||
[](https://hub.docker.com/r/mayswind/ezbookkeeping)
|
||||
[](https://hub.docker.com/r/mayswind/ezbookkeeping)
|
||||
[](https://deepwiki.com/mayswind/ezbookkeeping)
|
||||
|
||||
[](https://hellogithub.com/en/repository/mayswind/ezbookkeeping)
|
||||
[](https://trendshift.io/repositories/12917)
|
||||
|
||||
## Introduction
|
||||
ezBookkeeping is a lightweight, self-hosted personal finance app with a sleek, user-friendly interface and powerful bookkeeping features. Built with simplicity and portability in mind, it's easy to deploy, easy to use, and requires minimal system resources — perfect for microservers, NAS devices, and even Raspberry Pi.
|
||||
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.
|
||||
|
||||
The app is fully cross-platform and device-friendly — you can use it seamlessly on **mobile, tablet, 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.
|
||||
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.
|
||||
|
||||
Live Demo: [https://ezbookkeeping-demo.mayswind.net](https://ezbookkeeping-demo.mayswind.net)
|
||||
|
||||
@@ -27,6 +32,7 @@ Live Demo: [https://ezbookkeeping-demo.mayswind.net](https://ezbookkeeping-demo.
|
||||
- PWA support for native-like mobile experience
|
||||
- Dark mode
|
||||
- **AI-Powered Features**
|
||||
- Receipt image recognition
|
||||
- Supports MCP (Model Context Protocol) for AI integration
|
||||
- **Powerful Bookkeeping**
|
||||
- Two-level accounts and categories
|
||||
@@ -79,7 +85,7 @@ Download the latest release: [https://github.com/mayswind/ezbookkeeping/releases
|
||||
By default, ezBookkeeping listens on port 8080. You can then visit `http://{YOUR_HOST_ADDRESS}:8080/` .
|
||||
|
||||
### Build from Source
|
||||
Make sure you have [Golang](https://golang.org/), [GCC](http://gcc.gnu.org/), [Node.js](https://nodejs.org/) and [NPM](https://www.npmjs.com/) installed. Then download the source code, and follow these steps:
|
||||
Make sure you have [Golang](https://golang.org/), [GCC](https://gcc.gnu.org/), [Node.js](https://nodejs.org/) and [NPM](https://www.npmjs.com/) installed. Then download the source code, and follow these steps:
|
||||
|
||||
**Linux / macOS**
|
||||
|
||||
@@ -91,6 +97,10 @@ All the files will be packaged in `ezbookkeeping.tar.gz`.
|
||||
|
||||
> .\build.bat package -o ezbookkeeping.zip
|
||||
|
||||
or
|
||||
|
||||
PS > .\build.ps1 package -Output ezbookkeeping.zip
|
||||
|
||||
All the files will be packaged in `ezbookkeeping.zip`.
|
||||
|
||||
You can also build a Docker image. Make sure you have [Docker](https://www.docker.com/) installed, then follow these steps:
|
||||
@@ -100,7 +110,7 @@ You can also build a Docker image. Make sure you have [Docker](https://www.docke
|
||||
$ ./build.sh docker
|
||||
|
||||
## Contributing
|
||||
We welcome contributions of all kinds!
|
||||
We welcome contributions of all kinds.
|
||||
|
||||
Found a bug? [Submit an issue](https://github.com/mayswind/ezbookkeeping/issues)
|
||||
|
||||
@@ -108,10 +118,10 @@ Want to contribute code? Feel free to fork and send a pull request.
|
||||
|
||||
Contributions of all kinds — bug reports, feature suggestions, documentation improvements, or code — are highly appreciated.
|
||||
|
||||
Check out our [Contributor Graph](https://github.com/mayswind/ezbookkeeping/graphs/contributors) to see the amazing people who’ve already helped.
|
||||
Check out our [Contributor Graph](https://github.com/mayswind/ezbookkeeping/graphs/contributors) to see the amazing people who've already helped.
|
||||
|
||||
## 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. If you want to contribute a translation, please refer to our [translation guide](https://ezbookkeeping.mayswind.net/translating).
|
||||
|
||||
Currently available translations:
|
||||
|
||||
@@ -120,20 +130,24 @@ Currently available translations:
|
||||
| de | Deutsch | [@chrgm](https://github.com/chrgm) |
|
||||
| en | English | / |
|
||||
| es | Español | [@Miguelonlonlon](https://github.com/Miguelonlonlon) |
|
||||
| fr | Français | [@brieucdlf](https://github.com/brieucdlf) |
|
||||
| it | Italiano | [@waron97](https://github.com/waron97) |
|
||||
| ja | 日本語 | [@tkymmm](https://github.com/tkymmm) |
|
||||
| ko | 한국어 | [@overworks](https://github.com/overworks) |
|
||||
| nl | Nederlands | [@automagic](https://github.com/automagics) |
|
||||
| pt-BR | Português (Brasil) | [@thecodergus](https://github.com/thecodergus) |
|
||||
| ru | Русский | [@artegoser](https://github.com/artegoser) |
|
||||
| th | ไทย | [@natthavat28](https://github.com/natthavat28) |
|
||||
| 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!
|
||||
Don't see your language? Help us add it.
|
||||
|
||||
## Documentation
|
||||
1. [English](http://ezbookkeeping.mayswind.net)
|
||||
1. [中文 (简体)](http://ezbookkeeping.mayswind.net/zh_Hans)
|
||||
1. [English](https://ezbookkeeping.mayswind.net)
|
||||
1. [中文 (简体)](https://ezbookkeeping.mayswind.net/zh_Hans)
|
||||
|
||||
## License
|
||||
[MIT](https://github.com/mayswind/ezbookkeeping/blob/master/LICENSE)
|
||||
|
||||
@@ -8,8 +8,8 @@ set "RELEASE=%RELEASE_BUILD%"
|
||||
set "RELEASE_TYPE=unknown"
|
||||
set "VERSION="
|
||||
set "COMMIT_HASH="
|
||||
set "BUILD_UNIXTIME="
|
||||
set "BUILD_DATE="
|
||||
set "BUILD_UNIXTIME=%BUILD_UNIXTIME%"
|
||||
set "BUILD_DATE=%BUILD_DATE%"
|
||||
set "PACKAGE_FILENAME="
|
||||
for /f %%a in ('"prompt $E$S & echo on & for %%b in (1) do rem"') do set "ESC=%%a"
|
||||
|
||||
@@ -112,9 +112,15 @@ goto :pre_parse_args
|
||||
set VERSION=%VERSION: =%
|
||||
set VERSION=%VERSION:,=%
|
||||
set VERSION=%VERSION:"=%
|
||||
for /f %%x in ('git rev-parse --short HEAD') do set "COMMIT_HASH=%%x"
|
||||
call :set_unixtime BUILD_UNIXTIME
|
||||
call :set_date BUILD_DATE
|
||||
for /f %%x in ('git rev-parse --short^=7 HEAD') do set "COMMIT_HASH=%%x"
|
||||
|
||||
if "%BUILD_UNIXTIME%"=="" (
|
||||
call :set_unixtime BUILD_UNIXTIME
|
||||
)
|
||||
|
||||
if "%BUILD_DATE%"=="" (
|
||||
call :set_date BUILD_DATE
|
||||
)
|
||||
|
||||
:main
|
||||
if "%TYPE%"=="backend" call :build_backend & goto :end
|
||||
@@ -261,7 +267,7 @@ goto :pre_parse_args
|
||||
goto :end
|
||||
)
|
||||
|
||||
call 7z a -r -tzip -mx9 ..\%package_file_name% package *
|
||||
call 7z a -r -tzip -mx9 ..\%package_file_name% *
|
||||
|
||||
cd ..
|
||||
endlocal
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
param(
|
||||
[string]$Type,
|
||||
[switch]$NoLint,
|
||||
[switch]$NoTest,
|
||||
[string]$Output,
|
||||
[switch]$Release,
|
||||
[switch]$Help
|
||||
)
|
||||
|
||||
$script:SkipTests = $env:SKIP_TESTS
|
||||
$script:ReleaseType = "unknown"
|
||||
$script:Version = ""
|
||||
$script:CommitHash = ""
|
||||
$script:BuildUnixTime = $env:BUILD_UNIXTIME
|
||||
$script:BuildDate = $env:BUILD_DATE
|
||||
|
||||
function Write-Red($msg) {
|
||||
Write-Host $msg -ForegroundColor Red
|
||||
}
|
||||
|
||||
function Check-Dependency {
|
||||
param([string[]]$commands)
|
||||
foreach ($cmd in $commands) {
|
||||
if (-not (Get-Command $cmd -ErrorAction SilentlyContinue)) {
|
||||
Write-Red "Error: `"$cmd`" is required."
|
||||
exit 127
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Show-Help {
|
||||
Write-Host "ezBookkeeping build script for Windows PowerShell"
|
||||
Write-Host ""
|
||||
Write-Host "Usage:"
|
||||
Write-Host " build.ps1 type [options]"
|
||||
Write-Host ""
|
||||
Write-Host "Types:"
|
||||
Write-Host " backend Build backend binary file"
|
||||
Write-Host " frontend Build frontend files"
|
||||
Write-Host " package Build package archive"
|
||||
Write-Host ""
|
||||
Write-Host "Options:"
|
||||
Write-Host " -Release Build release (The script will use environment variable `"RELEASE_BUILD`" to detect whether this is release building by default)"
|
||||
Write-Host " -Output <filename> Package file name (for `"package`" type only)"
|
||||
Write-Host " -NoLint Do not execute lint check before building"
|
||||
Write-Host " -NoTest Do not execute unit testing before building (You can use environment variable `"SKIP_TESTS`" to skip specified tests)"
|
||||
Write-Host " -Help Show help"
|
||||
}
|
||||
|
||||
function Parse-Args {
|
||||
if (-not $Type) {
|
||||
Show-Help
|
||||
exit 0
|
||||
}
|
||||
|
||||
if ($Release -or $env:RELEASE_BUILD) {
|
||||
$script:ReleaseType = "release"
|
||||
} else {
|
||||
$script:ReleaseType = "snapshot"
|
||||
}
|
||||
}
|
||||
|
||||
function Check-Type-Dependencies {
|
||||
Check-Dependency "git"
|
||||
|
||||
switch ($Type.ToLower()) {
|
||||
"backend" {
|
||||
Check-Dependency "go","gcc"
|
||||
}
|
||||
"frontend" {
|
||||
Check-Dependency "node","npm"
|
||||
}
|
||||
"package" {
|
||||
Check-Dependency "go","gcc","node","npm","7z"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Set-Build-Parameters {
|
||||
$script:Version = (Get-Content package.json | ConvertFrom-Json).version
|
||||
$script:CommitHash = git rev-parse --short=7 HEAD
|
||||
|
||||
if (-not $BuildUnixTime) {
|
||||
$script:BuildUnixTime = [int][double]::Parse((Get-Date -UFormat %s))
|
||||
}
|
||||
|
||||
if (-not $BuildDate) {
|
||||
$script:BuildDate = Get-Date -Format "yyyyMMdd"
|
||||
}
|
||||
}
|
||||
|
||||
function Build-Backend {
|
||||
Write-Host "Pulling backend dependencies..."
|
||||
go get .
|
||||
|
||||
if (-not $NoLint) {
|
||||
Write-Host "Executing backend lint checking..."
|
||||
go vet -v .\...
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Red "Error: Failed to pass lint checking"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $NoTest) {
|
||||
Write-Host "Executing backend unit testing..."
|
||||
go clean -cache
|
||||
|
||||
if (-not $SkipTests) {
|
||||
go test .\... -v
|
||||
} else {
|
||||
Write-Host "(Skip unit test `"$SkipTests`")"
|
||||
go test .\... -v -skip "$SkipTests"
|
||||
}
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Red "Error: Failed to pass unit testing"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
$backend_build_extra_arguments = "-X main.Version=$Version "
|
||||
$backend_build_extra_arguments = "$backend_build_extra_arguments -X main.CommitHash=$CommitHash"
|
||||
|
||||
if (-not $Release) {
|
||||
$backend_build_extra_arguments += " -X main.BuildUnixTime=$BuildUnixTime"
|
||||
}
|
||||
|
||||
Write-Host "Building backend binary file ($ReleaseType)..."
|
||||
|
||||
$env:CGO_ENABLED = 1
|
||||
go build -a -v -trimpath -tags timetzdata -ldflags "-w -s -linkmode external -extldflags '-static' $backend_build_extra_arguments" -o ezbookkeeping.exe ezbookkeeping.go
|
||||
|
||||
Remove-Item Env:\CGO_ENABLED -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
function Build-Frontend {
|
||||
Write-Host "Pulling frontend dependencies..."
|
||||
npm install
|
||||
|
||||
if (-not $NoLint) {
|
||||
Write-Host "Executing frontend lint checking..."
|
||||
npm run lint
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Red "Error: Failed to pass lint checking"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $NoTest) {
|
||||
Write-Host "Executing frontend unit testing..."
|
||||
|
||||
npm run test
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Red "Error: Failed to pass unit testing"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "Building frontend files ($ReleaseType)..."
|
||||
|
||||
if (-not $Release) {
|
||||
$env:buildUnixTime = $BuildUnixTime
|
||||
npm run build
|
||||
Remove-Item Env:\buildUnixTime -ErrorAction SilentlyContinue
|
||||
} else {
|
||||
npm run build
|
||||
}
|
||||
}
|
||||
|
||||
function Build-Package {
|
||||
$packageFileName = "ezbookkeeping-$Version"
|
||||
|
||||
if (-not $Release) {
|
||||
$packageFileName = "$packageFileName-$BuildDate"
|
||||
}
|
||||
|
||||
$packageFileName = "$packageFileName-windows.zip"
|
||||
|
||||
if ($Output) {
|
||||
$packageFileName = $Output
|
||||
}
|
||||
|
||||
Write-Host "Building package archive '$packageFileName' ($ReleaseType)..."
|
||||
|
||||
Build-Backend
|
||||
Build-Frontend
|
||||
|
||||
Remove-Item package -Recurse -Force -ErrorAction SilentlyContinue
|
||||
New-Item -ItemType Directory -Path "package"
|
||||
New-Item -ItemType Directory -Path "package\data"
|
||||
New-Item -ItemType Directory -Path "package\storage"
|
||||
New-Item -ItemType Directory -Path "package\log"
|
||||
|
||||
Copy-Item ezbookkeeping.exe package\
|
||||
Copy-Item dist package\public -Recurse
|
||||
Copy-Item conf package\conf -Recurse
|
||||
Copy-Item templates package\templates -Recurse
|
||||
Copy-Item LICENSE package\
|
||||
|
||||
Push-Location package
|
||||
7z a -r -tzip -mx9 "..\$packageFileName" *
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
function Main {
|
||||
if ($Help) {
|
||||
Show-Help
|
||||
exit 0
|
||||
}
|
||||
|
||||
Parse-Args
|
||||
Check-Type-Dependencies
|
||||
Set-Build-Parameters
|
||||
|
||||
switch ($Type) {
|
||||
"backend" {
|
||||
Build-Backend
|
||||
}
|
||||
"frontend" {
|
||||
Build-Frontend
|
||||
}
|
||||
"package" {
|
||||
Build-Package
|
||||
}
|
||||
default {
|
||||
Write-Red "Invalid type: $Type"
|
||||
Show-Help
|
||||
exit 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Main
|
||||
@@ -8,7 +8,8 @@ RELEASE=${RELEASE_BUILD:-"0"}
|
||||
RELEASE_TYPE="unknown"
|
||||
VERSION=""
|
||||
COMMIT_HASH=""
|
||||
BUILD_UNIXTIME=""
|
||||
BUILD_UNIXTIME="${BUILD_UNIXTIME}"
|
||||
BUILD_DATE="${BUILD_DATE}"
|
||||
PACKAGE_FILENAME=""
|
||||
DOCKER_TAG=""
|
||||
|
||||
@@ -117,8 +118,15 @@ check_type_dependencies() {
|
||||
|
||||
set_build_parameters() {
|
||||
VERSION="$(grep '"version": ' package.json | awk -F ':' '{print $2}' | tr -d ' ' | tr -d ',' | tr -d '"')"
|
||||
COMMIT_HASH="$(git rev-parse --short HEAD)"
|
||||
BUILD_UNIXTIME="$(date '+%s')"
|
||||
COMMIT_HASH="$(git rev-parse --short=7 HEAD)"
|
||||
|
||||
if [ -z "$BUILD_UNIXTIME" ]; then
|
||||
BUILD_UNIXTIME="$(date '+%s')"
|
||||
fi
|
||||
|
||||
if [ -z "$BUILD_DATE" ]; then
|
||||
BUILD_DATE="$(date '+%Y%m%d')"
|
||||
fi
|
||||
}
|
||||
|
||||
build_backend() {
|
||||
@@ -203,7 +211,7 @@ build_package() {
|
||||
package_file_name="$VERSION";
|
||||
|
||||
if [ "$RELEASE" = "0" ]; then
|
||||
package_file_name="$package_file_name-$(date '+%Y%m%d')"
|
||||
package_file_name="$package_file_name-$BUILD_DATE"
|
||||
fi
|
||||
|
||||
package_file_name="ezbookkeeping-$package_file_name-$(arch).tar.gz"
|
||||
@@ -237,7 +245,7 @@ build_docker() {
|
||||
docker_tag="$VERSION"
|
||||
|
||||
if [ "$RELEASE" = "0" ]; then
|
||||
docker_tag="SNAPSHOT-$(date '+%Y%m%d')";
|
||||
docker_tag="SNAPSHOT-$BUILD_DATE";
|
||||
fi
|
||||
|
||||
docker_tag="ezbookkeeping:$docker_tag"
|
||||
|
||||
@@ -149,5 +149,13 @@ func updateAllDatabaseTablesStructure(c *core.CliContext) error {
|
||||
|
||||
log.BootInfof(c, "[database.updateAllDatabaseTablesStructure] user application cloud settings table maintained successfully")
|
||||
|
||||
err = datastore.Container.UserDataStore.SyncStructs(new(models.UserExternalAuth))
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.BootInfof(c, "[database.updateAllDatabaseTablesStructure] user external auth table maintained successfully")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
+51
-5
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/mayswind/ezbookkeeping/pkg/datastore"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/duplicatechecker"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/exchangerates"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/llm"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/mail"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||
@@ -90,6 +91,15 @@ func initializeSystem(c *core.CliContext) (*settings.Config, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = llm.InitializeLargeLanguageModelProvider(config)
|
||||
|
||||
if err != nil {
|
||||
if !isDisableBootLog {
|
||||
log.BootErrorf(c, "[initializer.initializeSystem] initializes large language model provider failed, because %s", err.Error())
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = uuid.InitializeUuidGenerator(config)
|
||||
|
||||
if err != nil {
|
||||
@@ -152,11 +162,47 @@ func getConfigWithoutSensitiveData(config *settings.Config) *settings.Config {
|
||||
return config
|
||||
}
|
||||
|
||||
clonedConfig.DatabaseConfig.DatabasePassword = "****"
|
||||
clonedConfig.SMTPConfig.SMTPPasswd = "****"
|
||||
clonedConfig.MinIOConfig.SecretAccessKey = "****"
|
||||
clonedConfig.SecretKey = "****"
|
||||
clonedConfig.AmapApplicationSecret = "****"
|
||||
if clonedConfig.DatabaseConfig.DatabasePassword != "" {
|
||||
clonedConfig.DatabaseConfig.DatabasePassword = "****"
|
||||
}
|
||||
|
||||
if clonedConfig.SMTPConfig.SMTPPasswd != "" {
|
||||
clonedConfig.SMTPConfig.SMTPPasswd = "****"
|
||||
}
|
||||
|
||||
if clonedConfig.MinIOConfig.SecretAccessKey != "" {
|
||||
clonedConfig.MinIOConfig.SecretAccessKey = "****"
|
||||
}
|
||||
|
||||
if clonedConfig.SecretKey != "" {
|
||||
clonedConfig.SecretKey = "****"
|
||||
}
|
||||
|
||||
if clonedConfig.AmapApplicationSecret != "" {
|
||||
clonedConfig.AmapApplicationSecret = "****"
|
||||
}
|
||||
|
||||
if clonedConfig.WebDAVConfig != nil && clonedConfig.WebDAVConfig.Password != "" {
|
||||
clonedConfig.WebDAVConfig.Password = "****"
|
||||
}
|
||||
|
||||
if clonedConfig.ReceiptImageRecognitionLLMConfig != nil {
|
||||
if clonedConfig.ReceiptImageRecognitionLLMConfig.OpenAIAPIKey != "" {
|
||||
clonedConfig.ReceiptImageRecognitionLLMConfig.OpenAIAPIKey = "****"
|
||||
}
|
||||
|
||||
if clonedConfig.ReceiptImageRecognitionLLMConfig.OpenAICompatibleAPIKey != "" {
|
||||
clonedConfig.ReceiptImageRecognitionLLMConfig.OpenAICompatibleAPIKey = "****"
|
||||
}
|
||||
|
||||
if clonedConfig.ReceiptImageRecognitionLLMConfig.OpenRouterAPIKey != "" {
|
||||
clonedConfig.ReceiptImageRecognitionLLMConfig.OpenRouterAPIKey = "****"
|
||||
}
|
||||
}
|
||||
|
||||
if clonedConfig.OAuth2ClientSecret != "" {
|
||||
clonedConfig.OAuth2ClientSecret = "****"
|
||||
}
|
||||
|
||||
return clonedConfig
|
||||
}
|
||||
|
||||
+53
-5
@@ -264,7 +264,26 @@ var UserData = &cli.Command{
|
||||
Name: "type",
|
||||
Aliases: []string{"t"},
|
||||
Required: false,
|
||||
Usage: "Specific token type, supports \"normal\" and \"mcp\", default is \"normal\"",
|
||||
Usage: "Specific token type, supports \"api\" and \"mcp\", default is \"api\"",
|
||||
},
|
||||
&cli.Int64Flag{
|
||||
Name: "expiresInSeconds",
|
||||
Aliases: []string{"e"},
|
||||
Required: true,
|
||||
Usage: "Token expiration time in seconds (0 - 4294967295, 0 means no expiration).",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "user-session-revoke",
|
||||
Usage: "Revoke the specified user session",
|
||||
Action: bindAction(revokeUserToken),
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "token",
|
||||
Aliases: []string{"t"},
|
||||
Required: false,
|
||||
Usage: "Specific token content",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -709,17 +728,23 @@ func createNewUserToken(c *core.CliContext) error {
|
||||
|
||||
username := c.String("username")
|
||||
tokenType := c.String("type")
|
||||
expiresInSeconds := c.Int64("expiresInSeconds")
|
||||
|
||||
if tokenType == "" {
|
||||
tokenType = "normal"
|
||||
tokenType = "api"
|
||||
}
|
||||
|
||||
if tokenType != "normal" && tokenType != "mcp" {
|
||||
if tokenType != "api" && tokenType != "mcp" {
|
||||
log.CliErrorf(c, "[user_data.createNewUserToken] token type is invalid")
|
||||
return nil
|
||||
}
|
||||
|
||||
token, tokenString, err := clis.UserData.CreateNewUserToken(c, username, tokenType)
|
||||
if expiresInSeconds < 0 || expiresInSeconds > 4294967295 {
|
||||
log.CliErrorf(c, "[user_data.createNewUserToken] expiresInSeconds is out of range (0 - 4294967295)")
|
||||
return nil
|
||||
}
|
||||
|
||||
token, tokenString, err := clis.UserData.CreateNewUserToken(c, username, tokenType, expiresInSeconds)
|
||||
|
||||
if err != nil {
|
||||
log.CliErrorf(c, "[user_data.createNewUserToken] error occurs when creating user token")
|
||||
@@ -732,6 +757,26 @@ func createNewUserToken(c *core.CliContext) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func revokeUserToken(c *core.CliContext) error {
|
||||
_, err := initializeSystem(c)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
token := c.String("token")
|
||||
err = clis.UserData.RevokeUserToken(c, token)
|
||||
|
||||
if err != nil {
|
||||
log.CliErrorf(c, "[user_data.revokeUserToken] error occurs when revoking user token")
|
||||
return err
|
||||
}
|
||||
|
||||
log.CliInfof(c, "[user_data.revokeUserToken] the specified user token has been revoked successfully")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func clearUserTokens(c *core.CliContext) error {
|
||||
_, err := initializeSystem(c)
|
||||
|
||||
@@ -913,15 +958,18 @@ func printUserInfo(user *models.User) {
|
||||
fmt.Printf("[DefaultCurrency] %s\n", user.DefaultCurrency)
|
||||
fmt.Printf("[FirstDayOfWeek] %s (%d)\n", user.FirstDayOfWeek, user.FirstDayOfWeek)
|
||||
fmt.Printf("[FiscalYearStart] %s (%d)\n", user.FiscalYearStart, user.FiscalYearStart)
|
||||
fmt.Printf("[CalendarDisplayType] %s (%d)\n", user.CalendarDisplayType, user.CalendarDisplayType)
|
||||
fmt.Printf("[DateDisplayType] %s (%d)\n", user.DateDisplayType, user.DateDisplayType)
|
||||
fmt.Printf("[LongDateFormat] %s (%d)\n", user.LongDateFormat, user.LongDateFormat)
|
||||
fmt.Printf("[ShortDateFormat] %s (%d)\n", user.ShortDateFormat, user.ShortDateFormat)
|
||||
fmt.Printf("[LongTimeFormat] %s (%d)\n", user.LongTimeFormat, user.LongTimeFormat)
|
||||
fmt.Printf("[ShortTimeFormat] %s (%d)\n", user.ShortTimeFormat, user.ShortTimeFormat)
|
||||
fmt.Printf("[FiscalYearFormat] %s (%d)\n", user.FiscalYearFormat, user.FiscalYearFormat)
|
||||
fmt.Printf("[CurrencyDisplayType] %s (%d)\n", user.CurrencyDisplayType, user.CurrencyDisplayType)
|
||||
fmt.Printf("[NumeralSystem] %s (%d)\n", user.NumeralSystem, user.NumeralSystem)
|
||||
fmt.Printf("[DecimalSeparator] %s (%d)\n", user.DecimalSeparator, user.DecimalSeparator)
|
||||
fmt.Printf("[DigitGroupingSymbol] %s (%d)\n", user.DigitGroupingSymbol, user.DigitGroupingSymbol)
|
||||
fmt.Printf("[DigitGrouping] %s (%d)\n", user.DigitGrouping, user.DigitGrouping)
|
||||
fmt.Printf("[CurrencyDisplayType] %s (%d)\n", user.CurrencyDisplayType, user.CurrencyDisplayType)
|
||||
fmt.Printf("[CoordinateDisplayType] %s (%d)\n", user.CoordinateDisplayType, user.CoordinateDisplayType)
|
||||
fmt.Printf("[ExpenseAmountColor] %s (%d)\n", user.ExpenseAmountColor, user.ExpenseAmountColor)
|
||||
fmt.Printf("[IncomeAmountColor] %s (%d)\n", user.IncomeAmountColor, user.IncomeAmountColor)
|
||||
|
||||
+2
-2
@@ -81,13 +81,13 @@ func sendTestMail(c *core.CliContext) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if !config.EnableSMTP || mail.Container.Current == nil {
|
||||
if !config.EnableSMTP {
|
||||
return errs.ErrSMTPServerNotEnabled
|
||||
}
|
||||
|
||||
toAddress := c.String("to")
|
||||
|
||||
err = mail.Container.Current.SendMail(&mail.MailMessage{
|
||||
err = mail.Container.SendMail(&mail.MailMessage{
|
||||
To: toAddress,
|
||||
Subject: "ezBookkeeping test e-mail",
|
||||
Body: "This is a test e-mail",
|
||||
|
||||
+79
-18
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/urfave/cli/v3"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/api"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/auth/oauth2"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/cron"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
@@ -72,6 +73,13 @@ func startWebServer(c *core.CliContext) error {
|
||||
return err
|
||||
}
|
||||
|
||||
err = oauth2.InitializeOAuth2Provider(config)
|
||||
|
||||
if err != nil {
|
||||
log.BootErrorf(c, "[webserver.startWebServer] initializes oauth 2.0 provider failed, because %s", err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
err = cron.InitializeCronJobSchedulerContainer(c, config, true)
|
||||
|
||||
if err != nil {
|
||||
@@ -79,7 +87,7 @@ func startWebServer(c *core.CliContext) error {
|
||||
return err
|
||||
}
|
||||
|
||||
serverInfo := fmt.Sprintf("current server id is %d, current instance id is %d", requestid.Container.Current.GetCurrentServerUniqId(), requestid.Container.Current.GetCurrentInstanceUniqId())
|
||||
serverInfo := fmt.Sprintf("current server id is %d, current instance id is %d", requestid.Container.GetCurrentServerUniqId(), requestid.Container.GetCurrentInstanceUniqId())
|
||||
uuidServerInfo := ""
|
||||
if config.UuidGeneratorType == settings.InternalUuidGeneratorType {
|
||||
uuidServerInfo = fmt.Sprintf(", current uuid server id is %d", config.UuidServerId)
|
||||
@@ -104,6 +112,7 @@ func startWebServer(c *core.CliContext) error {
|
||||
_ = v.RegisterValidation("notBlank", validators.NotBlank)
|
||||
_ = v.RegisterValidation("validUsername", validators.ValidUsername)
|
||||
_ = v.RegisterValidation("validEmail", validators.ValidEmail)
|
||||
_ = v.RegisterValidation("validNickname", validators.ValidNickname)
|
||||
_ = v.RegisterValidation("validCurrency", validators.ValidCurrency)
|
||||
_ = v.RegisterValidation("validHexRGBColor", validators.ValidHexRGBColor)
|
||||
_ = v.RegisterValidation("validAmountFilter", validators.ValidAmountFilter)
|
||||
@@ -167,7 +176,7 @@ func startWebServer(c *core.CliContext) error {
|
||||
|
||||
if config.AvatarProvider == core.USER_AVATAR_PROVIDER_INTERNAL {
|
||||
avatarRoute := router.Group("/avatar")
|
||||
avatarRoute.Use(bindMiddleware(middlewares.JWTAuthorizationByQueryString))
|
||||
avatarRoute.Use(bindMiddleware(middlewares.JWTAuthorizationByQueryString(config)))
|
||||
{
|
||||
avatarRoute.GET("/:fileName", bindImage(api.Users.UserGetAvatarHandler))
|
||||
}
|
||||
@@ -175,7 +184,7 @@ func startWebServer(c *core.CliContext) error {
|
||||
|
||||
if config.EnableTransactionPictures {
|
||||
pictureRoute := router.Group("/pictures")
|
||||
pictureRoute.Use(bindMiddleware(middlewares.JWTAuthorizationByQueryString))
|
||||
pictureRoute.Use(bindMiddleware(middlewares.JWTAuthorizationByQueryString(config)))
|
||||
{
|
||||
pictureRoute.GET("/:fileName", bindImage(api.TransactionPictures.TransactionPictureGetHandler))
|
||||
}
|
||||
@@ -184,7 +193,7 @@ func startWebServer(c *core.CliContext) error {
|
||||
router.GET("/healthz.json", bindApi(api.Healths.HealthStatusHandler))
|
||||
|
||||
proxyRoute := router.Group("/proxy")
|
||||
proxyRoute.Use(bindMiddleware(middlewares.JWTAuthorizationByQueryString))
|
||||
proxyRoute.Use(bindMiddleware(middlewares.JWTAuthorizationByQueryString(config)))
|
||||
{
|
||||
if config.EnableMapDataFetchProxy {
|
||||
if config.MapProvider == settings.OpenStreetMapProvider ||
|
||||
@@ -208,7 +217,7 @@ func startWebServer(c *core.CliContext) error {
|
||||
|
||||
if config.MapProvider == settings.AmapProvider && config.AmapSecurityVerificationMethod == settings.AmapSecurityVerificationInternalProxyMethod {
|
||||
amapApiProxyRoute := router.Group("/_AMapService")
|
||||
amapApiProxyRoute.Use(bindMiddleware(middlewares.JWTAuthorizationByCookie))
|
||||
amapApiProxyRoute.Use(bindMiddleware(middlewares.JWTAuthorizationByCookie(config)))
|
||||
{
|
||||
amapApiProxyRoute.GET("/*action", bindProxy(api.AmapApis.AmapApiProxyHandler))
|
||||
}
|
||||
@@ -226,7 +235,7 @@ func startWebServer(c *core.CliContext) error {
|
||||
mcpRoute.Use(bindMiddleware(middlewares.RequestId(config)))
|
||||
mcpRoute.Use(bindMiddleware(middlewares.RequestLog))
|
||||
mcpRoute.Use(bindMiddleware(middlewares.MCPServerIpLimit(config)))
|
||||
mcpRoute.Use(bindMiddleware(middlewares.JWTMCPAuthorization))
|
||||
mcpRoute.Use(bindMiddleware(middlewares.JWTMCPAuthorization(config)))
|
||||
{
|
||||
mcpRoute.POST("", bindJSONRPCApi(map[string]core.JSONRPCApiHandlerFunc{
|
||||
"initialize": api.ModelContextProtocols.InitializeHandler,
|
||||
@@ -242,23 +251,43 @@ func startWebServer(c *core.CliContext) error {
|
||||
}
|
||||
}
|
||||
|
||||
if config.EnableOAuth2Login {
|
||||
oauth2Route := router.Group("/oauth2")
|
||||
oauth2Route.Use(bindMiddleware(middlewares.RequestId(config)))
|
||||
oauth2Route.Use(bindMiddleware(middlewares.RequestLog))
|
||||
{
|
||||
oauth2Route.GET("/login", bindRedirect(api.OAuth2Authentications.LoginHandler))
|
||||
oauth2Route.GET("/callback", bindRedirect(api.OAuth2Authentications.CallbackHandler))
|
||||
}
|
||||
}
|
||||
|
||||
apiRoute := router.Group("/api")
|
||||
|
||||
apiRoute.Use(bindMiddleware(middlewares.RequestId(config)))
|
||||
apiRoute.Use(bindMiddleware(middlewares.RequestLog))
|
||||
{
|
||||
apiRoute.POST("/authorize.json", bindApiWithTokenUpdate(api.Authorizations.AuthorizeHandler, config))
|
||||
if config.EnableInternalAuth {
|
||||
apiRoute.POST("/authorize.json", bindApiWithTokenUpdate(api.Authorizations.AuthorizeHandler, config))
|
||||
}
|
||||
|
||||
if config.EnableTwoFactor {
|
||||
if config.EnableInternalAuth && config.EnableTwoFactor {
|
||||
twoFactorRoute := apiRoute.Group("/2fa")
|
||||
twoFactorRoute.Use(bindMiddleware(middlewares.JWTTwoFactorAuthorization))
|
||||
twoFactorRoute.Use(bindMiddleware(middlewares.JWTTwoFactorAuthorization(config)))
|
||||
{
|
||||
twoFactorRoute.POST("/authorize.json", bindApiWithTokenUpdate(api.Authorizations.TwoFactorAuthorizeHandler, config))
|
||||
twoFactorRoute.POST("/recovery.json", bindApiWithTokenUpdate(api.Authorizations.TwoFactorAuthorizeByRecoveryCodeHandler, config))
|
||||
}
|
||||
}
|
||||
|
||||
if config.EnableUserRegister {
|
||||
if config.EnableOAuth2Login {
|
||||
oauth2Route := apiRoute.Group("/oauth2")
|
||||
oauth2Route.Use(bindMiddleware(middlewares.JWTOAuth2CallbackAuthorization(config)))
|
||||
{
|
||||
oauth2Route.POST("/authorize.json", bindApiWithTokenUpdate(api.Authorizations.OAuth2CallbackAuthorizeHandler, config))
|
||||
}
|
||||
}
|
||||
|
||||
if config.EnableInternalAuth && config.EnableUserRegister {
|
||||
apiRoute.POST("/register.json", bindApiWithTokenUpdate(api.Users.UserRegisterHandler, config))
|
||||
}
|
||||
|
||||
@@ -266,17 +295,17 @@ func startWebServer(c *core.CliContext) error {
|
||||
apiRoute.POST("/verify_email/resend.json", bindApi(api.Users.UserSendVerifyEmailByUnloginUserHandler))
|
||||
|
||||
emailVerifyRoute := apiRoute.Group("/verify_email")
|
||||
emailVerifyRoute.Use(bindMiddleware(middlewares.JWTEmailVerifyAuthorization))
|
||||
emailVerifyRoute.Use(bindMiddleware(middlewares.JWTEmailVerifyAuthorization(config)))
|
||||
{
|
||||
emailVerifyRoute.POST("/by_token.json", bindApi(api.Users.UserEmailVerifyHandler))
|
||||
}
|
||||
}
|
||||
|
||||
if config.EnableUserForgetPassword {
|
||||
if config.EnableInternalAuth && config.EnableUserForgetPassword {
|
||||
apiRoute.POST("/forget_password/request.json", bindApi(api.ForgetPasswords.UserForgetPasswordRequestHandler))
|
||||
|
||||
resetPasswordRoute := apiRoute.Group("/forget_password/reset")
|
||||
resetPasswordRoute.Use(bindMiddleware(middlewares.JWTResetPasswordAuthorization))
|
||||
resetPasswordRoute.Use(bindMiddleware(middlewares.JWTResetPasswordAuthorization(config)))
|
||||
{
|
||||
resetPasswordRoute.POST("/by_token.json", bindApi(api.ForgetPasswords.UserResetPasswordHandler))
|
||||
}
|
||||
@@ -285,10 +314,11 @@ func startWebServer(c *core.CliContext) error {
|
||||
apiRoute.GET("/logout.json", bindApiWithTokenUpdate(api.Tokens.TokenRevokeCurrentHandler, config))
|
||||
|
||||
apiV1Route := apiRoute.Group("/v1")
|
||||
apiV1Route.Use(bindMiddleware(middlewares.JWTAuthorization))
|
||||
apiV1Route.Use(bindMiddleware(middlewares.JWTAuthorization(config)))
|
||||
{
|
||||
// Tokens
|
||||
apiV1Route.GET("/tokens/list.json", bindApi(api.Tokens.TokenListHandler))
|
||||
apiV1Route.POST("/tokens/generate/api.json", bindApi(api.Tokens.TokenGenerateAPIHandler))
|
||||
apiV1Route.POST("/tokens/generate/mcp.json", bindApi(api.Tokens.TokenGenerateMCPHandler))
|
||||
apiV1Route.POST("/tokens/revoke.json", bindApi(api.Tokens.TokenRevokeHandler))
|
||||
apiV1Route.POST("/tokens/revoke_all.json", bindApi(api.Tokens.TokenRevokeAllHandler))
|
||||
@@ -307,6 +337,12 @@ func startWebServer(c *core.CliContext) error {
|
||||
apiV1Route.POST("/users/verify_email/resend.json", bindApi(api.Users.UserSendVerifyEmailByLoginedUserHandler))
|
||||
}
|
||||
|
||||
// External Authentications
|
||||
if config.EnableOAuth2Login {
|
||||
apiV1Route.GET("/users/external_auth/list.json", bindApi(api.UserExternalAuths.ExternalAuthListHanlder))
|
||||
apiV1Route.POST("/users/external_auth/unlink.json", bindApi(api.UserExternalAuths.UnlinkExternalAuthHandler))
|
||||
}
|
||||
|
||||
// Application Cloud Settings
|
||||
apiV1Route.GET("/users/settings/cloud/get.json", bindApi(api.UserApplicationCloudSettings.ApplicationSettingsGetHandler))
|
||||
apiV1Route.POST("/users/settings/cloud/update.json", bindApi(api.UserApplicationCloudSettings.ApplicationSettingsUpdateHandler))
|
||||
@@ -323,7 +359,9 @@ func startWebServer(c *core.CliContext) error {
|
||||
|
||||
// Data
|
||||
apiV1Route.GET("/data/statistics.json", bindApi(api.DataManagements.DataStatisticsHandler))
|
||||
apiV1Route.POST("/data/clear.json", bindApi(api.DataManagements.ClearDataHandler))
|
||||
apiV1Route.POST("/data/clear/all.json", bindApi(api.DataManagements.ClearAllDataHandler))
|
||||
apiV1Route.POST("/data/clear/transactions.json", bindApi(api.DataManagements.ClearAllTransactionsHandler))
|
||||
apiV1Route.POST("/data/clear/transactions/by_account.json", bindApi(api.DataManagements.ClearAllTransactionsByAccountHandler))
|
||||
|
||||
if config.EnableDataExport {
|
||||
apiV1Route.GET("/data/export.csv", bindCsv(api.DataManagements.ExportDataToEzbookkeepingCSVHandler))
|
||||
@@ -344,12 +382,15 @@ func startWebServer(c *core.CliContext) error {
|
||||
apiV1Route.GET("/transactions/count.json", bindApi(api.Transactions.TransactionCountHandler))
|
||||
apiV1Route.GET("/transactions/list.json", bindApi(api.Transactions.TransactionListHandler))
|
||||
apiV1Route.GET("/transactions/list/by_month.json", bindApi(api.Transactions.TransactionMonthListHandler))
|
||||
apiV1Route.GET("/transactions/reconciliation_statements.json", bindApi(api.Transactions.TransactionReconciliationStatementHandler))
|
||||
apiV1Route.GET("/transactions/statistics.json", bindApi(api.Transactions.TransactionStatisticsHandler))
|
||||
apiV1Route.GET("/transactions/statistics/trends.json", bindApi(api.Transactions.TransactionStatisticsTrendsHandler))
|
||||
apiV1Route.GET("/transactions/statistics/asset_trends.json", bindApi(api.Transactions.TransactionStatisticsAssetTrendsHandler))
|
||||
apiV1Route.GET("/transactions/amounts.json", bindApi(api.Transactions.TransactionAmountsHandler))
|
||||
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/move/all.json", bindApi(api.Transactions.TransactionMoveAllBetweenAccountsHandler))
|
||||
apiV1Route.POST("/transactions/delete.json", bindApi(api.Transactions.TransactionDeleteHandler))
|
||||
|
||||
if config.EnableDataImport {
|
||||
@@ -394,6 +435,13 @@ func startWebServer(c *core.CliContext) error {
|
||||
apiV1Route.POST("/transaction/templates/move.json", bindApi(api.TransactionTemplates.TemplateMoveHandler))
|
||||
apiV1Route.POST("/transaction/templates/delete.json", bindApi(api.TransactionTemplates.TemplateDeleteHandler))
|
||||
|
||||
// Large Language Models
|
||||
if config.ReceiptImageRecognitionLLMConfig != nil && config.ReceiptImageRecognitionLLMConfig.LLMProvider != "" {
|
||||
if config.TransactionFromAIImageRecognition {
|
||||
apiV1Route.POST("/llm/transactions/recognize_receipt_image.json", bindApi(api.LargeLanguageModels.RecognizeReceiptImageHandler))
|
||||
}
|
||||
}
|
||||
|
||||
// Exchange Rates
|
||||
apiV1Route.GET("/exchange_rates/latest.json", bindApi(api.ExchangeRates.LatestExchangeRateHandler))
|
||||
apiV1Route.POST("/exchange_rates/user_custom/update.json", bindApi(api.ExchangeRates.UserCustomExchangeRateUpdateHandler))
|
||||
@@ -433,6 +481,19 @@ func bindMiddleware(fn core.MiddlewareHandlerFunc) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func bindRedirect(fn core.RedirectHandlerFunc) gin.HandlerFunc {
|
||||
return func(ginCtx *gin.Context) {
|
||||
c := core.WrapWebContext(ginCtx)
|
||||
url, err := fn(c)
|
||||
|
||||
if err != nil {
|
||||
utils.PrintJsonErrorResult(c, err)
|
||||
} else {
|
||||
c.Redirect(http.StatusFound, url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func bindApi(fn core.ApiHandlerFunc) gin.HandlerFunc {
|
||||
return func(ginCtx *gin.Context) {
|
||||
c := core.WrapWebContext(ginCtx)
|
||||
@@ -521,7 +582,7 @@ func bindCachedJs(fn core.DataHandlerFunc, store persistence.CacheStore) gin.Han
|
||||
if err != nil {
|
||||
utils.PrintDataErrorResult(c, "text/javascript", err)
|
||||
} else {
|
||||
utils.PrintDataSuccessResult(c, "text/javascript", "", result)
|
||||
utils.PrintDataSuccessResult(c, "text/javascript; charset=utf-8", "", result)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -534,7 +595,7 @@ func bindCsv(fn core.DataHandlerFunc) gin.HandlerFunc {
|
||||
if err != nil {
|
||||
utils.PrintDataErrorResult(c, "text/text", err)
|
||||
} else {
|
||||
utils.PrintDataSuccessResult(c, "text/csv", fileName, result)
|
||||
utils.PrintDataSuccessResult(c, "text/csv; charset=utf-8", fileName, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -547,7 +608,7 @@ func bindTsv(fn core.DataHandlerFunc) gin.HandlerFunc {
|
||||
if err != nil {
|
||||
utils.PrintDataErrorResult(c, "text/text", err)
|
||||
} else {
|
||||
utils.PrintDataSuccessResult(c, "text/tab-separated-values", fileName, result)
|
||||
utils.PrintDataSuccessResult(c, "text/tab-separated-values; charset=utf-8", fileName, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+153
-12
@@ -37,6 +37,9 @@ enable_gzip = false
|
||||
# Set to true to log each request and execution time
|
||||
log_request = true
|
||||
|
||||
# Add X-Request-Id header to response to track user request or error, default is true
|
||||
request_id_header = true
|
||||
|
||||
[mcp]
|
||||
# Set to true to enable MCP (Model Context Protocol) server (via http / https web server) for AI/LLM access
|
||||
enable_mcp = false
|
||||
@@ -115,7 +118,7 @@ log_file_max_size = 104857600
|
||||
log_file_max_days = 7
|
||||
|
||||
[storage]
|
||||
# Object storage type, supports "local_filesystem" and "minio" currently
|
||||
# Object storage type, supports "local_filesystem", "minio" and "webdav" currently
|
||||
type = local_filesystem
|
||||
|
||||
# For "local_filesystem" storage only, the storage root path (relative or absolute path)
|
||||
@@ -139,6 +142,82 @@ minio_bucket = ezbookkeeping
|
||||
# For "minio" storage only, the root path to store files in minio
|
||||
minio_root_path = /
|
||||
|
||||
# For "webdav" storage only, the webdav url
|
||||
webdav_url =
|
||||
|
||||
# For "webdav" storage only, the webdav username
|
||||
webdav_username =
|
||||
|
||||
# For "webdav" storage only, the webdav password
|
||||
webdav_password =
|
||||
|
||||
# For "webdav" storage only, the webdav root path to store files
|
||||
webdav_root_path = /
|
||||
|
||||
# For "webdav" storage only, requesting webdav url timeout (0 - 4294967295 milliseconds)
|
||||
# Set to 0 to disable timeout for requesting webdav url, default is 10000 (10 seconds)
|
||||
webdav_request_timeout = 10000
|
||||
|
||||
# For "webdav" storage only, proxy for requesting webdav url, supports "system" (use system proxy), "none" (do not use proxy), or proxy URL which starts with "http://", "https://" or "socks5://", default is "system"
|
||||
webdav_proxy = system
|
||||
|
||||
# For "webdav" storage only, set to true to skip tls verification when connect webdav
|
||||
webdav_skip_tls_verify = false
|
||||
|
||||
[llm]
|
||||
# Set to true to enable creating transactions from AI image recognition results, requires "llm_provider" and its related model id to be configured properly in "llm_image_recognition" section
|
||||
transaction_from_ai_image_recognition = false
|
||||
|
||||
# Maximum allowed AI recognition picture file size (1 - 4294967295 bytes)
|
||||
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"
|
||||
llm_provider =
|
||||
|
||||
# For "openai" llm provider only, OpenAI API secret key, please visit https://platform.openai.com/api-keys for more information
|
||||
openai_api_key =
|
||||
|
||||
# For "openai" llm provider only, receipt image recognition model for creating transactions from images
|
||||
openai_model_id =
|
||||
|
||||
# For "openai_compatible" llm provider only, OpenAI compatible API base url, e.g. "https://api.openai.com/v1/"
|
||||
openai_compatible_base_url =
|
||||
|
||||
# For "openai_compatible" llm provider only, OpenAI compatible API secret key
|
||||
openai_compatible_api_key =
|
||||
|
||||
# For "openai_compatible" llm provider only, receipt image recognition model for creating transactions from images
|
||||
openai_compatible_model_id =
|
||||
|
||||
# For "openrouter" llm provider only, OpenRouter API key, please visit https://openrouter.ai/settings/keys for more information
|
||||
openrouter_api_key =
|
||||
|
||||
# For "openrouter" llm provider only, receipt image recognition model for creating transactions from images
|
||||
openrouter_model_id =
|
||||
|
||||
# For "ollama" llm provider only, Ollama server url, e.g. "http://127.0.0.1:11434/"
|
||||
ollama_server_url =
|
||||
|
||||
# For "ollama" llm provider only, receipt image recognition model for creating transactions from images
|
||||
ollama_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 =
|
||||
|
||||
# For "google_ai" llm provider only, receipt image recognition model for creating transactions from images
|
||||
google_ai_model_id =
|
||||
|
||||
# Requesting large language model api timeout (0 - 4294967295 milliseconds)
|
||||
# Set to 0 to disable timeout for requesting large language model api, default is 60000 (60 seconds)
|
||||
request_timeout = 60000
|
||||
|
||||
# Proxy for ezbookkeeping server requesting large language model api, supports "system" (use system proxy), "none" (do not use proxy), or proxy URL which starts with "http://", "https://" or "socks5://", default is "system"
|
||||
proxy = system
|
||||
|
||||
# Set to true to skip tls verification when request large language model api
|
||||
skip_tls_verify = false
|
||||
|
||||
[uuid]
|
||||
# Uuid generator type, supports "internal" currently
|
||||
generator_type = internal
|
||||
@@ -168,9 +247,6 @@ enable_create_scheduled_transaction = true
|
||||
# Used for signing, you must change it to keep your user data safe before you first run ezBookkeeping
|
||||
secret_key =
|
||||
|
||||
# Set to true to enable two-factor authorization
|
||||
enable_two_factor = true
|
||||
|
||||
# Token expired seconds (60 - 4294967295), default is 2592000 (30 days)
|
||||
token_expired_time = 2592000
|
||||
|
||||
@@ -187,14 +263,81 @@ email_verify_token_expired_time = 3600
|
||||
# Password reset token expired seconds (60 - 4294967295), default is 3600 (60 minutes)
|
||||
password_reset_token_expired_time = 3600
|
||||
|
||||
# Set to true to enable API token generation
|
||||
enable_api_token = false
|
||||
|
||||
# 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
|
||||
|
||||
# Maximum count of password / token check failures (0 - 4294967295) per user per minute (use the above duplicate checker), default is 5, set to 0 to disable
|
||||
max_failures_per_user_per_minute = 5
|
||||
|
||||
# Add X-Request-Id header to response to track user request or error, default is true
|
||||
request_id_header = true
|
||||
[auth]
|
||||
# Set to true to enable internal authentication
|
||||
enable_internal_auth = true
|
||||
|
||||
# Set to true to enable OAuth 2.0 authentication
|
||||
enable_oauth2_auth = false
|
||||
|
||||
# For "internal" authentication only, set to true to enable two-factor authorization
|
||||
enable_two_factor = true
|
||||
|
||||
# For "internal" authentication only, set to true to allow users to reset password
|
||||
enable_forget_password = true
|
||||
|
||||
# For "internal" authentication only, set to true to require email must be verified when use forget password
|
||||
forget_password_require_email_verify = false
|
||||
|
||||
# For "oauth2" authentication only, OAuth 2.0 provider, supports "oidc", "nextcloud", "gitea" and "github" currently
|
||||
oauth2_provider =
|
||||
|
||||
# For "oauth2" authentication only, OAuth 2.0 client ID
|
||||
oauth2_client_id =
|
||||
|
||||
# For "oauth2" authentication only, OAuth 2.0 client secret
|
||||
oauth2_client_secret =
|
||||
|
||||
# For "oauth2" authentication only, OAuth 2.0 provider user identifier claim name, supports "email" and "username", default is "email"
|
||||
oauth2_user_identifier = email
|
||||
|
||||
# For "oauth2" authentication only, set to true to use PKCE
|
||||
oauth2_use_pkce = false
|
||||
|
||||
# For "oauth2" authentication only, if the user returned by OAuth 2.0 is not registered, automatically create a new user (requires "enable_register" to be set to true)
|
||||
oauth2_auto_register = true
|
||||
|
||||
# For "oauth2" authentication only, OAuth 2.0 state expired seconds (60 - 4294967295), default is 300 (5 minutes)
|
||||
oauth2_state_expired_time = 300
|
||||
|
||||
# For "oauth2" authentication only, requesting OAuth 2.0 api timeout (0 - 4294967295 milliseconds)
|
||||
# Set to 0 to disable timeout for requesting OAuth 2.0 api, default is 10000 (10 seconds)
|
||||
oauth2_request_timeout = 10000
|
||||
|
||||
# For "oauth2" authentication only, proxy for ezbookkeeping server requesting OAuth 2.0 api, supports "system" (use system proxy), "none" (do not use proxy), or proxy URL which starts with "http://", "https://" or "socks5://", default is "system"
|
||||
oauth2_proxy = system
|
||||
|
||||
# For "oauth2" authentication only, set to true to skip tls verification when request OAuth 2.0 api
|
||||
oauth2_skip_tls_verify = false
|
||||
|
||||
# For "oauth2" authentication and "oidc" OAuth 2.0 provider only, OIDC provider issuer url. Make sure the ".well-known" directory is available under this path. For example, if it's set to "https://auth.example.com", the discovery URL should be "https://auth.example.com/.well-known/openid-configuration".
|
||||
oidc_provider_base_url =
|
||||
|
||||
# For "oauth2" authentication and "oidc" OAuth 2.0 provider only, set to true to check whether the issuer url in the discovery response matches the above "oidc_provider_base_url"
|
||||
oidc_provider_check_issuer_url = true
|
||||
|
||||
# For "oauth2" authentication and "oidc" OAuth 2.0 provider only, set to true to replace the text "Connect ID" in the "Log in with Connect ID" button with the below custom provider name
|
||||
enable_oidc_display_name = false
|
||||
|
||||
# For "oauth2" authentication and "oidc" OAuth 2.0 provider only, the custom provider name to replace the text in the "Log in with Connect ID" button, it supports multi-language configuration
|
||||
# Add an underscore and a language tag after the setting key to configure the display name in that language
|
||||
# For example, oidc_custom_display_name_zh_hans means the display name in Chinese (Simplified)
|
||||
oidc_custom_display_name =
|
||||
|
||||
# For "oauth2" authentication and "nextcloud" OAuth 2.0 provider only, Nextcloud base url, e.g. "https://cloud.example.org/"
|
||||
nextcloud_base_url =
|
||||
|
||||
# For "oauth2" authentication and "gitea" OAuth 2.0 provider only, Gitea base url, e.g. "https://git.example.com/"
|
||||
gitea_base_url =
|
||||
|
||||
[user]
|
||||
# Set to true to allow users to register account by themselves
|
||||
@@ -206,12 +349,6 @@ enable_email_verify = false
|
||||
# Set to true to require email must be verified when login
|
||||
enable_force_email_verify = false
|
||||
|
||||
# Set to true to allow users to reset password
|
||||
enable_forget_password = true
|
||||
|
||||
# Set to true to require email must be verified when use forget password
|
||||
forget_password_require_email_verify = false
|
||||
|
||||
# Set to true to allow users to upload transaction pictures
|
||||
enable_transaction_picture = true
|
||||
|
||||
@@ -245,6 +382,10 @@ max_user_avatar_size = 1048576
|
||||
# 11: Clear All Data
|
||||
# 12: Sync Application Settings
|
||||
# 13: MCP (Model Context Protocol) Access
|
||||
# 14: Create Transactions from AI Image Recognition
|
||||
# 15: OAuth 2.0 Login
|
||||
# 16: Unlink Third-party Login
|
||||
# 17: Generate API Token
|
||||
default_feature_restrictions =
|
||||
|
||||
[data]
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
variable "DEFAULT_TAG" {
|
||||
default = "ezbookkeeping:local"
|
||||
}
|
||||
|
||||
// Special target: https://github.com/docker/metadata-action#bake-definition
|
||||
target "docker-metadata-action" {
|
||||
tags = ["${DEFAULT_TAG}"]
|
||||
}
|
||||
|
||||
// Default target if none specified
|
||||
group "default" {
|
||||
targets = ["image-local"]
|
||||
}
|
||||
|
||||
target "image" {
|
||||
inherits = ["docker-metadata-action"]
|
||||
context = "./"
|
||||
dockerfile = "Dockerfile"
|
||||
}
|
||||
|
||||
target "image-local" {
|
||||
inherits = ["image"]
|
||||
output = ["type=docker"]
|
||||
}
|
||||
|
||||
target "image-all" {
|
||||
inherits = ["image"]
|
||||
platforms = [
|
||||
"linux/amd64",
|
||||
"linux/arm64",
|
||||
"linux/arm/v7",
|
||||
"linux/arm/v6"
|
||||
]
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
[Unit]
|
||||
Description=ezBookkeeping, a lightweight, self-hosted personal finance app with a sleek, user-friendly interface and powerful bookkeeping features.
|
||||
Description=ezBookkeeping, a lightweight, self-hosted personal finance app with a user-friendly interface and powerful bookkeeping features.
|
||||
After=syslog.target
|
||||
After=network.target
|
||||
After=mariadb.service mysqld.service postgresql.service
|
||||
|
||||
+1
-1
@@ -32,7 +32,7 @@ func main() {
|
||||
|
||||
cmd := &cli.Command{
|
||||
Name: "ezBookkeeping",
|
||||
Usage: "A lightweight, self-hosted personal finance app with a sleek, user-friendly interface and powerful bookkeeping features.",
|
||||
Usage: "A lightweight, self-hosted personal finance app with a user-friendly interface and powerful bookkeeping features.",
|
||||
Version: GetFullVersion(),
|
||||
Commands: []*cli.Command{
|
||||
cmd.WebServer,
|
||||
|
||||
@@ -1,34 +1,37 @@
|
||||
module github.com/mayswind/ezbookkeeping
|
||||
|
||||
go 1.24
|
||||
go 1.25
|
||||
|
||||
require (
|
||||
github.com/boombuler/barcode v1.0.2
|
||||
github.com/boombuler/barcode v1.1.0
|
||||
github.com/coreos/go-oidc/v3 v3.16.0
|
||||
github.com/extrame/xls v0.0.2-0.20200426124601-4a6cf263071b
|
||||
github.com/gin-contrib/cache v1.4.0
|
||||
github.com/gin-contrib/gzip v1.2.3
|
||||
github.com/gin-gonic/gin v1.10.1
|
||||
github.com/go-co-op/gocron/v2 v2.16.2
|
||||
github.com/go-playground/validator/v10 v10.26.0
|
||||
github.com/go-sql-driver/mysql v1.9.2
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2
|
||||
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.17.0
|
||||
github.com/go-playground/validator/v10 v10.28.0
|
||||
github.com/go-sql-driver/mysql v1.9.3
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||
github.com/invopop/jsonschema v0.13.0
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/mattn/go-sqlite3 v1.14.28
|
||||
github.com/minio/minio-go/v7 v7.0.92
|
||||
github.com/mattn/go-sqlite3 v1.14.32
|
||||
github.com/minio/minio-go/v7 v7.0.95
|
||||
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/stretchr/testify v1.10.0
|
||||
github.com/urfave/cli/v3 v3.3.3
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/urfave/cli/v3 v3.5.0
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8
|
||||
github.com/xuri/excelize/v2 v2.9.0
|
||||
golang.org/x/crypto v0.38.0
|
||||
golang.org/x/net v0.40.0
|
||||
golang.org/x/text v0.25.0
|
||||
github.com/xuri/excelize/v2 v2.10.0
|
||||
golang.org/x/crypto v0.43.0
|
||||
golang.org/x/net v0.46.0
|
||||
golang.org/x/oauth2 v0.32.0
|
||||
golang.org/x/text v0.30.0
|
||||
gopkg.in/ini.v1 v1.67.0
|
||||
gopkg.in/mail.v2 v2.3.1
|
||||
xorm.io/builder v0.3.13
|
||||
xorm.io/xorm v1.3.9
|
||||
xorm.io/xorm v1.3.11
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -36,44 +39,48 @@ require (
|
||||
github.com/bahlo/generic-list-go v0.2.0 // indirect
|
||||
github.com/bradfitz/gomemcache v0.0.0-20250403215159-8d39553ac7cf // indirect
|
||||
github.com/buger/jsonparser v1.1.1 // indirect
|
||||
github.com/bytedance/sonic v1.13.2 // indirect
|
||||
github.com/bytedance/sonic/loader v0.2.4 // 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/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
|
||||
github.com/cloudwego/base64x v0.1.5 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
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.9 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.10 // 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-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/golang/snappy v0.0.4 // indirect
|
||||
github.com/gomodule/redigo v1.9.2 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/invopop/jsonschema v0.13.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/cpuid/v2 v2.2.10 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.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.0.1 // indirect
|
||||
github.com/minio/crc64nvme v1.0.2 // 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
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // 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/robfig/cron/v3 v3.0.1 // indirect
|
||||
@@ -82,17 +89,20 @@ require (
|
||||
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.6.0 // indirect
|
||||
github.com/tiendc/go-deepcopy v1.7.1 // indirect
|
||||
github.com/tinylib/msgp v1.3.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
github.com/ugorji/go/codec v1.3.0 // 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.1 // indirect
|
||||
golang.org/x/arch v0.17.0 // indirect
|
||||
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
|
||||
golang.org/x/arch v0.22.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
google.golang.org/protobuf v1.36.6 // indirect
|
||||
golang.org/x/mod v0.29.0 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/sys v0.37.0 // indirect
|
||||
golang.org/x/tools v0.38.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
|
||||
)
|
||||
|
||||
@@ -4,19 +4,20 @@ gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a/go.mod h1:EXuID2Zs0p
|
||||
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
|
||||
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/boombuler/barcode v1.0.2 h1:79yrbttoZrLGkL/oOI8hBrUKucwOL0oOjUgEguGMcJ4=
|
||||
github.com/boombuler/barcode v1.0.2/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo=
|
||||
github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/bradfitz/gomemcache v0.0.0-20250403215159-8d39553ac7cf h1:TqhNAT4zKbTdLa62d2HDBFdvgSbIGB3eJE8HqhgiL9I=
|
||||
github.com/bradfitz/gomemcache v0.0.0-20250403215159-8d39553ac7cf/go.mod h1:r5xuitiExdLAJ09PR7vBVENGvp4ZuTBeWTGtxuX3K+c=
|
||||
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
|
||||
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
|
||||
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.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ=
|
||||
github.com/bytedance/sonic v1.13.2/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4=
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY=
|
||||
github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||
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/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=
|
||||
@@ -24,9 +25,11 @@ github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583j
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d/go.mod h1:8EPpVsBuRksnlj1mLy4AWzRNQYxauNi62uWcE3to6eA=
|
||||
github.com/chenzhuoyu/iasm v0.9.0/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog=
|
||||
github.com/chenzhuoyu/iasm v0.9.1/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog=
|
||||
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
|
||||
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
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.16.0 h1:qRQUCFstKpXwmEjDQTIbyY/5jF00+asXzSkmkoa/mow=
|
||||
github.com/coreos/go-oidc/v3 v3.16.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
|
||||
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=
|
||||
@@ -40,32 +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.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
|
||||
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
|
||||
github.com/gin-contrib/cache v1.4.0 h1:d1FUqCE2+gJQKT0vJjr7jMn1htW9+cypk5oF7aoQcmE=
|
||||
github.com/gin-contrib/cache v1.4.0/go.mod h1:6d0UAPedInkublPl/uJUB4bqwsEgJI1y5QGszhqnyxg=
|
||||
github.com/gin-contrib/gzip v1.2.3 h1:dAhT722RuEG330ce2agAs75z7yB+NKvX/ZM1r8w0u2U=
|
||||
github.com/gin-contrib/gzip v1.2.3/go.mod h1:ad72i4Bzmaypk8M762gNXa2wkxxjbz0icRNnuLJ9a/c=
|
||||
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/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
|
||||
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/go-co-op/gocron/v2 v2.16.2 h1:r08P663ikXiulLT9XaabkLypL/W9MoCIbqgQoAutyX4=
|
||||
github.com/go-co-op/gocron/v2 v2.16.2/go.mod h1:4YTLGCCAH75A5RlQ6q+h+VacO7CgjkgP0EJ+BEOXRSI=
|
||||
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.17.0 h1:e/oj6fcAM8vOOKZxv2Cgfmjo+s8AXC46po5ZPtaSea4=
|
||||
github.com/go-co-op/gocron/v2 v2.17.0/go.mod h1:Zii6he+Zfgy5W9B+JKk/KwejFOW0kZTFvHtwIpR4aBI=
|
||||
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-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.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
|
||||
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||
github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRjiHuU=
|
||||
github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||
github.com/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-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/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
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/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=
|
||||
@@ -87,8 +94,8 @@ github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zt
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
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.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
@@ -101,22 +108,21 @@ 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.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A=
|
||||
github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
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/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.0.1 h1:DHQPrYPdqK7jQG/Ls5CTBZWeex/2FMS3G5XGkycuFrY=
|
||||
github.com/minio/crc64nvme v1.0.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
|
||||
github.com/minio/crc64nvme v1.0.2 h1:6uO1UxGAD+kwqWWp7mBFsi5gAse66C4NXO8cmcVculg=
|
||||
github.com/minio/crc64nvme v1.0.2/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.92 h1:jpBFWyRS3p8P/9tsRc+NuvqoFi7qAmTCFPoRFmobbVw=
|
||||
github.com/minio/minio-go/v7 v7.0.92/go.mod h1:vTIc8DNcnAZIhyFsk8EB90AbPjj3j68aWIEQCiPj7d0=
|
||||
github.com/minio/minio-go/v7 v7.0.95 h1:ywOUPg+PebTMTzn9VDsoFJy32ZuARN9zhB+K3IYEvYU=
|
||||
github.com/minio/minio-go/v7 v7.0.95/go.mod h1:wOOX3uxS334vImCNRVyIDdXX9OsXDm89ToynKgqUKlo=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
|
||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
@@ -125,12 +131,16 @@ github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaR
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c h1:dAMKvw0MlJT1GshSTtih8C2gDs04w8dReiOGXrGLNoY=
|
||||
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/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=
|
||||
@@ -153,50 +163,59 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
||||
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.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
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.6.0/go.mod h1:toXoeQoUqXOOS/X4sKuiAoSk6elIdqc0pN7MTgOOo2I=
|
||||
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/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/urfave/cli/v3 v3.3.3 h1:byCBaVdIXuLPIDm5CYZRVG6NvT7tv1ECqdU4YzlEa3I=
|
||||
github.com/urfave/cli/v3 v3.3.3/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo=
|
||||
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.5.0 h1:qCuFMmdayTF3zmjG8TSsoBzrDqszNrklYg2x3g4MSgw=
|
||||
github.com/urfave/cli/v3 v3.5.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.9.0 h1:1tgOaEq92IOEumR1/JfYS/eR0KHOCsRv/rYXXh6YJQE=
|
||||
github.com/xuri/excelize/v2 v2.9.0/go.mod h1:uqey4QBZ9gdMeWApPLdhm9x+9o2lq4iVmjiLfBS5hdE=
|
||||
github.com/xuri/nfp v0.0.1 h1:MDamSGatIvp8uOmDP8FnmjuQpu90NzdJxo7242ANR9Q=
|
||||
github.com/xuri/nfp v0.0.1/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
|
||||
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/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=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.17.0 h1:4O3dfLzd+lQewptAHqjewQZQDyEdejz3VwgeYwkZneU=
|
||||
golang.org/x/arch v0.17.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
||||
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
|
||||
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
|
||||
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.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
||||
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
|
||||
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI=
|
||||
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
|
||||
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
|
||||
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
|
||||
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
|
||||
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
||||
golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
|
||||
golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
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.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
|
||||
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
|
||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
||||
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
||||
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=
|
||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
@@ -215,5 +234,5 @@ nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYm
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||
xorm.io/builder v0.3.13 h1:a3jmiVVL19psGeXx8GIurTp7p0IIgqeDmwhcR6BAOAo=
|
||||
xorm.io/builder v0.3.13/go.mod h1:aUW0S9eb9VCaPohFCH3j7czOx1PMW3i1HrSzbLYGBSE=
|
||||
xorm.io/xorm v1.3.9 h1:TUovzS0ko+IQ1XnNLfs5dqK1cJl1H5uHpWbWqAQ04nU=
|
||||
xorm.io/xorm v1.3.9/go.mod h1:LsCCffeeYp63ssk0pKumP6l96WZcHix7ChpurcLNuMw=
|
||||
xorm.io/xorm v1.3.11 h1:i4tlVUASogb0ZZFJHA7dZqoRU2pUpUsutnNdaOlFyMI=
|
||||
xorm.io/xorm v1.3.11/go.mod h1:cs0ePc8O4a0jD78cNvD+0VFwhqotTvLQZv372QsDw7Q=
|
||||
|
||||
Generated
+2989
-2026
File diff suppressed because it is too large
Load Diff
+39
-32
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ezbookkeeping",
|
||||
"version": "0.10.0",
|
||||
"version": "1.2.0",
|
||||
"private": true,
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -20,64 +20,71 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@mdi/js": "^7.4.47",
|
||||
"@vuepic/vue-datepicker": "^11.0.2",
|
||||
"axios": "^1.9.0",
|
||||
"@vuepic/vue-datepicker": "^11.0.3",
|
||||
"axios": "^1.12.2",
|
||||
"cbor-js": "^0.1.0",
|
||||
"clipboard": "^2.0.11",
|
||||
"crypto-js": "^4.2.0",
|
||||
"dom7": "^4.0.6",
|
||||
"echarts": "^5.6.0",
|
||||
"echarts": "^6.0.0",
|
||||
"framework7": "^8.3.4",
|
||||
"framework7-icons": "^5.0.5",
|
||||
"framework7-vue": "^8.3.4",
|
||||
"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.2",
|
||||
"pinia": "^3.0.3",
|
||||
"register-service-worker": "^1.7.2",
|
||||
"skeleton-elements": "^4.0.1",
|
||||
"swiper": "^10.2.0",
|
||||
"ua-parser-js": "^1.0.39",
|
||||
"vue": "^3.5.16",
|
||||
"vue-echarts": "^7.0.3",
|
||||
"vue-i18n": "^11.1.5",
|
||||
"vue-router": "^4.5.1",
|
||||
"vue": "^3.5.22",
|
||||
"vue-echarts": "^8.0.1",
|
||||
"vue-i18n": "^11.1.12",
|
||||
"vue-router": "^4.6.3",
|
||||
"vue3-perfect-scrollbar": "^2.0.0",
|
||||
"vuedraggable": "^4.1.0",
|
||||
"vuetify": "^3.8.7"
|
||||
"vuetify": "^3.10.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@jest/globals": "^29.7.0",
|
||||
"@tsconfig/node22": "^22.0.2",
|
||||
"@jest/globals": "^30.2.0",
|
||||
"@tsconfig/node24": "^24.0.1",
|
||||
"@types/cbor-js": "^0.1.1",
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"@types/git-rev-sync": "^2.0.2",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/node": "^22.15.29",
|
||||
"@types/jalaali-js": "^1.2.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^24.9.1",
|
||||
"@types/ua-parser-js": "^0.7.39",
|
||||
"@vitejs/plugin-vue": "^5.2.4",
|
||||
"@vue/eslint-config-typescript": "^14.5.0",
|
||||
"@vue/tsconfig": "^0.7.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^9.28.0",
|
||||
"eslint-plugin-vue": "^10.1.0",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@vue/eslint-config-typescript": "^14.6.0",
|
||||
"@vue/tsconfig": "^0.8.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"eslint": "^9.38.0",
|
||||
"eslint-plugin-vue": "^10.5.1",
|
||||
"git-rev-sync": "^3.0.2",
|
||||
"jest": "^29.7.0",
|
||||
"postcss-preset-env": "^10.2.0",
|
||||
"sass": "^1.89.1",
|
||||
"ts-jest": "^29.3.4",
|
||||
"jest": "^30.2.0",
|
||||
"postcss-preset-env": "^10.4.0",
|
||||
"sass": "^1.93.2",
|
||||
"ts-jest": "^29.4.5",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^6.3.5",
|
||||
"vite-plugin-checker": "^0.9.3",
|
||||
"vite-plugin-pwa": "^1.0.0",
|
||||
"vite-plugin-vuetify": "^2.1.1",
|
||||
"vue-tsc": "^2.2.10"
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.1.12",
|
||||
"vite-plugin-checker": "^0.11.0",
|
||||
"vite-plugin-pwa": "^1.1.0",
|
||||
"vite-plugin-vuetify": "^2.1.2",
|
||||
"vue-tsc": "^3.1.2"
|
||||
},
|
||||
"browserslist": [
|
||||
"> 1%",
|
||||
"last 2 versions",
|
||||
"last 5 Chrome versions",
|
||||
"last 5 Firefox versions",
|
||||
"last 5 Safari versions",
|
||||
"last 5 Edge versions",
|
||||
"last 5 ChromeAndroid versions",
|
||||
"last 5 iOS versions",
|
||||
"not IE <= 11",
|
||||
"not dead"
|
||||
]
|
||||
}
|
||||
|
||||
+201
-3
@@ -1,6 +1,9 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
"github.com/pquerna/otp/totp"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/avatars"
|
||||
@@ -22,6 +25,7 @@ type AuthorizationsApi struct {
|
||||
userAppCloudSettings *services.UserApplicationCloudSettingsService
|
||||
tokens *services.TokenService
|
||||
twoFactorAuthorizations *services.TwoFactorAuthorizationService
|
||||
userExternalAuths *services.UserExternalAuthService
|
||||
}
|
||||
|
||||
// Initialize a authorization api singleton instance
|
||||
@@ -48,11 +52,16 @@ var (
|
||||
userAppCloudSettings: services.UserApplicationCloudSettings,
|
||||
tokens: services.Tokens,
|
||||
twoFactorAuthorizations: services.TwoFactorAuthorizations,
|
||||
userExternalAuths: services.UserExternalAuths,
|
||||
}
|
||||
)
|
||||
|
||||
// AuthorizeHandler verifies and authorizes current login request
|
||||
func (a *AuthorizationsApi) AuthorizeHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
if !a.CurrentConfig().EnableInternalAuth {
|
||||
return nil, errs.ErrCannotLoginByPassword
|
||||
}
|
||||
|
||||
var credential models.UserLoginRequest
|
||||
err := c.ShouldBindJSON(&credential)
|
||||
|
||||
@@ -141,6 +150,7 @@ func (a *AuthorizationsApi) AuthorizeHandler(c *core.WebContext) (any, *errs.Err
|
||||
}
|
||||
|
||||
c.SetTokenClaims(claims)
|
||||
c.SetTokenContext("")
|
||||
|
||||
userApplicationCloudSettings, err := a.userAppCloudSettings.GetUserApplicationCloudSettingsByUid(c, user.Uid)
|
||||
var applicationCloudSettingSlice *models.ApplicationCloudSettingSlice = nil
|
||||
@@ -151,7 +161,7 @@ func (a *AuthorizationsApi) AuthorizeHandler(c *core.WebContext) (any, *errs.Err
|
||||
applicationCloudSettingSlice = &userApplicationCloudSettings.Settings
|
||||
}
|
||||
|
||||
log.Infof(c, "[authorizations.AuthorizeHandler] user \"uid:%d\" has logined, token type is %d, token will be expired at %d", user.Uid, claims.Type, claims.ExpiresAt)
|
||||
log.Infof(c, "[authorizations.AuthorizeHandler] user \"uid:%d\" has logged in, token type is %d, token will be expired at %d", user.Uid, claims.Type, claims.ExpiresAt)
|
||||
|
||||
authResp := a.getAuthResponse(c, token, twoFactorEnable, user, applicationCloudSettingSlice)
|
||||
return authResp, nil
|
||||
@@ -159,6 +169,10 @@ func (a *AuthorizationsApi) AuthorizeHandler(c *core.WebContext) (any, *errs.Err
|
||||
|
||||
// TwoFactorAuthorizeHandler verifies and authorizes current 2fa login by passcode
|
||||
func (a *AuthorizationsApi) TwoFactorAuthorizeHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
if !a.CurrentConfig().EnableInternalAuth {
|
||||
return nil, errs.ErrCannotLoginByPassword
|
||||
}
|
||||
|
||||
var credential models.TwoFactorLoginRequest
|
||||
err := c.ShouldBindJSON(&credential)
|
||||
|
||||
@@ -198,7 +212,7 @@ func (a *AuthorizationsApi) TwoFactorAuthorizeHandler(c *core.WebContext) (any,
|
||||
user, err := a.users.GetUserById(c, uid)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[authorizations.TwoFactorAuthorizeHandler] failed to get user \"uid:%d\" info, because %s", user.Uid, err.Error())
|
||||
log.Errorf(c, "[authorizations.TwoFactorAuthorizeHandler] failed to get user \"uid:%d\" info, because %s", uid, err.Error())
|
||||
return nil, errs.ErrUserNotFound
|
||||
}
|
||||
|
||||
@@ -228,6 +242,7 @@ func (a *AuthorizationsApi) TwoFactorAuthorizeHandler(c *core.WebContext) (any,
|
||||
|
||||
c.SetTextualToken(token)
|
||||
c.SetTokenClaims(claims)
|
||||
c.SetTokenContext("")
|
||||
|
||||
userApplicationCloudSettings, err := a.userAppCloudSettings.GetUserApplicationCloudSettingsByUid(c, user.Uid)
|
||||
var applicationCloudSettingSlice *models.ApplicationCloudSettingSlice = nil
|
||||
@@ -246,6 +261,10 @@ func (a *AuthorizationsApi) TwoFactorAuthorizeHandler(c *core.WebContext) (any,
|
||||
|
||||
// TwoFactorAuthorizeByRecoveryCodeHandler verifies and authorizes current 2fa login by recovery code
|
||||
func (a *AuthorizationsApi) TwoFactorAuthorizeByRecoveryCodeHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
if !a.CurrentConfig().EnableInternalAuth {
|
||||
return nil, errs.ErrCannotLoginByPassword
|
||||
}
|
||||
|
||||
var credential models.TwoFactorRecoveryCodeLoginRequest
|
||||
err := c.ShouldBindJSON(&credential)
|
||||
|
||||
@@ -276,7 +295,7 @@ func (a *AuthorizationsApi) TwoFactorAuthorizeByRecoveryCodeHandler(c *core.WebC
|
||||
user, err := a.users.GetUserById(c, uid)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] failed to get user \"uid:%d\" info, because %s", user.Uid, err.Error())
|
||||
log.Errorf(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] failed to get user \"uid:%d\" info, because %s", uid, err.Error())
|
||||
return nil, errs.ErrUserNotFound
|
||||
}
|
||||
|
||||
@@ -322,6 +341,7 @@ func (a *AuthorizationsApi) TwoFactorAuthorizeByRecoveryCodeHandler(c *core.WebC
|
||||
|
||||
c.SetTextualToken(token)
|
||||
c.SetTokenClaims(claims)
|
||||
c.SetTokenContext("")
|
||||
|
||||
userApplicationCloudSettings, err := a.userAppCloudSettings.GetUserApplicationCloudSettingsByUid(c, user.Uid)
|
||||
var applicationCloudSettingSlice *models.ApplicationCloudSettingSlice = nil
|
||||
@@ -338,6 +358,184 @@ func (a *AuthorizationsApi) TwoFactorAuthorizeByRecoveryCodeHandler(c *core.WebC
|
||||
return authResp, nil
|
||||
}
|
||||
|
||||
// OAuth2CallbackAuthorizeHandler verifies and authorizes current OAuth 2.0 callback login
|
||||
func (a *AuthorizationsApi) OAuth2CallbackAuthorizeHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
if !a.CurrentConfig().EnableOAuth2Login {
|
||||
return nil, errs.ErrOAuth2NotEnabled
|
||||
}
|
||||
|
||||
var credential models.OAuth2CallbackLoginRequest
|
||||
err := c.ShouldBindJSON(&credential)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[authorizations.OAuth2CallbackAuthorizeHandler] parse request failed, because %s", err.Error())
|
||||
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||
}
|
||||
|
||||
var tokenContext models.OAuth2CallbackTokenContext
|
||||
err = json.Unmarshal([]byte(c.GetTokenContext()), &tokenContext)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[authorizations.OAuth2CallbackAuthorizeHandler] parse token context failed, because %s", err.Error())
|
||||
return nil, errs.ErrOperationFailed
|
||||
}
|
||||
|
||||
if !tokenContext.ExternalAuthType.IsValid() {
|
||||
log.Warnf(c, "[authorizations.OAuth2CallbackAuthorizeHandler] external auth type \"%s\" is invalid", tokenContext.ExternalAuthType)
|
||||
return nil, errs.ErrInvalidOAuth2Provider
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
err = a.CheckFailureCount(c, uid)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[authorizations.OAuth2CallbackAuthorizeHandler] cannot auth for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrFailureCountLimitReached)
|
||||
}
|
||||
|
||||
user, err := a.users.GetUserById(c, uid)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[authorizations.OAuth2CallbackAuthorizeHandler] failed to get user \"uid:%d\" info, because %s", uid, err.Error())
|
||||
return nil, errs.ErrUserNotFound
|
||||
}
|
||||
|
||||
if user.Disabled {
|
||||
log.Warnf(c, "[authorizations.OAuth2CallbackAuthorizeHandler] user \"uid:%d\" is disabled", user.Uid)
|
||||
return nil, errs.ErrUserIsDisabled
|
||||
}
|
||||
|
||||
if a.CurrentConfig().EnableUserForceVerifyEmail && !user.EmailVerified {
|
||||
log.Warnf(c, "[authorizations.OAuth2CallbackAuthorizeHandler] user \"uid:%d\" has not verified email", user.Uid)
|
||||
return nil, errs.ErrEmailIsNotVerified
|
||||
}
|
||||
|
||||
oldTokenClaims := c.GetTokenClaims()
|
||||
|
||||
if oldTokenClaims.Type == core.USER_TOKEN_TYPE_OAUTH2_CALLBACK_REQUIRE_VERIFY {
|
||||
if credential.Password == "" {
|
||||
return nil, errs.ErrPasswordIsEmpty
|
||||
}
|
||||
|
||||
if !a.users.IsPasswordEqualsUserPassword(credential.Password, user) {
|
||||
failureCheckErr := a.CheckAndIncreaseFailureCount(c, uid)
|
||||
|
||||
if failureCheckErr != nil {
|
||||
log.Warnf(c, "[authorizations.OAuth2CallbackAuthorizeHandler] cannot login for user \"uid:%d\", because %s", user.Uid, failureCheckErr.Error())
|
||||
return nil, errs.Or(failureCheckErr, errs.ErrFailureCountLimitReached)
|
||||
}
|
||||
|
||||
return nil, errs.ErrUserPasswordWrong
|
||||
}
|
||||
|
||||
if a.CurrentConfig().EnableTwoFactor {
|
||||
twoFactorSetting, err := a.twoFactorAuthorizations.GetUserTwoFactorSettingByUid(c, uid)
|
||||
|
||||
if err != nil && !errors.Is(err, errs.ErrTwoFactorIsNotEnabled) {
|
||||
log.Errorf(c, "[authorizations.OAuth2CallbackAuthorizeHandler] failed to check two-factor setting for user \"uid:%d\", because %s", user.Uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrSystemError)
|
||||
}
|
||||
|
||||
if twoFactorSetting != nil {
|
||||
if credential.Passcode == "" {
|
||||
return nil, errs.ErrPasscodeEmpty
|
||||
}
|
||||
|
||||
if !totp.Validate(credential.Passcode, twoFactorSetting.Secret) {
|
||||
log.Warnf(c, "[authorizations.OAuth2CallbackAuthorizeHandler] passcode is invalid for user \"uid:%d\"", uid)
|
||||
|
||||
err = a.CheckAndIncreaseFailureCount(c, uid)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[authorizations.OAuth2CallbackAuthorizeHandler] cannot auth for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrFailureCountLimitReached)
|
||||
}
|
||||
|
||||
return nil, errs.ErrPasscodeInvalid
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
userExternalAuth := &models.UserExternalAuth{
|
||||
Uid: user.Uid,
|
||||
ExternalAuthType: tokenContext.ExternalAuthType,
|
||||
ExternalUsername: tokenContext.ExternalUsername,
|
||||
ExternalEmail: tokenContext.ExternalEmail,
|
||||
}
|
||||
|
||||
err = a.userExternalAuths.CreateUserExternalAuth(c, userExternalAuth)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[authorizations.OAuth2CallbackAuthorizeHandler] failed to create user external auth for user \"uid:%d\", because %s", user.Uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
log.Infof(c, "[authorizations.OAuth2CallbackAuthorizeHandler] user external auth has been created for user \"uid:%d\"", user.Uid)
|
||||
} else if oldTokenClaims.Type == core.USER_TOKEN_TYPE_OAUTH2_CALLBACK {
|
||||
_, err = a.userExternalAuths.GetUserExternalAuthByUid(c, uid, tokenContext.ExternalAuthType)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[authorizations.OAuth2CallbackAuthorizeHandler] failed to get user external auth for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrUserExternalAuthNotFound)
|
||||
}
|
||||
} else {
|
||||
return nil, errs.ErrSystemError
|
||||
}
|
||||
|
||||
err = a.tokens.DeleteTokenByClaims(c, oldTokenClaims)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[authorizations.OAuth2CallbackAuthorizeHandler] failed to revoke temporary token \"utid:%s\" for user \"uid:%d\", because %s", oldTokenClaims.UserTokenId, user.Uid, err.Error())
|
||||
}
|
||||
|
||||
var token string
|
||||
var claims *core.UserTokenClaims
|
||||
|
||||
if credential.Token != "" {
|
||||
_, claims, _, err = a.tokens.ParseToken(c, credential.Token)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[authorizations.OAuth2CallbackAuthorizeHandler] failed to parse token, because %s", err.Error())
|
||||
return nil, errs.ErrInvalidToken
|
||||
}
|
||||
|
||||
if claims.Uid != user.Uid {
|
||||
log.Warnf(c, "[authorizations.OAuth2CallbackAuthorizeHandler] oauth 2.0 user \"uid:%d\" does not match current user \"uid:%d\"", user.Uid, claims.Uid)
|
||||
token = ""
|
||||
claims = nil
|
||||
} else {
|
||||
token = credential.Token
|
||||
}
|
||||
}
|
||||
|
||||
if token == "" {
|
||||
token, claims, err = a.tokens.CreateToken(c, user)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[authorizations.OAuth2CallbackAuthorizeHandler] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error())
|
||||
return nil, errs.ErrTokenGenerating
|
||||
}
|
||||
}
|
||||
|
||||
c.SetTextualToken(token)
|
||||
c.SetTokenClaims(claims)
|
||||
c.SetTokenContext("")
|
||||
|
||||
userApplicationCloudSettings, err := a.userAppCloudSettings.GetUserApplicationCloudSettingsByUid(c, user.Uid)
|
||||
var applicationCloudSettingSlice *models.ApplicationCloudSettingSlice = nil
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[authorizations.OAuth2CallbackAuthorizeHandler] failed to get latest user application cloud settings for user \"uid:%d\", because %s", user.Uid, err.Error())
|
||||
} else if userApplicationCloudSettings != nil && len(userApplicationCloudSettings.Settings) > 0 {
|
||||
applicationCloudSettingSlice = &userApplicationCloudSettings.Settings
|
||||
}
|
||||
|
||||
log.Infof(c, "[authorizations.OAuth2CallbackAuthorizeHandler] user \"uid:%d\" has logged in, token will be expired at %d", user.Uid, claims.ExpiresAt)
|
||||
|
||||
authResp := a.getAuthResponse(c, token, false, user, applicationCloudSettingSlice)
|
||||
return authResp, nil
|
||||
}
|
||||
|
||||
func (a *AuthorizationsApi) getAuthResponse(c *core.WebContext, token string, need2FA bool, user *models.User, applicationCloudSettings *models.ApplicationCloudSettingSlice) *models.AuthResponse {
|
||||
return &models.AuthResponse{
|
||||
Token: token,
|
||||
|
||||
+21
-10
@@ -3,6 +3,7 @@ package api
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/avatars"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
@@ -23,7 +24,7 @@ type ApiUsingConfig struct {
|
||||
|
||||
// CurrentConfig returns the current config
|
||||
func (a *ApiUsingConfig) CurrentConfig() *settings.Config {
|
||||
return a.container.Current
|
||||
return a.container.GetCurrentConfig()
|
||||
}
|
||||
|
||||
// GetTransactionPictureInfoResponse returns the view-object of transaction picture basic info according to the transaction picture model
|
||||
@@ -53,15 +54,15 @@ func (a *ApiUsingConfig) GetAfterRegisterNotificationContent(userLanguage string
|
||||
language = clientLanguage
|
||||
}
|
||||
|
||||
if !a.container.Current.AfterRegisterNotification.Enabled {
|
||||
if !a.CurrentConfig().AfterRegisterNotification.Enabled {
|
||||
return ""
|
||||
}
|
||||
|
||||
if multiLanguageContent, exists := a.container.Current.AfterRegisterNotification.MultiLanguageContent[language]; exists {
|
||||
if multiLanguageContent, exists := a.CurrentConfig().AfterRegisterNotification.MultiLanguageContent[language]; exists {
|
||||
return multiLanguageContent
|
||||
}
|
||||
|
||||
return a.container.Current.AfterRegisterNotification.DefaultContent
|
||||
return a.CurrentConfig().AfterRegisterNotification.DefaultContent
|
||||
}
|
||||
|
||||
// GetAfterLoginNotificationContent returns the notification content displayed each time users log in
|
||||
@@ -72,15 +73,15 @@ func (a *ApiUsingConfig) GetAfterLoginNotificationContent(userLanguage string, c
|
||||
language = clientLanguage
|
||||
}
|
||||
|
||||
if !a.container.Current.AfterLoginNotification.Enabled {
|
||||
if !a.CurrentConfig().AfterLoginNotification.Enabled {
|
||||
return ""
|
||||
}
|
||||
|
||||
if multiLanguageContent, exists := a.container.Current.AfterLoginNotification.MultiLanguageContent[language]; exists {
|
||||
if multiLanguageContent, exists := a.CurrentConfig().AfterLoginNotification.MultiLanguageContent[language]; exists {
|
||||
return multiLanguageContent
|
||||
}
|
||||
|
||||
return a.container.Current.AfterLoginNotification.DefaultContent
|
||||
return a.CurrentConfig().AfterLoginNotification.DefaultContent
|
||||
}
|
||||
|
||||
// GetAfterOpenNotificationContent returns the notification content displayed each time users open the app
|
||||
@@ -91,15 +92,15 @@ func (a *ApiUsingConfig) GetAfterOpenNotificationContent(userLanguage string, cl
|
||||
language = clientLanguage
|
||||
}
|
||||
|
||||
if !a.container.Current.AfterOpenNotification.Enabled {
|
||||
if !a.CurrentConfig().AfterOpenNotification.Enabled {
|
||||
return ""
|
||||
}
|
||||
|
||||
if multiLanguageContent, exists := a.container.Current.AfterOpenNotification.MultiLanguageContent[language]; exists {
|
||||
if multiLanguageContent, exists := a.CurrentConfig().AfterOpenNotification.MultiLanguageContent[language]; exists {
|
||||
return multiLanguageContent
|
||||
}
|
||||
|
||||
return a.container.Current.AfterOpenNotification.DefaultContent
|
||||
return a.CurrentConfig().AfterOpenNotification.DefaultContent
|
||||
}
|
||||
|
||||
// ApiUsingDuplicateChecker represents an api that need to use duplicate checker
|
||||
@@ -113,6 +114,11 @@ func (a *ApiUsingDuplicateChecker) GetSubmissionRemark(checkerType duplicatechec
|
||||
return a.container.GetSubmissionRemark(checkerType, uid, identification)
|
||||
}
|
||||
|
||||
// SetSubmissionRemarkWithCustomExpiration saves the identification and remark by the current duplicate checker with custom expiration time
|
||||
func (a *ApiUsingDuplicateChecker) SetSubmissionRemarkWithCustomExpiration(checkerType duplicatechecker.DuplicateCheckerType, uid int64, identification string, remark string, expiration time.Duration) {
|
||||
a.container.SetSubmissionRemarkWithCustomExpiration(checkerType, uid, identification, remark, expiration)
|
||||
}
|
||||
|
||||
// SetSubmissionRemarkIfEnable saves the identification and remark by the current duplicate checker if the duplicate submission check is enabled
|
||||
func (a *ApiUsingDuplicateChecker) SetSubmissionRemarkIfEnable(checkerType duplicatechecker.DuplicateCheckerType, uid int64, identification string, remark string) {
|
||||
if a.CurrentConfig().EnableDuplicateSubmissionsCheck {
|
||||
@@ -120,6 +126,11 @@ func (a *ApiUsingDuplicateChecker) SetSubmissionRemarkIfEnable(checkerType dupli
|
||||
}
|
||||
}
|
||||
|
||||
// RemoveSubmissionRemark removes the identification and remark by the current duplicate checker
|
||||
func (a *ApiUsingDuplicateChecker) RemoveSubmissionRemark(checkerType duplicatechecker.DuplicateCheckerType, uid int64, identification string) {
|
||||
a.container.RemoveSubmissionRemark(checkerType, uid, identification)
|
||||
}
|
||||
|
||||
// RemoveSubmissionRemarkIfEnable removes the identification and remark by the current duplicate checker if the duplicate submission check is enabled
|
||||
func (a *ApiUsingDuplicateChecker) RemoveSubmissionRemarkIfEnable(checkerType duplicatechecker.DuplicateCheckerType, uid int64, identification string) {
|
||||
if a.CurrentConfig().EnableDuplicateSubmissionsCheck {
|
||||
|
||||
+109
-12
@@ -2,6 +2,7 @@ package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -15,6 +16,7 @@ import (
|
||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||
)
|
||||
|
||||
const pageCountForClearTransactions = 1000
|
||||
const pageCountForDataExport = 1000
|
||||
|
||||
// DataManagementsApi represents data management api
|
||||
@@ -124,13 +126,13 @@ func (a *DataManagementsApi) DataStatisticsHandler(c *core.WebContext) (any, *er
|
||||
return dataStatisticsResp, nil
|
||||
}
|
||||
|
||||
// ClearDataHandler deletes all user data
|
||||
func (a *DataManagementsApi) ClearDataHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
// ClearAllDataHandler deletes all user data
|
||||
func (a *DataManagementsApi) ClearAllDataHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
var clearDataReq models.ClearDataRequest
|
||||
err := c.ShouldBindJSON(&clearDataReq)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[data_managements.ClearDataHandler] parse request failed, because %s", err.Error())
|
||||
log.Warnf(c, "[data_managements.ClearAllDataHandler] parse request failed, because %s", err.Error())
|
||||
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||
}
|
||||
|
||||
@@ -139,7 +141,7 @@ func (a *DataManagementsApi) ClearDataHandler(c *core.WebContext) (any, *errs.Er
|
||||
|
||||
if err != nil {
|
||||
if !errs.IsCustomError(err) {
|
||||
log.Warnf(c, "[data_managements.ClearDataHandler] failed to get user for user \"uid:%d\", because %s", uid, err.Error())
|
||||
log.Warnf(c, "[data_managements.ClearAllDataHandler] failed to get user for user \"uid:%d\", because %s", uid, err.Error())
|
||||
}
|
||||
|
||||
return nil, errs.ErrUserNotFound
|
||||
@@ -156,39 +158,134 @@ func (a *DataManagementsApi) ClearDataHandler(c *core.WebContext) (any, *errs.Er
|
||||
err = a.templates.DeleteAllTemplates(c, uid)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[data_managements.ClearDataHandler] failed to delete all transaction templates, because %s", err.Error())
|
||||
log.Errorf(c, "[data_managements.ClearAllDataHandler] failed to delete all transaction templates, because %s", err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
err = a.transactions.DeleteAllTransactions(c, uid)
|
||||
err = a.transactions.DeleteAllTransactions(c, uid, true)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[data_managements.ClearDataHandler] failed to delete all transactions, because %s", err.Error())
|
||||
log.Errorf(c, "[data_managements.ClearAllDataHandler] failed to delete all transactions, because %s", err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
err = a.categories.DeleteAllCategories(c, uid)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[data_managements.ClearDataHandler] failed to delete all transaction categories, because %s", err.Error())
|
||||
log.Errorf(c, "[data_managements.ClearAllDataHandler] failed to delete all transaction categories, because %s", err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
err = a.tags.DeleteAllTags(c, uid)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[data_managements.ClearDataHandler] failed to delete all transaction tags, because %s", err.Error())
|
||||
log.Errorf(c, "[data_managements.ClearAllDataHandler] failed to delete all transaction tags, because %s", err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
err = a.userCustomExchangeRates.DeleteAllCustomExchangeRates(c, uid)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[data_managements.ClearDataHandler] failed to delete all user custom exchange rates, because %s", err.Error())
|
||||
log.Errorf(c, "[data_managements.ClearAllDataHandler] failed to delete all user custom exchange rates, because %s", err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
log.Infof(c, "[data_managements.ClearDataHandler] user \"uid:%d\" has cleared all data", uid)
|
||||
log.Infof(c, "[data_managements.ClearAllDataHandler] user \"uid:%d\" has cleared all data", uid)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// ClearAllTransactionsHandler deletes all transactions
|
||||
func (a *DataManagementsApi) ClearAllTransactionsHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
var clearDataReq models.ClearDataRequest
|
||||
err := c.ShouldBindJSON(&clearDataReq)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[data_managements.ClearAllTransactionsHandler] parse request failed, because %s", err.Error())
|
||||
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
user, err := a.users.GetUserById(c, uid)
|
||||
|
||||
if err != nil {
|
||||
if !errs.IsCustomError(err) {
|
||||
log.Warnf(c, "[data_managements.ClearAllTransactionsHandler] failed to get user for user \"uid:%d\", because %s", uid, err.Error())
|
||||
}
|
||||
|
||||
return nil, errs.ErrUserNotFound
|
||||
}
|
||||
|
||||
if !a.users.IsPasswordEqualsUserPassword(clearDataReq.Password, user) {
|
||||
return nil, errs.ErrUserPasswordWrong
|
||||
}
|
||||
|
||||
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_CLEAR_ALL_DATA) {
|
||||
return nil, errs.ErrNotPermittedToPerformThisAction
|
||||
}
|
||||
|
||||
err = a.transactions.DeleteAllTransactions(c, uid, false)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[data_managements.ClearAllTransactionsHandler] failed to delete all transactions, because %s", err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
log.Infof(c, "[data_managements.ClearAllTransactionsHandler] user \"uid:%d\" has cleared all transactions", uid)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// ClearAllTransactionsByAccountHandler deletes all transactions of specified account
|
||||
func (a *DataManagementsApi) ClearAllTransactionsByAccountHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
var clearDataReq models.ClearAccountTransactionsRequest
|
||||
err := c.ShouldBindJSON(&clearDataReq)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[data_managements.ClearAllTransactionsByAccountHandler] parse request failed, because %s", err.Error())
|
||||
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
user, err := a.users.GetUserById(c, uid)
|
||||
|
||||
if err != nil {
|
||||
if !errs.IsCustomError(err) {
|
||||
log.Warnf(c, "[data_managements.ClearAllTransactionsByAccountHandler] failed to get user for user \"uid:%d\", because %s", uid, err.Error())
|
||||
}
|
||||
|
||||
return nil, errs.ErrUserNotFound
|
||||
}
|
||||
|
||||
if !a.users.IsPasswordEqualsUserPassword(clearDataReq.Password, user) {
|
||||
return nil, errs.ErrUserPasswordWrong
|
||||
}
|
||||
|
||||
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_CLEAR_ALL_DATA) {
|
||||
return nil, errs.ErrNotPermittedToPerformThisAction
|
||||
}
|
||||
|
||||
account, err := a.accounts.GetAccountByAccountId(c, uid, clearDataReq.AccountId)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[data_managements.ClearAllTransactionsByAccountHandler] failed to get account \"id:%d\" for user \"uid:%d\", because %s", uid, clearDataReq.AccountId, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
if account.Hidden {
|
||||
return nil, errs.ErrCannotDeleteTransactionInHiddenAccount
|
||||
}
|
||||
|
||||
if account.Type == models.ACCOUNT_TYPE_MULTI_SUB_ACCOUNTS {
|
||||
return nil, errs.ErrCannotDeleteTransactionInParentAccount
|
||||
}
|
||||
|
||||
err = a.transactions.DeleteAllTransactionsOfAccount(c, uid, account.AccountId, pageCountForClearTransactions)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[data_managements.ClearAllTransactionsByAccountHandler] failed to delete all transactions in account \"id:%d\", because %s", account.AccountId, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
log.Infof(c, "[data_managements.ClearAllTransactionsByAccountHandler] user \"uid:%d\" has cleared all transactions in account \"id:%d\"", uid, account.AccountId)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
@@ -287,7 +384,7 @@ func (a *DataManagementsApi) getExportedFileContent(c *core.WebContext, fileType
|
||||
}
|
||||
}
|
||||
|
||||
maxTransactionTime := utils.GetMaxTransactionTimeFromUnixTime(time.Now().Unix())
|
||||
maxTransactionTime := int64(math.MaxInt64)
|
||||
minTransactionTime := int64(0)
|
||||
|
||||
if exportTransactionDataReq.MaxTime > 0 {
|
||||
|
||||
@@ -30,13 +30,7 @@ var (
|
||||
|
||||
// LatestExchangeRateHandler returns latest exchange rate data
|
||||
func (a *ExchangeRatesApi) LatestExchangeRateHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
dataSource := exchangerates.Container.Current
|
||||
|
||||
if dataSource == nil {
|
||||
return nil, errs.ErrInvalidExchangeRatesDataSource
|
||||
}
|
||||
|
||||
exchangeRateResponse, err := dataSource.GetLatestExchangeRates(c, c.GetCurrentUid(), a.container.Current)
|
||||
exchangeRateResponse, err := exchangerates.Container.GetLatestExchangeRates(c, c.GetCurrentUid(), a.CurrentConfig())
|
||||
|
||||
if err != nil {
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/duplicatechecker"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
// ForgetPasswordsApi represents user forget password api
|
||||
type ForgetPasswordsApi struct {
|
||||
ApiUsingConfig
|
||||
ApiUsingDuplicateChecker
|
||||
users *services.UserService
|
||||
tokens *services.TokenService
|
||||
forgetPasswords *services.ForgetPasswordService
|
||||
@@ -25,6 +27,12 @@ var (
|
||||
ApiUsingConfig: ApiUsingConfig{
|
||||
container: settings.Container,
|
||||
},
|
||||
ApiUsingDuplicateChecker: ApiUsingDuplicateChecker{
|
||||
ApiUsingConfig: ApiUsingConfig{
|
||||
container: settings.Container,
|
||||
},
|
||||
container: duplicatechecker.Container,
|
||||
},
|
||||
users: services.Users,
|
||||
tokens: services.Tokens,
|
||||
forgetPasswords: services.ForgetPasswords,
|
||||
@@ -41,6 +49,13 @@ func (a *ForgetPasswordsApi) UserForgetPasswordRequestHandler(c *core.WebContext
|
||||
return nil, errs.ErrEmailIsEmptyOrInvalid
|
||||
}
|
||||
|
||||
err = a.CheckFailureCount(c, 0)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[forget_passwords.UserForgetPasswordRequestHandler] cannot send forget password mail to \"%s\", because %s", request.Email, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrFailureCountLimitReached)
|
||||
}
|
||||
|
||||
user, err := a.users.GetUserByEmail(c, request.Email)
|
||||
|
||||
if err != nil {
|
||||
@@ -48,6 +63,13 @@ func (a *ForgetPasswordsApi) UserForgetPasswordRequestHandler(c *core.WebContext
|
||||
log.Errorf(c, "[forget_passwords.UserForgetPasswordRequestHandler] failed to get user, because %s", err.Error())
|
||||
}
|
||||
|
||||
failureCheckErr := a.CheckAndIncreaseFailureCount(c, 0)
|
||||
|
||||
if failureCheckErr != nil {
|
||||
log.Warnf(c, "[forget_passwords.UserForgetPasswordRequestHandler] cannot send forget password mail to \"%s\", because %s", request.Email, failureCheckErr.Error())
|
||||
return nil, errs.Or(failureCheckErr, errs.ErrFailureCountLimitReached)
|
||||
}
|
||||
|
||||
return nil, errs.ErrUserNotFound
|
||||
}
|
||||
|
||||
@@ -124,7 +146,7 @@ func (a *ForgetPasswordsApi) UserResetPasswordHandler(c *core.WebContext) (any,
|
||||
|
||||
if user.Email != request.Email {
|
||||
log.Warnf(c, "[forget_passwords.UserResetPasswordHandler] request email not equals the user email")
|
||||
return nil, errs.ErrEmptyIsInvalid
|
||||
return nil, errs.ErrEmailIsInvalid
|
||||
}
|
||||
|
||||
if a.users.IsPasswordEqualsUserPassword(request.Password, user) {
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/llm"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/llm/data"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/services"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/templates"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||
)
|
||||
|
||||
// LargeLanguageModelsApi represents large language models api
|
||||
type LargeLanguageModelsApi struct {
|
||||
ApiUsingConfig
|
||||
transactionCategories *services.TransactionCategoryService
|
||||
transactionTags *services.TransactionTagService
|
||||
accounts *services.AccountService
|
||||
users *services.UserService
|
||||
}
|
||||
|
||||
// Initialize a large language models api singleton instance
|
||||
var (
|
||||
LargeLanguageModels = &LargeLanguageModelsApi{
|
||||
ApiUsingConfig: ApiUsingConfig{
|
||||
container: settings.Container,
|
||||
},
|
||||
transactionCategories: services.TransactionCategories,
|
||||
transactionTags: services.TransactionTags,
|
||||
accounts: services.Accounts,
|
||||
users: services.Users,
|
||||
}
|
||||
)
|
||||
|
||||
// RecognizeReceiptImageHandler returns the recognized receipt image result
|
||||
func (a *LargeLanguageModelsApi) RecognizeReceiptImageHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
if a.CurrentConfig().ReceiptImageRecognitionLLMConfig == nil || a.CurrentConfig().ReceiptImageRecognitionLLMConfig.LLMProvider == "" || !a.CurrentConfig().TransactionFromAIImageRecognition {
|
||||
return nil, errs.ErrLargeLanguageModelProviderNotEnabled
|
||||
}
|
||||
|
||||
utcOffset, err := c.GetClientTimezoneOffset()
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[large_language_models.RecognizeReceiptImageHandler] cannot get client timezone offset, because %s", err.Error())
|
||||
return nil, errs.ErrClientTimezoneOffsetInvalid
|
||||
}
|
||||
|
||||
timezone := time.FixedZone("Client Timezone", int(utcOffset)*60)
|
||||
uid := c.GetCurrentUid()
|
||||
user, err := a.users.GetUserById(c, uid)
|
||||
|
||||
if err != nil {
|
||||
if !errs.IsCustomError(err) {
|
||||
log.Warnf(c, "[large_language_models.RecognizeReceiptImageHandler] failed to get user for user \"uid:%d\", because %s", uid, err.Error())
|
||||
}
|
||||
|
||||
return false, errs.ErrUserNotFound
|
||||
}
|
||||
|
||||
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_CREATE_TRANSACTION_FROM_AI_IMAGE_RECOGNITION) {
|
||||
return false, errs.ErrNotPermittedToPerformThisAction
|
||||
}
|
||||
|
||||
form, err := c.MultipartForm()
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[large_language_models.RecognizeReceiptImageHandler] failed to get multi-part form data for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.ErrParameterInvalid
|
||||
}
|
||||
|
||||
imageFiles := form.File["image"]
|
||||
|
||||
if len(imageFiles) < 1 {
|
||||
log.Warnf(c, "[large_language_models.RecognizeReceiptImageHandler] there is no image in request for user \"uid:%d\"", uid)
|
||||
return nil, errs.ErrNoAIRecognitionImage
|
||||
}
|
||||
|
||||
if imageFiles[0].Size < 1 {
|
||||
log.Warnf(c, "[large_language_models.RecognizeReceiptImageHandler] the size of image in request is zero for user \"uid:%d\"", uid)
|
||||
return nil, errs.ErrAIRecognitionImageIsEmpty
|
||||
}
|
||||
|
||||
if imageFiles[0].Size > int64(a.CurrentConfig().MaxAIRecognitionPictureFileSize) {
|
||||
log.Warnf(c, "[large_language_models.RecognizeReceiptImageHandler] the upload file size \"%d\" exceeds the maximum size \"%d\" of image for user \"uid:%d\"", imageFiles[0].Size, a.CurrentConfig().MaxAIRecognitionPictureFileSize, uid)
|
||||
return nil, errs.ErrExceedMaxAIRecognitionImageFileSize
|
||||
}
|
||||
|
||||
fileExtension := utils.GetFileNameExtension(imageFiles[0].Filename)
|
||||
contentType := utils.GetImageContentType(fileExtension)
|
||||
|
||||
if contentType == "" {
|
||||
log.Warnf(c, "[large_language_models.RecognizeReceiptImageHandler] the file extension \"%s\" of image in request is not supported for user \"uid:%d\"", fileExtension, uid)
|
||||
return nil, errs.ErrImageTypeNotSupported
|
||||
}
|
||||
|
||||
imageFile, err := imageFiles[0].Open()
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[large_language_models.RecognizeReceiptImageHandler] failed to get image file from request for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.ErrOperationFailed
|
||||
}
|
||||
|
||||
defer imageFile.Close()
|
||||
|
||||
imageData, err := io.ReadAll(imageFile)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[large_language_models.RecognizeReceiptImageHandler] failed to read image file from request for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.ErrOperationFailed
|
||||
}
|
||||
|
||||
accounts, err := a.accounts.GetAllAccountsByUid(c, uid)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[large_language_models.RecognizeReceiptImageHandler] failed to get all accounts for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
accountMap := a.accounts.GetVisibleAccountNameMapByList(accounts)
|
||||
accountNames := make([]string, 0, len(accounts))
|
||||
|
||||
for i := 0; i < len(accounts); i++ {
|
||||
if accounts[i].Hidden || accounts[i].Type == models.ACCOUNT_TYPE_MULTI_SUB_ACCOUNTS {
|
||||
continue
|
||||
}
|
||||
|
||||
accountNames = append(accountNames, accounts[i].Name)
|
||||
}
|
||||
|
||||
categories, err := a.transactionCategories.GetAllCategoriesByUid(c, uid, 0, -1)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[large_language_models.RecognizeReceiptImageHandler] failed to get categories for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
incomeCategoryMap := make(map[string]*models.TransactionCategory)
|
||||
incomeCategoryNames := make([]string, 0)
|
||||
|
||||
expenseCategoryMap := make(map[string]*models.TransactionCategory)
|
||||
expenseCategoryNames := make([]string, 0)
|
||||
|
||||
transferCategoryMap := make(map[string]*models.TransactionCategory)
|
||||
transferCategoryNames := make([]string, 0)
|
||||
|
||||
for i := 0; i < len(categories); i++ {
|
||||
category := categories[i]
|
||||
|
||||
if category.Hidden || category.ParentCategoryId == models.LevelOneTransactionCategoryParentId {
|
||||
continue
|
||||
}
|
||||
|
||||
if category.Type == models.CATEGORY_TYPE_INCOME {
|
||||
incomeCategoryMap[category.Name] = category
|
||||
incomeCategoryNames = append(incomeCategoryNames, category.Name)
|
||||
} else if category.Type == models.CATEGORY_TYPE_EXPENSE {
|
||||
expenseCategoryMap[category.Name] = category
|
||||
expenseCategoryNames = append(expenseCategoryNames, category.Name)
|
||||
} else if category.Type == models.CATEGORY_TYPE_TRANSFER {
|
||||
transferCategoryMap[category.Name] = category
|
||||
transferCategoryNames = append(transferCategoryNames, category.Name)
|
||||
}
|
||||
}
|
||||
|
||||
tags, err := a.transactionTags.GetAllTagsByUid(c, uid)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[large_language_models.RecognizeReceiptImageHandler] failed to get tags for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
tagMap := a.transactionTags.GetVisibleTagNameMapByList(tags)
|
||||
tagNames := make([]string, 0, len(tags))
|
||||
|
||||
for i := 0; i < len(tags); i++ {
|
||||
if tags[i].Hidden {
|
||||
continue
|
||||
}
|
||||
|
||||
tagNames = append(tagNames, tags[i].Name)
|
||||
}
|
||||
|
||||
systemPrompt, err := templates.GetTemplate(templates.SYSTEM_PROMPT_RECEIPT_IMAGE_RECOGNITION)
|
||||
|
||||
if err != nil {
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
systemPromptParams := map[string]any{
|
||||
"CurrentDateTime": utils.FormatUnixTimeToLongDateTime(time.Now().Unix(), timezone),
|
||||
"AllExpenseCategoryNames": strings.Join(expenseCategoryNames, "\n"),
|
||||
"AllIncomeCategoryNames": strings.Join(incomeCategoryNames, "\n"),
|
||||
"AllTransferCategoryNames": strings.Join(transferCategoryNames, "\n"),
|
||||
"AllAccountNames": strings.Join(accountNames, "\n"),
|
||||
"AllTagNames": strings.Join(tagNames, "\n"),
|
||||
}
|
||||
|
||||
var bodyBuffer bytes.Buffer
|
||||
err = systemPrompt.Execute(&bodyBuffer, systemPromptParams)
|
||||
|
||||
if err != nil {
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
llmRequest := &data.LargeLanguageModelRequest{
|
||||
Stream: false,
|
||||
SystemPrompt: strings.ReplaceAll(bodyBuffer.String(), "\r\n", "\n"),
|
||||
UserPrompt: imageData,
|
||||
UserPromptType: data.LARGE_LANGUAGE_MODEL_REQUEST_PROMPT_TYPE_IMAGE_URL,
|
||||
UserPromptContentType: contentType,
|
||||
}
|
||||
|
||||
llmResponse, err := llm.Container.GetJsonResponseByReceiptImageRecognitionModel(c, c.GetCurrentUid(), a.CurrentConfig(), llmRequest)
|
||||
|
||||
if err != nil {
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
if llmResponse == nil || len(llmResponse.Content) == 0 || strings.HasPrefix(llmResponse.Content, "{}") {
|
||||
return nil, errs.ErrNoTransactionInformationInImage
|
||||
}
|
||||
|
||||
var result *models.RecognizedReceiptImageResult
|
||||
|
||||
if err := json.Unmarshal([]byte(llmResponse.Content), &result); err != nil {
|
||||
log.Errorf(c, "[large_language_models.RecognizeReceiptImageHandler] failed to unmarshal recognized receipt image result from llm response \"%s\" for user \"uid:%d\", because %s", llmResponse.Content, uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
return a.parseRecognizedReceiptImageResponse(c, uid, utcOffset, result, accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap)
|
||||
}
|
||||
|
||||
func (a *LargeLanguageModelsApi) parseRecognizedReceiptImageResponse(c *core.WebContext, uid int64, utcOffset int16, recognizedResult *models.RecognizedReceiptImageResult, accountMap map[string]*models.Account, expenseCategoryMap map[string]*models.TransactionCategory, incomeCategoryMap map[string]*models.TransactionCategory, transferCategoryMap map[string]*models.TransactionCategory, tagMap map[string]*models.TransactionTag) (*models.RecognizedReceiptImageResponse, *errs.Error) {
|
||||
recognizedReceiptImageResponse := &models.RecognizedReceiptImageResponse{
|
||||
Type: models.TRANSACTION_TYPE_EXPENSE,
|
||||
}
|
||||
|
||||
if recognizedResult == nil {
|
||||
log.Errorf(c, "[large_language_models.parseRecognizedReceiptImageResponse] recoginzed result is null")
|
||||
return nil, errs.ErrNoTransactionInformationInImage
|
||||
}
|
||||
|
||||
if recognizedResult.Type == "income" {
|
||||
recognizedReceiptImageResponse.Type = models.TRANSACTION_TYPE_INCOME
|
||||
|
||||
if len(recognizedResult.CategoryName) > 0 {
|
||||
category, exists := incomeCategoryMap[recognizedResult.CategoryName]
|
||||
|
||||
if exists {
|
||||
recognizedReceiptImageResponse.CategoryId = category.CategoryId
|
||||
}
|
||||
}
|
||||
} else if recognizedResult.Type == "expense" {
|
||||
recognizedReceiptImageResponse.Type = models.TRANSACTION_TYPE_EXPENSE
|
||||
|
||||
if len(recognizedResult.CategoryName) > 0 {
|
||||
category, exists := expenseCategoryMap[recognizedResult.CategoryName]
|
||||
|
||||
if exists {
|
||||
recognizedReceiptImageResponse.CategoryId = category.CategoryId
|
||||
}
|
||||
}
|
||||
} else if recognizedResult.Type == "transfer" {
|
||||
recognizedReceiptImageResponse.Type = models.TRANSACTION_TYPE_TRANSFER
|
||||
|
||||
if len(recognizedResult.CategoryName) > 0 {
|
||||
category, exists := transferCategoryMap[recognizedResult.CategoryName]
|
||||
|
||||
if exists {
|
||||
recognizedReceiptImageResponse.CategoryId = category.CategoryId
|
||||
}
|
||||
}
|
||||
} else if len(recognizedResult.Type) == 0 {
|
||||
return nil, errs.ErrNoTransactionInformationInImage
|
||||
} else {
|
||||
log.Errorf(c, "[large_language_models.parseRecognizedReceiptImageResponse] recoginzed transaction type \"%s\" is invalid", recognizedResult.Type)
|
||||
return nil, errs.ErrOperationFailed
|
||||
}
|
||||
|
||||
if len(recognizedResult.Time) > 0 {
|
||||
longDateTime := a.getLongDateTime(recognizedResult.Time)
|
||||
timestamp, err := utils.ParseFromLongDateTime(longDateTime, utcOffset)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[large_language_models.parseRecognizedReceiptImageResponse] recoginzed time \"%s\" is invalid", recognizedResult.Time)
|
||||
} else {
|
||||
recognizedReceiptImageResponse.Time = timestamp.Unix()
|
||||
}
|
||||
}
|
||||
|
||||
if len(recognizedResult.Amount) > 0 {
|
||||
amount, err := utils.ParseAmount(recognizedResult.Amount)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[large_language_models.parseRecognizedReceiptImageResponse] recoginzed amount \"%s\" is invalid", recognizedResult.Amount)
|
||||
return nil, errs.ErrOperationFailed
|
||||
}
|
||||
|
||||
recognizedReceiptImageResponse.SourceAmount = amount
|
||||
|
||||
if recognizedReceiptImageResponse.Type == models.TRANSACTION_TYPE_TRANSFER && len(recognizedResult.DestinationAmount) > 0 {
|
||||
destinationAmount, err := utils.ParseAmount(recognizedResult.DestinationAmount)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[large_language_models.parseRecognizedReceiptImageResponse] recoginzed destination amount \"%s\" is invalid", recognizedResult.DestinationAmount)
|
||||
return nil, errs.ErrOperationFailed
|
||||
}
|
||||
|
||||
recognizedReceiptImageResponse.DestinationAmount = destinationAmount
|
||||
}
|
||||
}
|
||||
|
||||
if len(recognizedResult.AccountName) > 0 {
|
||||
account, exists := accountMap[recognizedResult.AccountName]
|
||||
|
||||
if exists {
|
||||
recognizedReceiptImageResponse.SourceAccountId = account.AccountId
|
||||
}
|
||||
}
|
||||
|
||||
if len(recognizedResult.DestinationAccountName) > 0 {
|
||||
account, exists := accountMap[recognizedResult.DestinationAccountName]
|
||||
|
||||
if exists {
|
||||
recognizedReceiptImageResponse.DestinationAccountId = account.AccountId
|
||||
}
|
||||
}
|
||||
|
||||
if len(recognizedResult.TagNames) > 0 {
|
||||
tagIds := make([]string, 0, len(recognizedResult.TagNames))
|
||||
|
||||
for i := 0; i < len(recognizedResult.TagNames); i++ {
|
||||
tagName := recognizedResult.TagNames[i]
|
||||
tag, exists := tagMap[tagName]
|
||||
|
||||
if exists {
|
||||
tagIds = append(tagIds, utils.Int64ToString(tag.TagId))
|
||||
}
|
||||
}
|
||||
|
||||
recognizedReceiptImageResponse.TagIds = tagIds
|
||||
}
|
||||
|
||||
if len(recognizedResult.Description) > 0 {
|
||||
recognizedReceiptImageResponse.Comment = recognizedResult.Description
|
||||
}
|
||||
|
||||
return recognizedReceiptImageResponse, nil
|
||||
}
|
||||
|
||||
func (a *LargeLanguageModelsApi) getLongDateTime(dateTime string) string {
|
||||
if utils.IsValidLongDateTimeFormat(dateTime) {
|
||||
return dateTime
|
||||
}
|
||||
|
||||
if utils.IsValidLongDateTimeWithoutSecondFormat(dateTime) {
|
||||
return dateTime + ":00"
|
||||
}
|
||||
|
||||
if utils.IsValidLongDateFormat(dateTime) {
|
||||
return dateTime + " 00:00:00"
|
||||
}
|
||||
|
||||
return dateTime
|
||||
}
|
||||
@@ -3,8 +3,6 @@ package api
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||
@@ -233,7 +231,7 @@ func (a *ModelContextProtocolAPI) CallToolHandler(c *core.WebContext, jsonRPCReq
|
||||
|
||||
// PingHandler return the ping response for model context protocol
|
||||
func (a *ModelContextProtocolAPI) PingHandler(c *core.WebContext, jsonRPCRequest *core.JSONRPCRequest) (any, *errs.Error) {
|
||||
return gin.H{}, nil
|
||||
return core.O{}, nil
|
||||
}
|
||||
|
||||
// GetTransactionService implements the MCPAvailableServices interface
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/auth/oauth2"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/duplicatechecker"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/locales"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/services"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/validators"
|
||||
)
|
||||
|
||||
const oauth2CallbackPageUrlSuccessFormat = "%sdesktop/#/oauth2_callback?platform=%s&provider=%s&token=%s"
|
||||
const oauth2CallbackPageUrlNeedVerifyFormat = "%sdesktop/#/oauth2_callback?platform=%s&provider=%s&userName=%s&token=%s"
|
||||
const oauth2CallbackPageUrlFailedFormat = "%sdesktop/#/oauth2_callback?errorCode=%d&errorMessage=%s"
|
||||
const oauth2CallbackPageUrlErrorMessageFormat = "%sdesktop/#/oauth2_callback?errorMessage=%s"
|
||||
|
||||
// OAuth2AuthenticationApi represents OAuth 2.0 authorization api
|
||||
type OAuth2AuthenticationApi struct {
|
||||
ApiUsingConfig
|
||||
ApiUsingDuplicateChecker
|
||||
users *services.UserService
|
||||
tokens *services.TokenService
|
||||
userExternalAuths *services.UserExternalAuthService
|
||||
}
|
||||
|
||||
// Initialize a OAuth 2.0 authentication api singleton instance
|
||||
var (
|
||||
OAuth2Authentications = &OAuth2AuthenticationApi{
|
||||
ApiUsingConfig: ApiUsingConfig{
|
||||
container: settings.Container,
|
||||
},
|
||||
ApiUsingDuplicateChecker: ApiUsingDuplicateChecker{
|
||||
ApiUsingConfig: ApiUsingConfig{
|
||||
container: settings.Container,
|
||||
},
|
||||
container: duplicatechecker.Container,
|
||||
},
|
||||
users: services.Users,
|
||||
tokens: services.Tokens,
|
||||
userExternalAuths: services.UserExternalAuths,
|
||||
}
|
||||
)
|
||||
|
||||
// LoginHandler handles user login request via OAuth 2.0
|
||||
func (a *OAuth2AuthenticationApi) LoginHandler(c *core.WebContext) (string, *errs.Error) {
|
||||
var oauth2LoginReq models.OAuth2LoginRequest
|
||||
err := c.ShouldBindQuery(&oauth2LoginReq)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[oauth2_authentications.LoginHandler] parse request failed, because %s", err.Error())
|
||||
return a.redirectToFailedCallbackPage(c, errs.NewIncompleteOrIncorrectSubmissionError(err))
|
||||
}
|
||||
|
||||
if oauth2LoginReq.Platform != "mobile" && oauth2LoginReq.Platform != "desktop" {
|
||||
return a.redirectToFailedCallbackPage(c, errs.ErrInvalidOAuth2LoginRequest)
|
||||
}
|
||||
|
||||
found, remark := a.GetSubmissionRemark(duplicatechecker.DUPLICATE_CHECKER_TYPE_OAUTH2_REDIRECT, 0, oauth2LoginReq.ClientSessionId)
|
||||
|
||||
if found {
|
||||
log.Errorf(c, "[oauth2_authentications.LoginHandler] another oauth 2.0 state \"%s\" has been processing for client session id \"%s\"", remark, oauth2LoginReq.ClientSessionId)
|
||||
return a.redirectToFailedCallbackPage(c, errs.ErrRepeatedRequest)
|
||||
}
|
||||
|
||||
uid := int64(0)
|
||||
|
||||
if oauth2LoginReq.Token != "" {
|
||||
_, claims, _, err := a.tokens.ParseToken(c, oauth2LoginReq.Token)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[oauth2_authentications.LoginHandler] failed to parse token, because %s", err.Error())
|
||||
return a.redirectToFailedCallbackPage(c, errs.ErrInvalidToken)
|
||||
}
|
||||
|
||||
uid = claims.Uid
|
||||
user, err := a.users.GetUserById(c, uid)
|
||||
|
||||
if err != nil && !errors.Is(err, errs.ErrUserNotFound) {
|
||||
log.Errorf(c, "[oauth2_authentications.LoginHandler] failed to get user by id %d, because %s", uid, err.Error())
|
||||
return a.redirectToFailedCallbackPage(c, errs.Or(err, errs.ErrOperationFailed))
|
||||
}
|
||||
|
||||
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_OAUTH2_LOGIN) {
|
||||
return a.redirectToFailedCallbackPage(c, errs.ErrNotPermittedToPerformThisAction)
|
||||
}
|
||||
}
|
||||
|
||||
verifier, err := utils.GetRandomNumberOrLowercaseLetter(64)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[oauth2_authentications.LoginHandler] failed to generate random string for oauth 2.0 state, because %s", err.Error())
|
||||
return a.redirectToFailedCallbackPage(c, errs.ErrSystemError)
|
||||
}
|
||||
|
||||
remark = fmt.Sprintf("%s|%s|%d|%s", oauth2LoginReq.Platform, oauth2LoginReq.ClientSessionId, uid, verifier)
|
||||
state := fmt.Sprintf("%s|%s|%s", oauth2LoginReq.Platform, oauth2LoginReq.ClientSessionId, utils.MD5EncodeToString([]byte(remark)))
|
||||
|
||||
redirectUrl, err := oauth2.GetOAuth2AuthUrl(c, state, verifier)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[oauth2_authentications.LoginHandler] failed to get oauth 2.0 auth url, because %s", err.Error())
|
||||
return a.redirectToFailedCallbackPage(c, errs.Or(err, errs.ErrSystemError))
|
||||
}
|
||||
|
||||
a.SetSubmissionRemarkWithCustomExpiration(duplicatechecker.DUPLICATE_CHECKER_TYPE_OAUTH2_REDIRECT, 0, oauth2LoginReq.ClientSessionId, remark, a.CurrentConfig().OAuth2StateExpiredTimeDuration)
|
||||
|
||||
return redirectUrl, nil
|
||||
}
|
||||
|
||||
// CallbackHandler handles OAuth 2.0 callback request
|
||||
func (a *OAuth2AuthenticationApi) CallbackHandler(c *core.WebContext) (string, *errs.Error) {
|
||||
var oauth2CallbackReq models.OAuth2CallbackRequest
|
||||
err := c.ShouldBindQuery(&oauth2CallbackReq)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[oauth2_authentications.CallbackHandler] parse request failed, because %s", err.Error())
|
||||
return a.redirectToFailedCallbackPage(c, errs.NewIncompleteOrIncorrectSubmissionError(err))
|
||||
}
|
||||
|
||||
if oauth2CallbackReq.State == "" {
|
||||
return a.redirectToFailedCallbackPage(c, errs.ErrMissingOAuth2State)
|
||||
}
|
||||
|
||||
if oauth2CallbackReq.Code == "" {
|
||||
if oauth2CallbackReq.ErrorDescription != "" {
|
||||
log.Errorf(c, "[oauth2_authentications.CallbackHandler] oauth 2.0 provider returned error: %s, description: %s", oauth2CallbackReq.Error, oauth2CallbackReq.ErrorDescription)
|
||||
return a.redirectToErrorMessageCallbackPage(c, oauth2CallbackReq.ErrorDescription)
|
||||
}
|
||||
|
||||
return a.redirectToFailedCallbackPage(c, errs.ErrMissingOAuth2Code)
|
||||
}
|
||||
|
||||
platform := ""
|
||||
clientSessionId := ""
|
||||
|
||||
stateParts := strings.Split(oauth2CallbackReq.State, "|")
|
||||
|
||||
if len(stateParts) == 3 {
|
||||
platform = stateParts[0]
|
||||
clientSessionId = stateParts[1]
|
||||
} else {
|
||||
return a.redirectToFailedCallbackPage(c, errs.ErrInvalidOAuth2State)
|
||||
}
|
||||
|
||||
if platform != "mobile" && platform != "desktop" {
|
||||
return a.redirectToFailedCallbackPage(c, errs.ErrInvalidOAuth2LoginRequest)
|
||||
}
|
||||
|
||||
found, remark := a.GetSubmissionRemark(duplicatechecker.DUPLICATE_CHECKER_TYPE_OAUTH2_REDIRECT, 0, clientSessionId)
|
||||
|
||||
if !found {
|
||||
log.Errorf(c, "[oauth2_authentications.CallbackHandler] cannot find oauth 2.0 state in duplicate checker for client session id \"%s\"", clientSessionId)
|
||||
return a.redirectToFailedCallbackPage(c, errs.ErrInvalidOAuth2Callback)
|
||||
}
|
||||
|
||||
remarkParts := strings.Split(remark, "|")
|
||||
|
||||
if len(remarkParts) != 4 || remarkParts[0] != platform || remarkParts[1] != clientSessionId {
|
||||
log.Errorf(c, "[oauth2_authentications.CallbackHandler] invalid oauth 2.0 state \"%s\" in duplicate checker for client session id \"%s\"", remark, clientSessionId)
|
||||
return a.redirectToFailedCallbackPage(c, errs.ErrInvalidOAuth2State)
|
||||
}
|
||||
|
||||
uid, err := utils.StringToInt64(remarkParts[2])
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[oauth2_authentications.CallbackHandler] invalid uid \"%s\" in oauth 2.0 state \"%s\"", remarkParts[2], remark)
|
||||
return a.redirectToFailedCallbackPage(c, errs.ErrInvalidOAuth2State)
|
||||
}
|
||||
|
||||
verifier := remarkParts[3]
|
||||
expectedRemark := fmt.Sprintf("%s|%s|%d|%s", platform, clientSessionId, uid, verifier)
|
||||
expectedState := fmt.Sprintf("%s|%s|%s", platform, clientSessionId, utils.MD5EncodeToString([]byte(expectedRemark)))
|
||||
|
||||
if oauth2CallbackReq.State != expectedState {
|
||||
log.Errorf(c, "[oauth2_authentications.CallbackHandler] mismatched random string in oauth 2.0 state, expected \"%s\", got \"%s\"", expectedState, oauth2CallbackReq.State)
|
||||
return a.redirectToFailedCallbackPage(c, errs.ErrInvalidOAuth2State)
|
||||
}
|
||||
|
||||
a.RemoveSubmissionRemark(duplicatechecker.DUPLICATE_CHECKER_TYPE_OAUTH2_REDIRECT, 0, clientSessionId)
|
||||
|
||||
oauth2Token, err := oauth2.GetOAuth2Token(c, oauth2CallbackReq.Code, verifier)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[oauth2_authentications.CallbackHandler] failed to retrieve oauth 2.0 token, because %s", err.Error())
|
||||
return a.redirectToFailedCallbackPage(c, errs.Or(err, errs.ErrCannotRetrieveOAuth2Token))
|
||||
}
|
||||
|
||||
oauth2UserInfo, err := oauth2.GetOAuth2UserInfo(c, oauth2Token)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[oauth2_authentications.CallbackHandler] failed to retrieve oauth 2.0 user info, because %s", err.Error())
|
||||
return a.redirectToFailedCallbackPage(c, errs.Or(err, errs.ErrInvalidOAuth2Token))
|
||||
}
|
||||
|
||||
if oauth2UserInfo == nil {
|
||||
log.Errorf(c, "[oauth2_authentications.CallbackHandler] failed to retrieve oauth 2.0 user info, because user info is nil")
|
||||
return a.redirectToFailedCallbackPage(c, errs.ErrCannotRetrieveUserInfo)
|
||||
}
|
||||
|
||||
if oauth2UserInfo.UserName == "" || oauth2UserInfo.Email == "" {
|
||||
log.Errorf(c, "[oauth2_authentications.CallbackHandler] invalid oauth 2.0 user info, userName: %s, email: %s", oauth2UserInfo.UserName, oauth2UserInfo.Email)
|
||||
return a.redirectToFailedCallbackPage(c, errs.ErrCannotRetrieveUserInfo)
|
||||
}
|
||||
|
||||
userExternalAuthType := oauth2.GetExternalUserAuthType()
|
||||
var userExternalAuth *models.UserExternalAuth
|
||||
|
||||
if a.CurrentConfig().OAuth2UserIdentifier == settings.OAuth2UserIdentifierEmail {
|
||||
userExternalAuth, err = a.userExternalAuths.GetUserExternalAuthByExternalEmail(c, oauth2UserInfo.Email, userExternalAuthType)
|
||||
} else if a.CurrentConfig().OAuth2UserIdentifier == settings.OAuth2UserIdentifierUsername {
|
||||
userExternalAuth, err = a.userExternalAuths.GetUserExternalAuthByExternalUserName(c, oauth2UserInfo.UserName, userExternalAuthType)
|
||||
} else {
|
||||
userExternalAuth, err = a.userExternalAuths.GetUserExternalAuthByExternalEmail(c, oauth2UserInfo.Email, userExternalAuthType)
|
||||
}
|
||||
|
||||
if err != nil && !errors.Is(err, errs.ErrUserExternalAuthNotFound) {
|
||||
log.Errorf(c, "[oauth2_authentications.CallbackHandler] failed to get user external auth, because %s", err.Error())
|
||||
return a.redirectToFailedCallbackPage(c, errs.Or(err, errs.ErrOperationFailed))
|
||||
}
|
||||
|
||||
if uid != 0 && userExternalAuth != nil && userExternalAuth.Uid != uid {
|
||||
log.Errorf(c, "[oauth2_authentications.CallbackHandler] oauth 2.0 external auth has been bound to another user \"uid:%d\", current user \"uid:%d\"", userExternalAuth.Uid, uid)
|
||||
return a.redirectToFailedCallbackPage(c, errs.ErrOAuth2UserAlreadyBoundToAnotherUser)
|
||||
}
|
||||
|
||||
var user *models.User
|
||||
|
||||
if err == nil { // user already bound to external auth, redirect to success page
|
||||
user, err = a.users.GetUserById(c, userExternalAuth.Uid)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[oauth2_authentications.CallbackHandler] failed to get user by id %d, because %s", userExternalAuth.Uid, err.Error())
|
||||
return a.redirectToFailedCallbackPage(c, errs.Or(err, errs.ErrOperationFailed))
|
||||
}
|
||||
} else { // errors.Is(err, errs.ErrUserExternalAuthNotFound) // user not bound to external auth, try to bind or register new user
|
||||
if uid != 0 {
|
||||
user, err = a.users.GetUserById(c, uid)
|
||||
|
||||
if err != nil && !errors.Is(err, errs.ErrUserNotFound) {
|
||||
log.Errorf(c, "[oauth2_authentications.CallbackHandler] failed to get user by id %d, because %s", uid, err.Error())
|
||||
return a.redirectToFailedCallbackPage(c, errs.Or(err, errs.ErrOperationFailed))
|
||||
}
|
||||
} else {
|
||||
if a.CurrentConfig().OAuth2UserIdentifier == settings.OAuth2UserIdentifierEmail {
|
||||
user, err = a.users.GetUserByEmail(c, oauth2UserInfo.Email)
|
||||
} else if a.CurrentConfig().OAuth2UserIdentifier == settings.OAuth2UserIdentifierUsername {
|
||||
user, err = a.users.GetUserByUsername(c, oauth2UserInfo.UserName)
|
||||
} else {
|
||||
user, err = a.users.GetUserByEmail(c, oauth2UserInfo.Email)
|
||||
}
|
||||
|
||||
if err != nil && !errors.Is(err, errs.ErrUserNotFound) {
|
||||
log.Errorf(c, "[oauth2_authentications.CallbackHandler] failed to get user, because %s", err.Error())
|
||||
return a.redirectToFailedCallbackPage(c, errs.Or(err, errs.ErrOperationFailed))
|
||||
}
|
||||
}
|
||||
|
||||
if user == nil && a.CurrentConfig().EnableUserRegister && a.CurrentConfig().OAuth2AutoRegister {
|
||||
userName := strings.TrimSpace(oauth2UserInfo.UserName)
|
||||
email := strings.TrimSpace(oauth2UserInfo.Email)
|
||||
nickName := strings.TrimSpace(oauth2UserInfo.NickName)
|
||||
languageCode := ""
|
||||
currencyCode := "USD"
|
||||
|
||||
if nickName == "" {
|
||||
nickName = userName
|
||||
}
|
||||
|
||||
if !utils.IsValidUsername(userName) {
|
||||
return a.redirectToFailedCallbackPage(c, errs.ErrUserNameIsInvalid)
|
||||
}
|
||||
|
||||
if !utils.IsValidEmail(email) {
|
||||
return a.redirectToFailedCallbackPage(c, errs.ErrEmailIsInvalid)
|
||||
}
|
||||
|
||||
if !utils.IsValidNickName(nickName) {
|
||||
return a.redirectToFailedCallbackPage(c, errs.ErrNickNameIsInvalid)
|
||||
}
|
||||
|
||||
if _, exists := locales.AllLanguages[oauth2UserInfo.LanguageCode]; exists {
|
||||
languageCode = oauth2UserInfo.LanguageCode
|
||||
}
|
||||
|
||||
if _, exists := validators.AllCurrencyNames[oauth2UserInfo.CurrencyCode]; exists {
|
||||
currencyCode = oauth2UserInfo.CurrencyCode
|
||||
}
|
||||
|
||||
user = &models.User{
|
||||
Username: userName,
|
||||
Email: email,
|
||||
Nickname: nickName,
|
||||
Language: languageCode,
|
||||
DefaultCurrency: currencyCode,
|
||||
FirstDayOfWeek: oauth2UserInfo.FirstDayOfWeek,
|
||||
FiscalYearStart: core.FISCAL_YEAR_START_DEFAULT,
|
||||
TransactionEditScope: models.TRANSACTION_EDIT_SCOPE_ALL,
|
||||
FeatureRestriction: a.CurrentConfig().DefaultFeatureRestrictions,
|
||||
}
|
||||
|
||||
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_OAUTH2_LOGIN) {
|
||||
return a.redirectToFailedCallbackPage(c, errs.ErrNotPermittedToPerformThisAction)
|
||||
}
|
||||
|
||||
err = a.users.CreateUser(c, user, true)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[oauth2_authentications.CallbackHandler] failed to create user \"%s\", because %s", user.Username, err.Error())
|
||||
return a.redirectToFailedCallbackPage(c, errs.Or(err, errs.ErrOperationFailed))
|
||||
}
|
||||
|
||||
log.Infof(c, "[oauth2_authentications.CallbackHandler] user \"%s\" has registered successfully, uid is %d", user.Username, user.Uid)
|
||||
|
||||
userExternalAuth = &models.UserExternalAuth{
|
||||
Uid: user.Uid,
|
||||
ExternalAuthType: userExternalAuthType,
|
||||
ExternalUsername: oauth2UserInfo.UserName,
|
||||
ExternalEmail: oauth2UserInfo.Email,
|
||||
}
|
||||
|
||||
err = a.userExternalAuths.CreateUserExternalAuth(c, userExternalAuth)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[oauth2_authentications.CallbackHandler] failed to create user external auth for user \"uid:%d\", because %s", user.Uid, err.Error())
|
||||
return a.redirectToFailedCallbackPage(c, errs.Or(err, errs.ErrOperationFailed))
|
||||
}
|
||||
|
||||
log.Infof(c, "[oauth2_authentications.CallbackHandler] user external auth has been created for user \"uid:%d\"", user.Uid)
|
||||
} else if user == nil {
|
||||
return a.redirectToFailedCallbackPage(c, errs.ErrOAuth2AutoRegistrationNotEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_OAUTH2_LOGIN) {
|
||||
return a.redirectToFailedCallbackPage(c, errs.ErrNotPermittedToPerformThisAction)
|
||||
}
|
||||
|
||||
if userExternalAuth == nil {
|
||||
tokenContext, err := json.Marshal(&models.OAuth2CallbackTokenContext{
|
||||
ExternalAuthType: userExternalAuthType,
|
||||
ExternalUsername: oauth2UserInfo.UserName,
|
||||
ExternalEmail: oauth2UserInfo.Email,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[oauth2_authentications.CallbackHandler] failed to marshal oauth 2.0 callback verify token context, because %s", err.Error())
|
||||
return a.redirectToFailedCallbackPage(c, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
token, _, err := a.tokens.CreateOAuth2CallbackRequireVerifyToken(c, user, string(tokenContext))
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[oauth2_authentications.CallbackHandler] failed to create oauth 2.0 callback verify token, because %s", err.Error())
|
||||
return a.redirectToFailedCallbackPage(c, errs.ErrTokenGenerating)
|
||||
}
|
||||
|
||||
return a.redirectToVerifyCallbackPage(c, platform, userExternalAuthType, user.Username, token)
|
||||
} else {
|
||||
tokenContext, err := json.Marshal(&models.OAuth2CallbackTokenContext{
|
||||
ExternalAuthType: userExternalAuthType,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[oauth2_authentications.CallbackHandler] failed to marshal oauth 2.0 callback token context, because %s", err.Error())
|
||||
return a.redirectToFailedCallbackPage(c, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
token, _, err := a.tokens.CreateOAuth2CallbackToken(c, user, string(tokenContext))
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[oauth2_authentications.CallbackHandler] failed to create oauth 2.0 callback token, because %s", err.Error())
|
||||
return a.redirectToFailedCallbackPage(c, errs.ErrTokenGenerating)
|
||||
}
|
||||
|
||||
return a.redirectToSuccessCallbackPage(c, platform, userExternalAuthType, token)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *OAuth2AuthenticationApi) redirectToSuccessCallbackPage(c *core.WebContext, platform string, externalAuthType core.UserExternalAuthType, token string) (string, *errs.Error) {
|
||||
return fmt.Sprintf(oauth2CallbackPageUrlSuccessFormat, a.CurrentConfig().RootUrl, platform, externalAuthType, url.QueryEscape(token)), nil
|
||||
}
|
||||
|
||||
func (a *OAuth2AuthenticationApi) redirectToVerifyCallbackPage(c *core.WebContext, platform string, externalAuthType core.UserExternalAuthType, userName string, token string) (string, *errs.Error) {
|
||||
return fmt.Sprintf(oauth2CallbackPageUrlNeedVerifyFormat, a.CurrentConfig().RootUrl, platform, externalAuthType, userName, url.QueryEscape(token)), nil
|
||||
}
|
||||
|
||||
func (a *OAuth2AuthenticationApi) redirectToFailedCallbackPage(c *core.WebContext, err *errs.Error) (string, *errs.Error) {
|
||||
return fmt.Sprintf(oauth2CallbackPageUrlFailedFormat, a.CurrentConfig().RootUrl, err.Code(), url.QueryEscape(utils.GetDisplayErrorMessage(err))), nil
|
||||
}
|
||||
|
||||
func (a *OAuth2AuthenticationApi) redirectToErrorMessageCallbackPage(c *core.WebContext, message string) (string, *errs.Error) {
|
||||
return fmt.Sprintf(oauth2CallbackPageUrlErrorMessageFormat, a.CurrentConfig().RootUrl, url.QueryEscape(message)), nil
|
||||
}
|
||||
@@ -35,18 +35,33 @@ func (a *ServerSettingsApi) ServerSettingsJavascriptHandler(c *core.WebContext)
|
||||
builder := &strings.Builder{}
|
||||
builder.WriteString(ezbookkeepingServerSettingsJavascriptFileHeader)
|
||||
|
||||
a.appendBooleanSetting(builder, "r", config.EnableUserRegister)
|
||||
a.appendBooleanSetting(builder, "f", config.EnableUserForgetPassword)
|
||||
a.appendBooleanSetting(builder, "a", config.EnableInternalAuth)
|
||||
a.appendBooleanSetting(builder, "o", config.EnableOAuth2Login)
|
||||
a.appendBooleanSetting(builder, "r", config.EnableInternalAuth && config.EnableUserRegister)
|
||||
a.appendBooleanSetting(builder, "f", config.EnableInternalAuth && config.EnableUserForgetPassword)
|
||||
a.appendBooleanSetting(builder, "t", config.EnableAPIToken)
|
||||
a.appendBooleanSetting(builder, "v", config.EnableUserVerifyEmail)
|
||||
a.appendBooleanSetting(builder, "p", config.EnableTransactionPictures)
|
||||
a.appendBooleanSetting(builder, "s", config.EnableScheduledTransaction)
|
||||
a.appendBooleanSetting(builder, "e", config.EnableDataExport)
|
||||
a.appendBooleanSetting(builder, "i", config.EnableDataImport)
|
||||
|
||||
a.appendStringSetting(builder, "op", config.OAuth2Provider)
|
||||
|
||||
if config.OAuth2Provider == settings.OAuth2ProviderOIDC && config.OAuth2OIDCCustomDisplayNameConfig.Enabled {
|
||||
a.appendMultiLanguageTipSetting(builder, "ocn", config.OAuth2OIDCCustomDisplayNameConfig)
|
||||
}
|
||||
|
||||
if config.EnableMCPServer {
|
||||
a.appendBooleanSetting(builder, "mcp", config.EnableMCPServer)
|
||||
}
|
||||
|
||||
if config.ReceiptImageRecognitionLLMConfig != nil && config.ReceiptImageRecognitionLLMConfig.LLMProvider != "" {
|
||||
if config.TransactionFromAIImageRecognition {
|
||||
a.appendBooleanSetting(builder, "llmt", config.TransactionFromAIImageRecognition)
|
||||
}
|
||||
}
|
||||
|
||||
if config.LoginPageTips.Enabled {
|
||||
a.appendMultiLanguageTipSetting(builder, "lpt", config.LoginPageTips)
|
||||
}
|
||||
@@ -132,7 +147,7 @@ func (a *ServerSettingsApi) appendStringSetting(builder *strings.Builder, key st
|
||||
builder.WriteString(";\n")
|
||||
}
|
||||
|
||||
func (a *ServerSettingsApi) appendMultiLanguageTipSetting(builder *strings.Builder, key string, value settings.TipConfig) {
|
||||
func (a *ServerSettingsApi) appendMultiLanguageTipSetting(builder *strings.Builder, key string, value settings.MultiLanguageContentConfig) {
|
||||
builder.WriteString(ezbookkeepingServerSettingsGlobalVariableFullName)
|
||||
builder.WriteString("[")
|
||||
a.appendEncodedString(builder, key)
|
||||
|
||||
+59
-3
@@ -69,7 +69,9 @@ func (a *TokensApi) TokenListHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
tokenResp.IsCurrent = true
|
||||
}
|
||||
|
||||
if token.TokenType == core.USER_TOKEN_TYPE_MCP && token.UserAgent != services.TokenUserAgentCreatedViaCli {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -81,6 +83,53 @@ func (a *TokensApi) TokenListHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
return tokenResps, nil
|
||||
}
|
||||
|
||||
// TokenGenerateAPIHandler generates a new API token for current user
|
||||
func (a *TokensApi) TokenGenerateAPIHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
if !a.CurrentConfig().EnableAPIToken {
|
||||
return nil, errs.ErrAPITokenNotEnabled
|
||||
}
|
||||
|
||||
var generateAPITokenReq models.TokenGenerateAPIRequest
|
||||
err := c.ShouldBindJSON(&generateAPITokenReq)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[tokens.TokenGenerateAPIHandler] parse request failed, because %s", err.Error())
|
||||
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
user, err := a.users.GetUserById(c, uid)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[tokens.TokenGenerateAPIHandler] failed to get user \"uid:%d\" info, because %s", uid, err.Error())
|
||||
return nil, errs.ErrUserNotFound
|
||||
}
|
||||
|
||||
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_GENERATE_API_TOKEN) {
|
||||
return false, errs.ErrNotPermittedToPerformThisAction
|
||||
}
|
||||
|
||||
if !a.users.IsPasswordEqualsUserPassword(generateAPITokenReq.Password, user) {
|
||||
return nil, errs.ErrUserPasswordWrong
|
||||
}
|
||||
|
||||
token, claims, err := a.tokens.CreateAPIToken(c, user, generateAPITokenReq.ExpiredInSeconds)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[tokens.TokenGenerateAPIHandler] failed to create api token for user \"uid:%d\", because %s", user.Uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrTokenGenerating)
|
||||
}
|
||||
|
||||
log.Infof(c, "[tokens.TokenGenerateAPIHandler] user \"uid:%d\" has generated api token, new token will be expired at %d", user.Uid, claims.ExpiresAt)
|
||||
|
||||
generateAPITokenResp := &models.TokenGenerateAPIResponse{
|
||||
Token: token,
|
||||
APIBaseUrl: a.CurrentConfig().RootUrl + "api",
|
||||
}
|
||||
|
||||
return generateAPITokenResp, nil
|
||||
}
|
||||
|
||||
// TokenGenerateMCPHandler generates a new MCP token for current user
|
||||
func (a *TokensApi) TokenGenerateMCPHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
if !a.CurrentConfig().EnableMCPServer {
|
||||
@@ -111,7 +160,7 @@ func (a *TokensApi) TokenGenerateMCPHandler(c *core.WebContext) (any, *errs.Erro
|
||||
return nil, errs.ErrUserPasswordWrong
|
||||
}
|
||||
|
||||
token, claims, err := a.tokens.CreateMCPToken(c, user)
|
||||
token, claims, err := a.tokens.CreateMCPToken(c, user, generateMCPTokenReq.ExpiredInSeconds)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[tokens.TokenGenerateMCPHandler] failed to create mcp token for user \"uid:%d\", because %s", user.Uid, err.Error())
|
||||
@@ -130,7 +179,13 @@ func (a *TokensApi) TokenGenerateMCPHandler(c *core.WebContext) (any, *errs.Erro
|
||||
|
||||
// TokenRevokeCurrentHandler revokes current token of current user
|
||||
func (a *TokensApi) TokenRevokeCurrentHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
_, claims, err := a.tokens.ParseTokenByHeader(c)
|
||||
tokenString := c.GetTokenStringFromHeader()
|
||||
|
||||
if tokenString == "" {
|
||||
return false, errs.ErrTokenIsEmpty
|
||||
}
|
||||
|
||||
_, claims, _, err := a.tokens.ParseToken(c, tokenString)
|
||||
|
||||
if err != nil {
|
||||
return nil, errs.Or(err, errs.NewIncompleteOrIncorrectSubmissionError(err))
|
||||
@@ -338,6 +393,7 @@ func (a *TokensApi) TokenRefreshHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
|
||||
c.SetTextualToken(token)
|
||||
c.SetTokenClaims(claims)
|
||||
c.SetTokenContext("")
|
||||
|
||||
userApplicationCloudSettings, err := a.userAppCloudSettings.GetUserApplicationCloudSettingsByUid(c, user.Uid)
|
||||
var applicationCloudSettingSlice *models.ApplicationCloudSettingSlice = nil
|
||||
|
||||
+275
-6
@@ -22,6 +22,8 @@ import (
|
||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||
)
|
||||
|
||||
const pageCountForAccountStatement = 1000
|
||||
|
||||
// TransactionsApi represents transaction api
|
||||
type TransactionsApi struct {
|
||||
ApiUsingConfig
|
||||
@@ -286,6 +288,114 @@ func (a *TransactionsApi) TransactionMonthListHandler(c *core.WebContext) (any,
|
||||
return transactionResps, nil
|
||||
}
|
||||
|
||||
// TransactionReconciliationStatementHandler returns transaction reconciliation statement list of current user
|
||||
func (a *TransactionsApi) TransactionReconciliationStatementHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
var reconciliationStatementRequest models.TransactionReconciliationStatementRequest
|
||||
err := c.ShouldBindQuery(&reconciliationStatementRequest)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[transactions.TransactionReconciliationStatementHandler] parse request failed, because %s", err.Error())
|
||||
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||
}
|
||||
|
||||
utcOffset, err := c.GetClientTimezoneOffset()
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[transactions.TransactionReconciliationStatementHandler] cannot get client timezone offset, because %s", err.Error())
|
||||
return nil, errs.ErrClientTimezoneOffsetInvalid
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
user, err := a.users.GetUserById(c, uid)
|
||||
|
||||
if err != nil {
|
||||
if !errs.IsCustomError(err) {
|
||||
log.Errorf(c, "[transactions.TransactionReconciliationStatementHandler] failed to get user, because %s", err.Error())
|
||||
}
|
||||
|
||||
return nil, errs.ErrUserNotFound
|
||||
}
|
||||
|
||||
account, err := a.accounts.GetAccountByAccountId(c, uid, reconciliationStatementRequest.AccountId)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.TransactionReconciliationStatementHandler] failed to get account \"id:%d\" for user \"uid:%d\", because %s", reconciliationStatementRequest.AccountId, uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
if account.Type != models.ACCOUNT_TYPE_SINGLE_ACCOUNT {
|
||||
log.Errorf(c, "[transactions.TransactionReconciliationStatementHandler] account \"id:%d\" for user \"uid:%d\" is not a single account", reconciliationStatementRequest.AccountId, uid)
|
||||
return nil, errs.ErrAccountTypeInvalid
|
||||
}
|
||||
|
||||
maxTransactionTime := int64(0)
|
||||
|
||||
if reconciliationStatementRequest.EndTime > 0 {
|
||||
maxTransactionTime = utils.GetMaxTransactionTimeFromUnixTime(reconciliationStatementRequest.EndTime)
|
||||
}
|
||||
|
||||
minTransactionTime := int64(0)
|
||||
|
||||
if reconciliationStatementRequest.StartTime > 0 {
|
||||
minTransactionTime = utils.GetMinTransactionTimeFromUnixTime(reconciliationStatementRequest.StartTime)
|
||||
}
|
||||
|
||||
transactionsWithAccountBalance, totalInflows, totalOutflows, openingBalance, closingBalance, err := a.transactions.GetAllTransactionsInOneAccountWithAccountBalanceByMaxTime(c, uid, pageCountForAccountStatement, maxTransactionTime, minTransactionTime, reconciliationStatementRequest.AccountId, account.Category)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.TransactionReconciliationStatementHandler] failed to get transactions from \"%d\" to \"%d\" for user \"uid:%d\", because %s", reconciliationStatementRequest.StartTime, reconciliationStatementRequest.EndTime, uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
transactions := make([]*models.Transaction, len(transactionsWithAccountBalance))
|
||||
transactionAccountBalanceMap := make(map[int64]*models.TransactionWithAccountBalance, len(transactionsWithAccountBalance))
|
||||
|
||||
for i := 0; i < len(transactionsWithAccountBalance); i++ {
|
||||
transactionWithBalance := transactionsWithAccountBalance[i]
|
||||
transactions[i] = transactionWithBalance.Transaction
|
||||
transactionAccountBalanceMap[transactionWithBalance.TransactionId] = transactionWithBalance
|
||||
transactionAccountBalanceMap[transactionWithBalance.RelatedId] = transactionWithBalance
|
||||
}
|
||||
|
||||
transactionResult, err := a.getTransactionResponseListResult(c, user, transactions, utcOffset, false, true, true, true)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.TransactionReconciliationStatementHandler] failed to assemble transaction result for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
responseItems := make([]*models.TransactionReconciliationStatementResponseItem, len(transactionResult))
|
||||
|
||||
for i := 0; i < len(transactionResult); i++ {
|
||||
transactionResult := transactionResult[i]
|
||||
accountOpeningBalance := int64(0)
|
||||
accountClosingBalance := int64(0)
|
||||
|
||||
if transactionWithBalance, exists := transactionAccountBalanceMap[transactionResult.Id]; exists {
|
||||
accountOpeningBalance = transactionWithBalance.AccountOpeningBalance
|
||||
accountClosingBalance = transactionWithBalance.AccountClosingBalance
|
||||
} else {
|
||||
log.Warnf(c, "[transactions.TransactionReconciliationStatementHandler] missing account balance for transaction \"id:%d\" of user \"uid:%d\"", transactionResult.Id, uid)
|
||||
}
|
||||
|
||||
responseItems[i] = &models.TransactionReconciliationStatementResponseItem{
|
||||
TransactionInfoResponse: transactionResult,
|
||||
AccountOpeningBalance: accountOpeningBalance,
|
||||
AccountClosingBalance: accountClosingBalance,
|
||||
}
|
||||
}
|
||||
|
||||
reconciliationStatementResp := &models.TransactionReconciliationStatementResponse{
|
||||
Transactions: responseItems,
|
||||
TotalInflows: totalInflows,
|
||||
TotalOutflows: totalOutflows,
|
||||
OpeningBalance: openingBalance,
|
||||
ClosingBalance: closingBalance,
|
||||
}
|
||||
|
||||
return reconciliationStatementResp, nil
|
||||
}
|
||||
|
||||
// TransactionStatisticsHandler returns transaction statistics of current user
|
||||
func (a *TransactionsApi) TransactionStatisticsHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
var statisticReq models.TransactionStatisticRequest
|
||||
@@ -316,7 +426,7 @@ func (a *TransactionsApi) TransactionStatisticsHandler(c *core.WebContext) (any,
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
totalAmounts, err := a.transactions.GetAccountsAndCategoriesTotalIncomeAndExpense(c, uid, statisticReq.StartTime, statisticReq.EndTime, allTagIds, noTags, statisticReq.TagFilterType, statisticReq.Keyword, utcOffset, statisticReq.UseTransactionTimezone)
|
||||
totalAmounts, err := a.transactions.GetAccountsAndCategoriesTotalInflowAndOutflow(c, uid, statisticReq.StartTime, statisticReq.EndTime, allTagIds, noTags, statisticReq.TagFilterType, statisticReq.Keyword, utcOffset, statisticReq.UseTransactionTimezone)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.TransactionStatisticsHandler] failed to get accounts and categories total income and expense for user \"uid:%d\", because %s", uid, err.Error())
|
||||
@@ -337,6 +447,11 @@ func (a *TransactionsApi) TransactionStatisticsHandler(c *core.WebContext) (any,
|
||||
AccountId: totalAmountItem.AccountId,
|
||||
TotalAmount: totalAmountItem.Amount,
|
||||
}
|
||||
|
||||
if totalAmountItem.Type == models.TRANSACTION_DB_TYPE_TRANSFER_OUT || totalAmountItem.Type == models.TRANSACTION_DB_TYPE_TRANSFER_IN {
|
||||
statisticResp.Items[i].RelatedAccountId = totalAmountItem.RelatedAccountId
|
||||
statisticResp.Items[i].RelatedAccountType, _ = totalAmountItem.Type.ToTransactionRelatedAccountType()
|
||||
}
|
||||
}
|
||||
|
||||
return statisticResp, nil
|
||||
@@ -379,7 +494,7 @@ func (a *TransactionsApi) TransactionStatisticsTrendsHandler(c *core.WebContext)
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
allMonthlyTotalAmounts, err := a.transactions.GetAccountsAndCategoriesMonthlyIncomeAndExpense(c, uid, startYear, startMonth, endYear, endMonth, allTagIds, noTags, statisticTrendsReq.TagFilterType, statisticTrendsReq.Keyword, utcOffset, statisticTrendsReq.UseTransactionTimezone)
|
||||
allMonthlyTotalAmounts, err := a.transactions.GetAccountsAndCategoriesMonthlyInflowAndOutflow(c, uid, startYear, startMonth, endYear, endMonth, allTagIds, noTags, statisticTrendsReq.TagFilterType, statisticTrendsReq.Keyword, utcOffset, statisticTrendsReq.UseTransactionTimezone)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.TransactionStatisticsTrendsHandler] failed to get accounts and categories total income and expense for user \"uid:%d\", because %s", uid, err.Error())
|
||||
@@ -402,6 +517,11 @@ func (a *TransactionsApi) TransactionStatisticsTrendsHandler(c *core.WebContext)
|
||||
AccountId: totalAmountItem.AccountId,
|
||||
TotalAmount: totalAmountItem.Amount,
|
||||
}
|
||||
|
||||
if totalAmountItem.Type == models.TRANSACTION_DB_TYPE_TRANSFER_OUT || totalAmountItem.Type == models.TRANSACTION_DB_TYPE_TRANSFER_IN {
|
||||
monthlyStatisticResp.Items[i].RelatedAccountId = totalAmountItem.RelatedAccountId
|
||||
monthlyStatisticResp.Items[i].RelatedAccountType, _ = totalAmountItem.Type.ToTransactionRelatedAccountType()
|
||||
}
|
||||
}
|
||||
|
||||
statisticTrendsResp = append(statisticTrendsResp, monthlyStatisticResp)
|
||||
@@ -412,6 +532,71 @@ func (a *TransactionsApi) TransactionStatisticsTrendsHandler(c *core.WebContext)
|
||||
return statisticTrendsResp, nil
|
||||
}
|
||||
|
||||
// TransactionStatisticsAssetTrendsHandler returns transaction statistics asset trends of current user
|
||||
func (a *TransactionsApi) TransactionStatisticsAssetTrendsHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
var statisticAssetTrendsReq models.TransactionStatisticAssetTrendsRequest
|
||||
err := c.ShouldBindQuery(&statisticAssetTrendsReq)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[transactions.TransactionStatisticsAssetTrendsHandler] parse request failed, because %s", err.Error())
|
||||
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||
}
|
||||
|
||||
utcOffset, err := c.GetClientTimezoneOffset()
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[transactions.TransactionStatisticsAssetTrendsHandler] cannot get client timezone offset, because %s", err.Error())
|
||||
return nil, errs.ErrClientTimezoneOffsetInvalid
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
|
||||
maxTransactionTime := int64(0)
|
||||
|
||||
if statisticAssetTrendsReq.EndTime > 0 {
|
||||
maxTransactionTime = utils.GetMaxTransactionTimeFromUnixTime(statisticAssetTrendsReq.EndTime)
|
||||
}
|
||||
|
||||
minTransactionTime := int64(0)
|
||||
|
||||
if statisticAssetTrendsReq.StartTime > 0 {
|
||||
minTransactionTime = utils.GetMinTransactionTimeFromUnixTime(statisticAssetTrendsReq.StartTime)
|
||||
}
|
||||
|
||||
accountDailyBalances, err := a.transactions.GetAllAccountsDailyOpeningAndClosingBalance(c, uid, maxTransactionTime, minTransactionTime, utcOffset)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.TransactionStatisticsAssetTrendsHandler] failed to get transactions from \"%d\" to \"%d\" for user \"uid:%d\", because %s", statisticAssetTrendsReq.StartTime, statisticAssetTrendsReq.EndTime, uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
statisticAssetTrendsResp := make(models.TransactionStatisticAssetTrendsResponseItemSlice, 0)
|
||||
|
||||
for yearMonthDay, dailyAccountBalances := range accountDailyBalances {
|
||||
dailyStatisticResp := &models.TransactionStatisticAssetTrendsResponseItem{
|
||||
Year: yearMonthDay / 10000,
|
||||
Month: (yearMonthDay % 10000) / 100,
|
||||
Day: yearMonthDay % 100,
|
||||
Items: make([]*models.TransactionStatisticAssetTrendsResponseDataItem, len(dailyAccountBalances)),
|
||||
}
|
||||
|
||||
for i := 0; i < len(dailyAccountBalances); i++ {
|
||||
accountBalance := dailyAccountBalances[i]
|
||||
dailyStatisticResp.Items[i] = &models.TransactionStatisticAssetTrendsResponseDataItem{
|
||||
AccountId: accountBalance.AccountId,
|
||||
AccountOpeningBalance: accountBalance.AccountOpeningBalance,
|
||||
AccountClosingBalance: accountBalance.AccountClosingBalance,
|
||||
}
|
||||
}
|
||||
|
||||
statisticAssetTrendsResp = append(statisticAssetTrendsResp, dailyStatisticResp)
|
||||
}
|
||||
|
||||
sort.Sort(statisticAssetTrendsResp)
|
||||
|
||||
return statisticAssetTrendsResp, nil
|
||||
}
|
||||
|
||||
// TransactionAmountsHandler returns transaction amounts of current user
|
||||
func (a *TransactionsApi) TransactionAmountsHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
var transactionAmountsReq models.TransactionAmountsRequest
|
||||
@@ -439,6 +624,25 @@ func (a *TransactionsApi) TransactionAmountsHandler(c *core.WebContext) (any, *e
|
||||
return nil, errs.ErrQueryItemsTooMuch
|
||||
}
|
||||
|
||||
excludeAccountIds := make([]int64, 0)
|
||||
excludeCategoryIds := make([]int64, 0)
|
||||
|
||||
if transactionAmountsReq.ExcludeAccountIds != "" {
|
||||
excludeAccountIds, err = utils.StringArrayToInt64Array(strings.Split(transactionAmountsReq.ExcludeAccountIds, ","))
|
||||
|
||||
if err != nil {
|
||||
return nil, errs.ErrAccountIdInvalid
|
||||
}
|
||||
}
|
||||
|
||||
if transactionAmountsReq.ExcludeCategoryIds != "" {
|
||||
excludeCategoryIds, err = utils.StringArrayToInt64Array(strings.Split(transactionAmountsReq.ExcludeCategoryIds, ","))
|
||||
|
||||
if err != nil {
|
||||
return nil, errs.ErrTransactionCategoryIdInvalid
|
||||
}
|
||||
}
|
||||
|
||||
utcOffset, err := c.GetClientTimezoneOffset()
|
||||
|
||||
if err != nil {
|
||||
@@ -461,7 +665,7 @@ func (a *TransactionsApi) TransactionAmountsHandler(c *core.WebContext) (any, *e
|
||||
for i := 0; i < len(requestItems); i++ {
|
||||
requestItem := requestItems[i]
|
||||
|
||||
incomeAmounts, expenseAmounts, err := a.transactions.GetAccountsTotalIncomeAndExpense(c, uid, requestItem.StartTime, requestItem.EndTime, utcOffset, transactionAmountsReq.UseTransactionTimezone)
|
||||
incomeAmounts, expenseAmounts, err := a.transactions.GetAccountsTotalIncomeAndExpense(c, uid, requestItem.StartTime, requestItem.EndTime, excludeAccountIds, excludeCategoryIds, utcOffset, transactionAmountsReq.UseTransactionTimezone)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.TransactionAmountsHandler] failed to get transaction amounts item for user \"uid:%d\", because %s", uid, err.Error())
|
||||
@@ -699,7 +903,7 @@ func (a *TransactionsApi) TransactionCreateHandler(c *core.WebContext) (any, *er
|
||||
return nil, errs.ErrTransactionTypeInvalid
|
||||
}
|
||||
|
||||
if transactionCreateReq.Type == models.TRANSACTION_TYPE_MODIFY_BALANCE && transactionCreateReq.CategoryId > 0 {
|
||||
if transactionCreateReq.Type == models.TRANSACTION_TYPE_MODIFY_BALANCE && transactionCreateReq.CategoryId != 0 {
|
||||
log.Warnf(c, "[transactions.TransactionCreateHandler] balance modification transaction cannot set category id")
|
||||
return nil, errs.ErrBalanceModificationTransactionCannotSetCategory
|
||||
}
|
||||
@@ -847,6 +1051,14 @@ func (a *TransactionsApi) TransactionModifyHandler(c *core.WebContext) (any, *er
|
||||
return nil, errs.ErrTransactionTypeInvalid
|
||||
}
|
||||
|
||||
if transaction.Type == models.TRANSACTION_DB_TYPE_MODIFY_BALANCE && transactionModifyReq.CategoryId != 0 {
|
||||
log.Warnf(c, "[transactions.TransactionModifyHandler] balance modification transaction cannot set category id")
|
||||
return nil, errs.ErrBalanceModificationTransactionCannotSetCategory
|
||||
} else if transaction.Type != models.TRANSACTION_DB_TYPE_MODIFY_BALANCE && transactionModifyReq.CategoryId == 0 {
|
||||
log.Warnf(c, "[transactions.TransactionModifyHandler] non-balance modification transaction must set category id")
|
||||
return nil, errs.ErrIncompleteOrIncorrectSubmission
|
||||
}
|
||||
|
||||
allTransactionTagIds, err := a.transactionTags.GetAllTagIdsOfTransactions(c, uid, []int64{transaction.TransactionId})
|
||||
|
||||
if err != nil {
|
||||
@@ -977,6 +1189,63 @@ func (a *TransactionsApi) TransactionModifyHandler(c *core.WebContext) (any, *er
|
||||
return newTransactionResp, nil
|
||||
}
|
||||
|
||||
// TransactionMoveAllBetweenAccountsHandler moves all transactions from one account to another account for current user
|
||||
func (a *TransactionsApi) TransactionMoveAllBetweenAccountsHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
var transactionMoveReq models.TransactionMoveBetweenAccountsRequest
|
||||
err := c.ShouldBindJSON(&transactionMoveReq)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[transactions.TransactionMoveAllBetweenAccountsHandler] parse request failed, because %s", err.Error())
|
||||
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||
}
|
||||
|
||||
if transactionMoveReq.FromAccountId == transactionMoveReq.ToAccountId {
|
||||
return nil, errs.ErrCannotMoveTransactionToSameAccount
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
accountMap, err := a.accounts.GetAccountsByAccountIds(c, uid, []int64{transactionMoveReq.FromAccountId, transactionMoveReq.ToAccountId})
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.TransactionMoveAllBetweenAccountsHandler] failed to get accounts for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
fromAccount, exists := accountMap[transactionMoveReq.FromAccountId]
|
||||
|
||||
if !exists {
|
||||
return nil, errs.ErrSourceAccountNotFound
|
||||
}
|
||||
|
||||
toAccount, exists := accountMap[transactionMoveReq.ToAccountId]
|
||||
|
||||
if !exists {
|
||||
return nil, errs.ErrDestinationAccountNotFound
|
||||
}
|
||||
|
||||
if fromAccount.Hidden || toAccount.Hidden {
|
||||
return nil, errs.ErrCannotMoveTransactionFromOrToHiddenAccount
|
||||
}
|
||||
|
||||
if fromAccount.Type == models.ACCOUNT_TYPE_MULTI_SUB_ACCOUNTS || toAccount.Type == models.ACCOUNT_TYPE_MULTI_SUB_ACCOUNTS {
|
||||
return nil, errs.ErrCannotMoveTransactionFromOrToParentAccount
|
||||
}
|
||||
|
||||
if fromAccount.Currency != toAccount.Currency {
|
||||
return nil, errs.ErrCannotMoveTransactionBetweenAccountsWithDifferentCurrencies
|
||||
}
|
||||
|
||||
err = a.transactions.MoveAllTransactionsBetweenAccounts(c, uid, transactionMoveReq.FromAccountId, transactionMoveReq.ToAccountId)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.TransactionMoveAllBetweenAccountsHandler] failed to move all transactions from account \"id:%d\" to account \"id:%d\" for user \"uid:%d\", because %s", transactionMoveReq.FromAccountId, transactionMoveReq.ToAccountId, uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
log.Infof(c, "[transactions.TransactionMoveAllBetweenAccountsHandler] user \"uid:%d\" has moved all transactions from account \"id:%d\" to account \"id:%d\" successfully", uid, transactionMoveReq.FromAccountId, transactionMoveReq.ToAccountId)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// TransactionDeleteHandler deletes an existed transaction by request parameters for current user
|
||||
func (a *TransactionsApi) TransactionDeleteHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
var transactionDeleteReq models.TransactionDeleteRequest
|
||||
@@ -1310,7 +1579,7 @@ func (a *TransactionsApi) TransactionParseImportFileHandler(c *core.WebContext)
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
tagMap := a.transactionTags.GetTagNameMapByList(tags)
|
||||
tagMap := a.transactionTags.GetVisibleTagNameMapByList(tags)
|
||||
|
||||
parsedTransactions, _, _, _, _, _, err := dataImporter.ParseImportedData(c, user, fileData, utcOffset, accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap)
|
||||
|
||||
@@ -1388,7 +1657,7 @@ func (a *TransactionsApi) TransactionImportHandler(c *core.WebContext) (any, *er
|
||||
return nil, errs.ErrTransactionTypeInvalid
|
||||
}
|
||||
|
||||
if transactionCreateReq.Type == models.TRANSACTION_TYPE_MODIFY_BALANCE && transactionCreateReq.CategoryId > 0 {
|
||||
if transactionCreateReq.Type == models.TRANSACTION_TYPE_MODIFY_BALANCE && transactionCreateReq.CategoryId != 0 {
|
||||
log.Warnf(c, "[transactions.TransactionImportHandler] balance modification transaction \"index:%d\" cannot set category id", i)
|
||||
return nil, errs.ErrBalanceModificationTransactionCannotSetCategory
|
||||
}
|
||||
|
||||
@@ -205,6 +205,7 @@ func (a *TwoFactorAuthorizationsApi) TwoFactorEnableConfirmHandler(c *core.WebCo
|
||||
|
||||
c.SetTextualToken(token)
|
||||
c.SetTokenClaims(claims)
|
||||
c.SetTokenContext("")
|
||||
|
||||
log.Infof(c, "[twofactor_authorizations.TwoFactorEnableConfirmHandler] user \"uid:%d\" token refreshed, new token will be expired at %d", user.Uid, claims.ExpiresAt)
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
@@ -74,115 +75,129 @@ func (a *UserApplicationCloudSettingsApi) ApplicationSettingsUpdateHandler(c *co
|
||||
return false, errs.ErrNotPermittedToPerformThisAction
|
||||
}
|
||||
|
||||
userApplicationCloudSettings, err := a.userAppCloudSettings.GetUserApplicationCloudSettingsByUid(c, uid)
|
||||
var userApplicationCloudSettings *models.UserApplicationCloudSetting
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] failed to get latest user application cloud settings for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return false, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
// Retry up to 3 times
|
||||
for i := 0; i < 3; i++ {
|
||||
userApplicationCloudSettings, err = a.userAppCloudSettings.GetUserApplicationCloudSettingsByUid(c, uid)
|
||||
|
||||
oldApplicationCloudSettingsMap := make(map[string]models.ApplicationCloudSetting)
|
||||
|
||||
if userApplicationCloudSettings != nil {
|
||||
for _, setting := range userApplicationCloudSettings.Settings {
|
||||
oldApplicationCloudSettingsMap[setting.SettingKey] = setting
|
||||
if err != nil {
|
||||
log.Errorf(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] failed to get latest user application cloud settings for user \"uid:%d\" (try count %d), because %s", uid, i+1, err.Error())
|
||||
return false, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the full update settings are the same as the existing settings
|
||||
if userAppCloudSettingUpdateReq.FullUpdate {
|
||||
if len(userAppCloudSettingUpdateReq.Settings) == len(oldApplicationCloudSettingsMap) {
|
||||
needUpdate := false
|
||||
oldApplicationCloudSettingsMap := make(map[string]models.ApplicationCloudSetting)
|
||||
lastUpdateTime := int64(0)
|
||||
|
||||
if userApplicationCloudSettings != nil {
|
||||
for _, setting := range userApplicationCloudSettings.Settings {
|
||||
oldApplicationCloudSettingsMap[setting.SettingKey] = setting
|
||||
}
|
||||
|
||||
lastUpdateTime = userApplicationCloudSettings.UpdatedUnixTime
|
||||
}
|
||||
|
||||
// Check if the full update settings are the same as the existing settings
|
||||
if userAppCloudSettingUpdateReq.FullUpdate {
|
||||
if len(userAppCloudSettingUpdateReq.Settings) == len(oldApplicationCloudSettingsMap) {
|
||||
needUpdate := false
|
||||
|
||||
for _, setting := range userAppCloudSettingUpdateReq.Settings {
|
||||
oldSetting, exists := oldApplicationCloudSettingsMap[setting.SettingKey]
|
||||
|
||||
if !exists || oldSetting.SettingValue != setting.SettingValue {
|
||||
needUpdate = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !needUpdate {
|
||||
return false, errs.ErrNothingWillBeUpdated
|
||||
}
|
||||
}
|
||||
} else { // Check if the partial update settings are the same as the existing settings or the settings to update are not set to sync
|
||||
needUpdate := true
|
||||
|
||||
for _, setting := range userAppCloudSettingUpdateReq.Settings {
|
||||
oldSetting, exists := oldApplicationCloudSettingsMap[setting.SettingKey]
|
||||
cloudSetting, exists := oldApplicationCloudSettingsMap[setting.SettingKey]
|
||||
|
||||
if !exists || oldSetting.SettingValue != setting.SettingValue {
|
||||
needUpdate = true
|
||||
break
|
||||
if !exists {
|
||||
needUpdate = false
|
||||
log.Infof(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user application cloud setting key \"%s\" is not set to sync (try count %d)", setting.SettingKey, i+1)
|
||||
} else if cloudSetting.SettingValue == setting.SettingValue {
|
||||
needUpdate = false
|
||||
log.Infof(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user application cloud setting key \"%s\" value \"%s\" is not changed, no need to update (try count %d)", setting.SettingKey, setting.SettingValue, i+1)
|
||||
}
|
||||
}
|
||||
|
||||
if !needUpdate {
|
||||
return false, errs.ErrNothingWillBeUpdated
|
||||
log.Infof(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] no user application cloud settings need to update for user \"uid:%d\" (try count %d)", uid, i+1)
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
newApplicationCloudSettingsMap := make(map[string]models.ApplicationCloudSetting)
|
||||
var newApplicationCloudSettingSlice models.ApplicationCloudSettingSlice
|
||||
|
||||
if userAppCloudSettingUpdateReq.FullUpdate {
|
||||
log.Infof(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user \"uid:%d\" application cloud settings force update, will overwrite all existing settings (try count %d)", uid, i+1)
|
||||
} else {
|
||||
if len(oldApplicationCloudSettingsMap) > 0 {
|
||||
log.Infof(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user \"uid:%d\" application cloud settings exists, try to merge it with request settings (try count %d)", uid, i+1)
|
||||
newApplicationCloudSettingsMap = oldApplicationCloudSettingsMap
|
||||
}
|
||||
}
|
||||
} else { // Check if the partial update settings are the same as the existing settings or the settings to update are not set to sync
|
||||
needUpdate := true
|
||||
|
||||
for _, setting := range userAppCloudSettingUpdateReq.Settings {
|
||||
cloudSetting, exists := oldApplicationCloudSettingsMap[setting.SettingKey]
|
||||
newApplicationCloudSettingsMap[setting.SettingKey] = setting
|
||||
}
|
||||
|
||||
for settingKey, setting := range newApplicationCloudSettingsMap {
|
||||
settingType, exists := models.ALL_ALLOWED_CLOUD_SYNC_APP_SETTING_KEY_TYPES[settingKey]
|
||||
|
||||
if !exists {
|
||||
needUpdate = false
|
||||
log.Infof(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user application cloud setting key \"%s\" is not set to sync", setting.SettingKey)
|
||||
} else if cloudSetting.SettingValue == setting.SettingValue {
|
||||
needUpdate = false
|
||||
log.Infof(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user application cloud setting key \"%s\" value \"%s\" is not changed, no need to update", setting.SettingKey, setting.SettingValue)
|
||||
}
|
||||
}
|
||||
|
||||
if !needUpdate {
|
||||
log.Infof(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] no user application cloud settings need to update for user \"uid:%d\"", uid)
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
newApplicationCloudSettingsMap := make(map[string]models.ApplicationCloudSetting)
|
||||
var newApplicationCloudSettingSlice models.ApplicationCloudSettingSlice
|
||||
|
||||
if userAppCloudSettingUpdateReq.FullUpdate {
|
||||
log.Infof(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user \"uid:%d\" application cloud settings force update, will overwrite all existing settings", uid)
|
||||
} else {
|
||||
if len(oldApplicationCloudSettingsMap) > 0 {
|
||||
log.Infof(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user \"uid:%d\" application cloud settings exists, try to merge it with request settings", uid)
|
||||
newApplicationCloudSettingsMap = oldApplicationCloudSettingsMap
|
||||
}
|
||||
}
|
||||
|
||||
for _, setting := range userAppCloudSettingUpdateReq.Settings {
|
||||
newApplicationCloudSettingsMap[setting.SettingKey] = setting
|
||||
}
|
||||
|
||||
for settingKey, setting := range newApplicationCloudSettingsMap {
|
||||
settingType, exists := models.ALL_ALLOWED_CLOUD_SYNC_APP_SETTING_KEY_TYPES[settingKey]
|
||||
|
||||
if !exists {
|
||||
log.Warnf(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user application cloud setting key \"%s\" is not supported to sync", settingKey)
|
||||
continue
|
||||
}
|
||||
|
||||
if settingType == models.USER_APPLICATION_CLOUD_SETTING_TYPE_STRING {
|
||||
// Do Nothing
|
||||
} else if settingType == models.USER_APPLICATION_CLOUD_SETTING_TYPE_NUMBER {
|
||||
_, err := utils.StringToFloat64(setting.SettingValue)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user application cloud setting key \"%s\" has invalid number value \"%s\"", settingKey, setting.SettingValue)
|
||||
log.Warnf(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user application cloud setting key \"%s\" is not supported to sync (try count %d)", settingKey, i+1)
|
||||
continue
|
||||
}
|
||||
} else if settingType == models.USER_APPLICATION_CLOUD_SETTING_TYPE_BOOLEAN {
|
||||
if setting.SettingValue != "true" && setting.SettingValue != "false" {
|
||||
log.Warnf(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user application cloud setting key \"%s\" has invalid boolean value \"%s\"", settingKey, setting.SettingValue)
|
||||
continue
|
||||
}
|
||||
} else if settingType == models.USER_APPLICATION_CLOUD_SETTING_TYPE_STRING_BOOLEAN_MAP {
|
||||
var settingValueMap map[string]bool
|
||||
err := json.Unmarshal([]byte(setting.SettingValue), &settingValueMap)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user application cloud setting key \"%s\" has invalid map value \"%s\", because %s", settingKey, setting.SettingValue, err.Error())
|
||||
if settingType == models.USER_APPLICATION_CLOUD_SETTING_TYPE_STRING {
|
||||
// Do Nothing
|
||||
} else if settingType == models.USER_APPLICATION_CLOUD_SETTING_TYPE_NUMBER {
|
||||
_, err := utils.StringToFloat64(setting.SettingValue)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user application cloud setting key \"%s\" has invalid number value \"%s\" (try count %d)", settingKey, setting.SettingValue, i+1)
|
||||
continue
|
||||
}
|
||||
} else if settingType == models.USER_APPLICATION_CLOUD_SETTING_TYPE_BOOLEAN {
|
||||
if setting.SettingValue != "true" && setting.SettingValue != "false" {
|
||||
log.Warnf(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user application cloud setting key \"%s\" has invalid boolean value \"%s\" (try count %d)", settingKey, setting.SettingValue, i+1)
|
||||
continue
|
||||
}
|
||||
} else if settingType == models.USER_APPLICATION_CLOUD_SETTING_TYPE_STRING_BOOLEAN_MAP {
|
||||
var settingValueMap map[string]bool
|
||||
err := json.Unmarshal([]byte(setting.SettingValue), &settingValueMap)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user application cloud setting key \"%s\" has invalid map value \"%s\" (try count %d), because %s", settingKey, setting.SettingValue, i+1, err.Error())
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
log.Warnf(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user application cloud setting key \"%s\" has unknown type \"%s\" (try count %d)", settingKey, settingType, i+1)
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
log.Warnf(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] user application cloud setting key \"%s\" has unknown type \"%s\"", settingKey, settingType)
|
||||
continue
|
||||
|
||||
newApplicationCloudSettingSlice = append(newApplicationCloudSettingSlice, setting)
|
||||
}
|
||||
|
||||
newApplicationCloudSettingSlice = append(newApplicationCloudSettingSlice, setting)
|
||||
}
|
||||
err = a.userAppCloudSettings.UpdateUserApplicationCloudSettings(c, uid, newApplicationCloudSettingSlice, userAppCloudSettingUpdateReq.FullUpdate, lastUpdateTime)
|
||||
|
||||
err = a.userAppCloudSettings.UpdateUserApplicationCloudSettings(c, uid, newApplicationCloudSettingSlice)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
|
||||
time.Sleep(100 * time.Millisecond) // Wait for 100 milliseconds before retrying
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[user_app_cloud_settings.ApplicationSettingsUpdateHandler] failed to update user application cloud settings for user \"uid:%d\", because %s", uid, err.Error())
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"sort"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/auth/oauth2"
|
||||
"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/services"
|
||||
)
|
||||
|
||||
// UserExternalAuthsApi represents user external auth api
|
||||
type UserExternalAuthsApi struct {
|
||||
users *services.UserService
|
||||
userExternalAuths *services.UserExternalAuthService
|
||||
}
|
||||
|
||||
// Initialize a user external auth api singleton instance
|
||||
var (
|
||||
UserExternalAuths = &UserExternalAuthsApi{
|
||||
users: services.Users,
|
||||
userExternalAuths: services.UserExternalAuths,
|
||||
}
|
||||
)
|
||||
|
||||
// ExternalAuthListHanlder returns external authentications list of current user
|
||||
func (a *UserExternalAuthsApi) ExternalAuthListHanlder(c *core.WebContext) (any, *errs.Error) {
|
||||
uid := c.GetCurrentUid()
|
||||
userExternalAuths, err := a.userExternalAuths.GetUserAllExternalAuthsByUid(c, uid)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[user_external_auths.ExternalAuthListHanlder] failed to get all external authentications for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
userExternalAuthResps := make(models.UserExternalAuthInfoResponsesSlice, 0, len(userExternalAuths)+1)
|
||||
currentExternalAuthType := oauth2.GetExternalUserAuthType()
|
||||
hasCurrentExternalAuth := false
|
||||
|
||||
for i := 0; i < len(userExternalAuths); i++ {
|
||||
userExternalAuth := userExternalAuths[i]
|
||||
|
||||
if userExternalAuth.ExternalAuthType == currentExternalAuthType {
|
||||
hasCurrentExternalAuth = true
|
||||
}
|
||||
|
||||
userExternalAuthResps = append(userExternalAuthResps, userExternalAuth.ToUserExternalAuthInfoResponse())
|
||||
}
|
||||
|
||||
if !hasCurrentExternalAuth {
|
||||
userExternalAuthResps = append(userExternalAuthResps, &models.UserExternalAuthInfoResponse{
|
||||
ExternalAuthCategory: currentExternalAuthType.GetCategory(),
|
||||
ExternalAuthType: currentExternalAuthType,
|
||||
Linked: false,
|
||||
})
|
||||
}
|
||||
|
||||
sort.Sort(userExternalAuthResps)
|
||||
|
||||
return userExternalAuthResps, nil
|
||||
}
|
||||
|
||||
// UnlinkExternalAuthHandler unlinks external authentication for current user
|
||||
func (a *UserExternalAuthsApi) UnlinkExternalAuthHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
var externalAuthLinkReq models.UserExternalAuthUnlinkRequest
|
||||
err := c.ShouldBindJSON(&externalAuthLinkReq)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[user_external_auths.UnlinkExternalAuthHandler] parse request failed, because %s", err.Error())
|
||||
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
user, err := a.users.GetUserById(c, uid)
|
||||
|
||||
if err != nil {
|
||||
if !errs.IsCustomError(err) {
|
||||
log.Warnf(c, "[user_external_auths.UnlinkExternalAuthHandler] failed to get user for user \"uid:%d\", because %s", uid, err.Error())
|
||||
}
|
||||
|
||||
return nil, errs.ErrUserNotFound
|
||||
}
|
||||
|
||||
if !a.users.IsPasswordEqualsUserPassword(externalAuthLinkReq.Password, user) {
|
||||
return nil, errs.ErrUserPasswordWrong
|
||||
}
|
||||
|
||||
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_UNLINK_THIRD_PARTY_LOGIN) {
|
||||
return nil, errs.ErrNotPermittedToPerformThisAction
|
||||
}
|
||||
|
||||
externalAuthType := core.UserExternalAuthType(externalAuthLinkReq.ExternalAuthType)
|
||||
|
||||
if !externalAuthType.IsValid() {
|
||||
return nil, errs.ErrUserExternalAuthNotFound
|
||||
}
|
||||
|
||||
err = a.userExternalAuths.DeleteUserExternalAuth(c, uid, externalAuthType)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[user_external_auths.UnlinkExternalAuthHandler] failed to unlink external authentication \"%s\" for user \"uid:%d\", because %s", externalAuthLinkReq.ExternalAuthType, uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
+42
-12
@@ -83,7 +83,7 @@ func (a *UsersApi) UserRegisterHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
FeatureRestriction: a.CurrentConfig().DefaultFeatureRestrictions,
|
||||
}
|
||||
|
||||
err = a.users.CreateUser(c, user)
|
||||
err = a.users.CreateUser(c, user, false)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[users.UserRegisterHandler] failed to create user \"%s\", because %s", user.Username, err.Error())
|
||||
@@ -142,8 +142,9 @@ func (a *UsersApi) UserRegisterHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
authResp.Token = token
|
||||
c.SetTextualToken(token)
|
||||
c.SetTokenClaims(claims)
|
||||
c.SetTokenContext("")
|
||||
|
||||
log.Infof(c, "[users.UserRegisterHandler] user \"uid:%d\" has logined, token will be expired at %d", user.Uid, claims.ExpiresAt)
|
||||
log.Infof(c, "[users.UserRegisterHandler] user \"uid:%d\" has logged in, token will be expired at %d", user.Uid, claims.ExpiresAt)
|
||||
|
||||
return authResp, nil
|
||||
}
|
||||
@@ -205,6 +206,7 @@ func (a *UsersApi) UserEmailVerifyHandler(c *core.WebContext) (any, *errs.Error)
|
||||
|
||||
c.SetTextualToken(token)
|
||||
c.SetTokenClaims(claims)
|
||||
c.SetTokenContext("")
|
||||
|
||||
log.Infof(c, "[users.UserEmailVerifyHandler] user \"uid:%d\" token created, new token will be expired at %d", user.Uid, claims.ExpiresAt)
|
||||
}
|
||||
@@ -275,7 +277,7 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.WebContext) (any, *errs.Erro
|
||||
return nil, errs.ErrNotPermittedToPerformThisAction
|
||||
}
|
||||
|
||||
if !a.users.IsPasswordEqualsUserPassword(userUpdateReq.OldPassword, user) {
|
||||
if user.Password != "" && !a.users.IsPasswordEqualsUserPassword(userUpdateReq.OldPassword, user) {
|
||||
return nil, errs.ErrUserPasswordWrong
|
||||
}
|
||||
|
||||
@@ -359,6 +361,24 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.WebContext) (any, *errs.Erro
|
||||
userNew.FiscalYearStart = core.FISCAL_YEAR_START_INVALID
|
||||
}
|
||||
|
||||
if userUpdateReq.CalendarDisplayType != nil && *userUpdateReq.CalendarDisplayType != user.CalendarDisplayType {
|
||||
user.CalendarDisplayType = *userUpdateReq.CalendarDisplayType
|
||||
userNew.CalendarDisplayType = *userUpdateReq.CalendarDisplayType
|
||||
modifyProfileBasicInfo = true
|
||||
anythingUpdate = true
|
||||
} else {
|
||||
userNew.CalendarDisplayType = core.CALENDAR_DISPLAY_TYPE_INVALID
|
||||
}
|
||||
|
||||
if userUpdateReq.DateDisplayType != nil && *userUpdateReq.DateDisplayType != user.DateDisplayType {
|
||||
user.DateDisplayType = *userUpdateReq.DateDisplayType
|
||||
userNew.DateDisplayType = *userUpdateReq.DateDisplayType
|
||||
modifyProfileBasicInfo = true
|
||||
anythingUpdate = true
|
||||
} else {
|
||||
userNew.DateDisplayType = core.DATE_DISPLAY_TYPE_INVALID
|
||||
}
|
||||
|
||||
if userUpdateReq.LongDateFormat != nil && *userUpdateReq.LongDateFormat != user.LongDateFormat {
|
||||
user.LongDateFormat = *userUpdateReq.LongDateFormat
|
||||
userNew.LongDateFormat = *userUpdateReq.LongDateFormat
|
||||
@@ -404,6 +424,24 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.WebContext) (any, *errs.Erro
|
||||
userNew.FiscalYearFormat = core.FISCAL_YEAR_FORMAT_INVALID
|
||||
}
|
||||
|
||||
if userUpdateReq.CurrencyDisplayType != nil && *userUpdateReq.CurrencyDisplayType != user.CurrencyDisplayType {
|
||||
user.CurrencyDisplayType = *userUpdateReq.CurrencyDisplayType
|
||||
userNew.CurrencyDisplayType = *userUpdateReq.CurrencyDisplayType
|
||||
modifyProfileBasicInfo = true
|
||||
anythingUpdate = true
|
||||
} else {
|
||||
userNew.CurrencyDisplayType = core.CURRENCY_DISPLAY_TYPE_INVALID
|
||||
}
|
||||
|
||||
if userUpdateReq.NumeralSystem != nil && *userUpdateReq.NumeralSystem != user.NumeralSystem {
|
||||
user.NumeralSystem = *userUpdateReq.NumeralSystem
|
||||
userNew.NumeralSystem = *userUpdateReq.NumeralSystem
|
||||
modifyProfileBasicInfo = true
|
||||
anythingUpdate = true
|
||||
} else {
|
||||
userNew.NumeralSystem = core.NUMERAL_SYSTEM_INVALID
|
||||
}
|
||||
|
||||
if userUpdateReq.DecimalSeparator != nil && *userUpdateReq.DecimalSeparator != user.DecimalSeparator {
|
||||
user.DecimalSeparator = *userUpdateReq.DecimalSeparator
|
||||
userNew.DecimalSeparator = *userUpdateReq.DecimalSeparator
|
||||
@@ -431,15 +469,6 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.WebContext) (any, *errs.Erro
|
||||
userNew.DigitGrouping = core.DIGIT_GROUPING_TYPE_INVALID
|
||||
}
|
||||
|
||||
if userUpdateReq.CurrencyDisplayType != nil && *userUpdateReq.CurrencyDisplayType != user.CurrencyDisplayType {
|
||||
user.CurrencyDisplayType = *userUpdateReq.CurrencyDisplayType
|
||||
userNew.CurrencyDisplayType = *userUpdateReq.CurrencyDisplayType
|
||||
modifyProfileBasicInfo = true
|
||||
anythingUpdate = true
|
||||
} else {
|
||||
userNew.CurrencyDisplayType = core.CURRENCY_DISPLAY_TYPE_INVALID
|
||||
}
|
||||
|
||||
if userUpdateReq.CoordinateDisplayType != nil && *userUpdateReq.CoordinateDisplayType != user.CoordinateDisplayType {
|
||||
user.CoordinateDisplayType = *userUpdateReq.CoordinateDisplayType
|
||||
userNew.CoordinateDisplayType = *userUpdateReq.CoordinateDisplayType
|
||||
@@ -561,6 +590,7 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.WebContext) (any, *errs.Erro
|
||||
resp.NewToken = token
|
||||
c.SetTextualToken(token)
|
||||
c.SetTokenClaims(claims)
|
||||
c.SetTokenContext("")
|
||||
|
||||
log.Infof(c, "[users.UserUpdateProfileHandler] user \"uid:%d\" token refreshed, new token will be expired at %d", user.Uid, claims.ExpiresAt)
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package data
|
||||
|
||||
import "github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
|
||||
// OAuth2UserInfo represents the user info retrieved from OAuth 2.0 provider
|
||||
type OAuth2UserInfo struct {
|
||||
UserName string
|
||||
Email string
|
||||
NickName string
|
||||
LanguageCode string
|
||||
CurrencyCode string
|
||||
FirstDayOfWeek core.WeekDay
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package oauth2
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/auth/oauth2/data"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/auth/oauth2/provider"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/auth/oauth2/provider/gitea"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/auth/oauth2/provider/github"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/auth/oauth2/provider/nextcloud"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/auth/oauth2/provider/oidc"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||
)
|
||||
|
||||
// OAuth2Container contains the current OAuth 2.0 authentication provider
|
||||
type OAuth2Container struct {
|
||||
current provider.OAuth2Provider
|
||||
usePKCE bool
|
||||
oauth2HttpClient *http.Client
|
||||
externalUserAuthType core.UserExternalAuthType
|
||||
}
|
||||
|
||||
// Initialize a OAuth 2.0 container singleton instance
|
||||
var (
|
||||
Container = &OAuth2Container{}
|
||||
)
|
||||
|
||||
// InitializeOAuth2Provider initializes the current OAuth 2.0 provider according to the config
|
||||
func InitializeOAuth2Provider(config *settings.Config) error {
|
||||
if !config.EnableOAuth2Login {
|
||||
return nil
|
||||
}
|
||||
|
||||
if config.OAuth2ClientID == "" || config.OAuth2ClientSecret == "" || config.OAuth2UserIdentifier == "" || config.OAuth2Provider == "" {
|
||||
return errs.ErrInvalidOAuth2Config
|
||||
}
|
||||
|
||||
var err error
|
||||
var oauth2Provider provider.OAuth2Provider
|
||||
var externalUserAuthType core.UserExternalAuthType
|
||||
redirectUrl := config.RootUrl + "oauth2/callback"
|
||||
|
||||
if config.OAuth2Provider == settings.OAuth2ProviderOIDC {
|
||||
oauth2Provider, err = oidc.NewOIDCProvider(config, redirectUrl)
|
||||
externalUserAuthType = core.USER_EXTERNAL_AUTH_TYPE_OAUTH2_OIDC
|
||||
} else if config.OAuth2Provider == settings.OAuth2ProviderNextcloud {
|
||||
oauth2Provider, err = nextcloud.NewNextcloudOAuth2Provider(config, redirectUrl)
|
||||
externalUserAuthType = core.USER_EXTERNAL_AUTH_TYPE_OAUTH2_NEXTCLOUD
|
||||
} else if config.OAuth2Provider == settings.OAuth2ProviderGitea {
|
||||
oauth2Provider, err = gitea.NewGiteaOAuth2Provider(config, redirectUrl)
|
||||
externalUserAuthType = core.USER_EXTERNAL_AUTH_TYPE_OAUTH2_GITEA
|
||||
} else if config.OAuth2Provider == settings.OAuth2ProviderGithub {
|
||||
oauth2Provider, err = github.NewGithubOAuth2Provider(config, redirectUrl)
|
||||
externalUserAuthType = core.USER_EXTERNAL_AUTH_TYPE_OAUTH2_GITHUB
|
||||
} else {
|
||||
return errs.ErrInvalidOAuth2Provider
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
Container.current = oauth2Provider
|
||||
Container.usePKCE = config.OAuth2UsePKCE
|
||||
Container.oauth2HttpClient = utils.NewHttpClient(config.OAuth2RequestTimeout, config.OAuth2Proxy, config.OAuth2SkipTLSVerify, settings.GetUserAgent())
|
||||
Container.externalUserAuthType = externalUserAuthType
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetOAuth2AuthUrl returns the OAuth 2.0 authentication url
|
||||
func GetOAuth2AuthUrl(c core.Context, state string, verifier string) (string, error) {
|
||||
if Container.current == nil {
|
||||
return "", errs.ErrOAuth2NotEnabled
|
||||
}
|
||||
|
||||
var opts []oauth2.AuthCodeOption
|
||||
|
||||
if Container.usePKCE {
|
||||
opts = append(opts, oauth2.S256ChallengeOption(verifier))
|
||||
}
|
||||
|
||||
return Container.current.GetOAuth2AuthUrl(wrapOAuth2Context(c, Container.oauth2HttpClient), state, opts...)
|
||||
}
|
||||
|
||||
// GetOAuth2Token exchanges the authorization code for an OAuth 2.0 token
|
||||
func GetOAuth2Token(c core.Context, code string, verifier string) (*oauth2.Token, error) {
|
||||
if Container.current == nil || Container.oauth2HttpClient == nil {
|
||||
return nil, errs.ErrOAuth2NotEnabled
|
||||
}
|
||||
|
||||
var opts []oauth2.AuthCodeOption
|
||||
|
||||
if Container.usePKCE {
|
||||
opts = append(opts, oauth2.VerifierOption(verifier))
|
||||
}
|
||||
|
||||
return Container.current.GetOAuth2Token(wrapOAuth2Context(c, Container.oauth2HttpClient), code, opts...)
|
||||
}
|
||||
|
||||
// GetOAuth2UserInfo retrieves the OAuth 2.0 user info using the provided OAuth 2.0 token
|
||||
func GetOAuth2UserInfo(c core.Context, token *oauth2.Token) (*data.OAuth2UserInfo, error) {
|
||||
if Container.current == nil || Container.oauth2HttpClient == nil {
|
||||
return nil, errs.ErrOAuth2NotEnabled
|
||||
}
|
||||
|
||||
if token == nil {
|
||||
return nil, errs.ErrInvalidOAuth2Token
|
||||
}
|
||||
|
||||
return Container.current.GetUserInfo(wrapOAuth2Context(c, Container.oauth2HttpClient), token)
|
||||
}
|
||||
|
||||
// GetExternalUserAuthType returns the external user auth type of the current OAuth 2.0 provider
|
||||
func GetExternalUserAuthType() core.UserExternalAuthType {
|
||||
return Container.externalUserAuthType
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package oauth2
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
)
|
||||
|
||||
// OAuth2Context represents the context for OAuth 2.0 operations
|
||||
type OAuth2Context struct {
|
||||
core.Context
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// Value returns the value associated with key
|
||||
func (c *OAuth2Context) Value(key any) any {
|
||||
if key == oauth2.HTTPClient {
|
||||
return c.httpClient
|
||||
}
|
||||
|
||||
return c.Context.Value(key)
|
||||
}
|
||||
|
||||
func wrapOAuth2Context(ctx core.Context, httpClient *http.Client) core.Context {
|
||||
return &OAuth2Context{
|
||||
Context: ctx,
|
||||
httpClient: httpClient,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/auth/oauth2/data"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/auth/oauth2/provider"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||
)
|
||||
|
||||
// CommonOAuth2Provider represents common OAuth 2.0 provider
|
||||
type CommonOAuth2Provider struct {
|
||||
provider.OAuth2Provider
|
||||
oauth2Config *oauth2.Config
|
||||
dataSource CommonOAuth2DataSource
|
||||
}
|
||||
|
||||
// CommonOAuth2DataSource defines the structure of OAuth 2.0 data source
|
||||
type CommonOAuth2DataSource interface {
|
||||
// GetAuthUrl returns the authentication url of the data source
|
||||
GetAuthUrl() string
|
||||
|
||||
// GetTokenUrl returns the token url of the data source
|
||||
GetTokenUrl() string
|
||||
|
||||
// GetUserInfoRequest returns the user info request of the data source
|
||||
GetUserInfoRequest() (*http.Request, error)
|
||||
|
||||
// GetScopes returns the scopes required by the data source
|
||||
GetScopes() []string
|
||||
|
||||
// ParseUserInfo returns the user info by parsing the response body
|
||||
ParseUserInfo(c core.Context, body []byte) (*data.OAuth2UserInfo, error)
|
||||
}
|
||||
|
||||
// GetOAuth2AuthUrl returns the authentication url of the common OAuth 2.0 provider
|
||||
func (p *CommonOAuth2Provider) GetOAuth2AuthUrl(c core.Context, state string, opts ...oauth2.AuthCodeOption) (string, error) {
|
||||
return p.oauth2Config.AuthCodeURL(state, opts...), nil
|
||||
}
|
||||
|
||||
// GetOAuth2Token returns the OAuth 2.0 token of the common OAuth 2.0 provider
|
||||
func (p *CommonOAuth2Provider) GetOAuth2Token(c core.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) {
|
||||
return p.oauth2Config.Exchange(c, code, opts...)
|
||||
}
|
||||
|
||||
// GetUserInfo returns the user info by the common OAuth 2.0 provider
|
||||
func (p *CommonOAuth2Provider) GetUserInfo(c core.Context, oauth2Token *oauth2.Token) (*data.OAuth2UserInfo, error) {
|
||||
req, err := p.dataSource.GetUserInfoRequest()
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[common_oauth2_provider.GetUserInfo] failed to get user info request, because %s", err.Error())
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
oauth2Client := oauth2.NewClient(c, oauth2.StaticTokenSource(oauth2Token))
|
||||
resp, err := oauth2Client.Do(req)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[common_oauth2_provider.GetUserInfo] failed to get user info response, because %s", err.Error())
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
|
||||
log.Debugf(c, "[common_oauth2_provider.GetUserInfo] response is %s", body)
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
log.Errorf(c, "[common_oauth2_provider.GetUserInfo] failed to get user info response, because response code is %d", resp.StatusCode)
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
return p.dataSource.ParseUserInfo(c, body)
|
||||
}
|
||||
|
||||
// GetDataSource returns the data source of the common OAuth 2.0 provider
|
||||
func (p *CommonOAuth2Provider) GetDataSource() CommonOAuth2DataSource {
|
||||
return p.dataSource
|
||||
}
|
||||
|
||||
// NewCommonOAuth2Provider returns a new common OAuth 2.0 provider
|
||||
func NewCommonOAuth2Provider(config *settings.Config, redirectUrl string, dataSource CommonOAuth2DataSource) *CommonOAuth2Provider {
|
||||
oauth2Config := &oauth2.Config{
|
||||
ClientID: config.OAuth2ClientID,
|
||||
ClientSecret: config.OAuth2ClientSecret,
|
||||
Endpoint: oauth2.Endpoint{
|
||||
AuthURL: dataSource.GetAuthUrl(),
|
||||
TokenURL: dataSource.GetTokenUrl(),
|
||||
},
|
||||
RedirectURL: redirectUrl,
|
||||
Scopes: dataSource.GetScopes(),
|
||||
}
|
||||
|
||||
return &CommonOAuth2Provider{
|
||||
oauth2Config: oauth2Config,
|
||||
dataSource: dataSource,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package gitea
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/auth/oauth2/data"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/auth/oauth2/provider"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/auth/oauth2/provider/common"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||
)
|
||||
|
||||
type giteaUserInfoResponse struct {
|
||||
Login string `json:"login"`
|
||||
FullName string `json:"full_name"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
// GiteaOAuth2DataSource represents Gitea OAuth 2.0 data source
|
||||
type GiteaOAuth2DataSource struct {
|
||||
common.CommonOAuth2DataSource
|
||||
baseUrl string
|
||||
}
|
||||
|
||||
// GetAuthUrl returns the authentication url of the Gitea data source
|
||||
func (s *GiteaOAuth2DataSource) GetAuthUrl() string {
|
||||
// Reference: https://docs.gitea.com/development/oauth2-provider
|
||||
return s.baseUrl + "login/oauth/authorize"
|
||||
}
|
||||
|
||||
// GetTokenUrl returns the token url of the Gitea data source
|
||||
func (s *GiteaOAuth2DataSource) GetTokenUrl() string {
|
||||
// Reference: https://docs.gitea.com/development/oauth2-provider
|
||||
return s.baseUrl + "login/oauth/access_token"
|
||||
}
|
||||
|
||||
// GetUserInfoRequest returns the user info request of the Gitea data source
|
||||
func (s *GiteaOAuth2DataSource) GetUserInfoRequest() (*http.Request, error) {
|
||||
// Reference: https://gitea.com/api/swagger#/user/userGetCurrent
|
||||
req, err := http.NewRequest("GET", s.baseUrl+"api/v1/user", nil)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Accept", "application/json")
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// GetScopes returns the scopes required by the Gitea provider
|
||||
func (s *GiteaOAuth2DataSource) GetScopes() []string {
|
||||
return []string{"read:user"}
|
||||
}
|
||||
|
||||
// ParseUserInfo returns the user info by parsing the response body
|
||||
func (s *GiteaOAuth2DataSource) ParseUserInfo(c core.Context, body []byte) (*data.OAuth2UserInfo, error) {
|
||||
userInfoResp := &giteaUserInfoResponse{}
|
||||
err := json.Unmarshal(body, &userInfoResp)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[gitea_oauth2_datasource.ParseUserInfo] failed to parse user profile response body, because %s", err.Error())
|
||||
return nil, errs.ErrCannotRetrieveUserInfo
|
||||
}
|
||||
|
||||
if userInfoResp.Login == "" {
|
||||
log.Warnf(c, "[gitea_oauth2_datasource.ParseUserInfo] invalid user profile response body")
|
||||
return nil, errs.ErrCannotRetrieveUserInfo
|
||||
}
|
||||
|
||||
return &data.OAuth2UserInfo{
|
||||
UserName: userInfoResp.Login,
|
||||
Email: userInfoResp.Email,
|
||||
NickName: userInfoResp.FullName,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NewGiteaOAuth2Provider creates a new Gitea OAuth 2.0 provider instance
|
||||
func NewGiteaOAuth2Provider(config *settings.Config, redirectUrl string) (provider.OAuth2Provider, error) {
|
||||
if len(config.OAuth2GiteaBaseUrl) < 1 {
|
||||
return nil, errs.ErrInvalidOAuth2Config
|
||||
}
|
||||
|
||||
baseUrl := config.OAuth2GiteaBaseUrl
|
||||
|
||||
if baseUrl[len(baseUrl)-1] != '/' {
|
||||
baseUrl += "/"
|
||||
}
|
||||
|
||||
return common.NewCommonOAuth2Provider(config, redirectUrl, &GiteaOAuth2DataSource{
|
||||
baseUrl: baseUrl,
|
||||
}), nil
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package gitea
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/auth/oauth2/provider/common"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||
)
|
||||
|
||||
func TestNewGiteaOAuth2Provider(t *testing.T) {
|
||||
provider, err := NewGiteaOAuth2Provider(&settings.Config{
|
||||
OAuth2GiteaBaseUrl: "https://example.com/",
|
||||
}, "")
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, "https://example.com/login/oauth/authorize", provider.(*common.CommonOAuth2Provider).GetDataSource().GetAuthUrl())
|
||||
assert.Equal(t, "https://example.com/login/oauth/access_token", provider.(*common.CommonOAuth2Provider).GetDataSource().GetTokenUrl())
|
||||
|
||||
provider, err = NewGiteaOAuth2Provider(&settings.Config{
|
||||
OAuth2GiteaBaseUrl: "https://example.com",
|
||||
}, "")
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, "https://example.com/login/oauth/authorize", provider.(*common.CommonOAuth2Provider).GetDataSource().GetAuthUrl())
|
||||
assert.Equal(t, "https://example.com/login/oauth/access_token", provider.(*common.CommonOAuth2Provider).GetDataSource().GetTokenUrl())
|
||||
|
||||
provider, err = NewGiteaOAuth2Provider(&settings.Config{}, "")
|
||||
assert.Equal(t, errs.ErrInvalidOAuth2Config, err)
|
||||
}
|
||||
|
||||
func TestGiteaOAuth2Datasource_GetUserInfoRequest(t *testing.T) {
|
||||
datasource := &GiteaOAuth2DataSource{baseUrl: "https://example.com/"}
|
||||
req, err := datasource.GetUserInfoRequest()
|
||||
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, "GET", req.Method)
|
||||
assert.Equal(t, "https://example.com/api/v1/user", req.URL.String())
|
||||
assert.Equal(t, "application/json", req.Header.Get("Accept"))
|
||||
}
|
||||
|
||||
func TestGiteaOAuth2Datasource_ParseUserInfo_Success(t *testing.T) {
|
||||
datasource := &GiteaOAuth2DataSource{}
|
||||
responseContent := `{
|
||||
"login": "user1",
|
||||
"full_name": "User",
|
||||
"email": "user1@example.com"
|
||||
}`
|
||||
info, err := datasource.ParseUserInfo(core.NewNullContext(), []byte(responseContent))
|
||||
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, "user1", info.UserName)
|
||||
assert.Equal(t, "user1@example.com", info.Email)
|
||||
assert.Equal(t, "User", info.NickName)
|
||||
}
|
||||
|
||||
func TestGiteaOAuth2Datasource_ParseUserInfo_InvalidJson(t *testing.T) {
|
||||
datasource := &GiteaOAuth2DataSource{}
|
||||
_, err := datasource.ParseUserInfo(core.NewNullContext(), []byte("invalid"))
|
||||
|
||||
assert.Equal(t, errs.ErrCannotRetrieveUserInfo, err)
|
||||
}
|
||||
|
||||
func TestGiteaOAuth2Datasource_ParseUserInfo_EmptyLogin(t *testing.T) {
|
||||
datasource := &GiteaOAuth2DataSource{}
|
||||
responseContent := `{"login": ""}`
|
||||
_, err := datasource.ParseUserInfo(core.NewNullContext(), []byte(responseContent))
|
||||
|
||||
assert.Equal(t, errs.ErrCannotRetrieveUserInfo, err)
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package github
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/auth/oauth2/data"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/auth/oauth2/provider"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||
)
|
||||
|
||||
const githubOAuth2AuthUrl = "https://github.com/login/oauth/authorize" // Reference: https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps
|
||||
const githubOAuth2TokenUrl = "https://github.com/login/oauth/access_token" // Reference: https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps
|
||||
const githubUserProfileApiUrl = "https://api.github.com/user" // Reference: https://docs.github.com/en/rest/users/users
|
||||
const githubUserEmailApiUrl = "https://api.github.com/user/emails" // Reference: https://docs.github.com/en/rest/users/emails
|
||||
|
||||
var githubOAuth2Scopes = []string{"user:email"}
|
||||
|
||||
type githubUserProfileResponse struct {
|
||||
Login string `json:"login"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
type githubUserEmailsResponse struct {
|
||||
Email string `json:"email"`
|
||||
Primary bool `json:"primary"`
|
||||
Verified bool `json:"verified"`
|
||||
}
|
||||
|
||||
// GithubOAuth2Provider represents Github OAuth 2.0 provider
|
||||
type GithubOAuth2Provider struct {
|
||||
provider.OAuth2Provider
|
||||
oauth2Config *oauth2.Config
|
||||
}
|
||||
|
||||
// GetOAuth2AuthUrl returns the authentication url of the GitHub OAuth 2.0 provider
|
||||
func (p *GithubOAuth2Provider) GetOAuth2AuthUrl(c core.Context, state string, opts ...oauth2.AuthCodeOption) (string, error) {
|
||||
return p.oauth2Config.AuthCodeURL(state, opts...), nil
|
||||
}
|
||||
|
||||
// GetOAuth2Token returns the OAuth 2.0 token of the GitHub OAuth 2.0 provider
|
||||
func (p *GithubOAuth2Provider) GetOAuth2Token(c core.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) {
|
||||
return p.oauth2Config.Exchange(c, code, opts...)
|
||||
}
|
||||
|
||||
// GetUserInfo returns the user info by the Github OAuth 2.0 provider
|
||||
func (p *GithubOAuth2Provider) GetUserInfo(c core.Context, oauth2Token *oauth2.Token) (*data.OAuth2UserInfo, error) {
|
||||
// first get user name and nick name from user profile
|
||||
req, err := p.buildAPIRequest(githubUserProfileApiUrl)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[github_oauth2_provider.GetUserInfo] failed to get user info request, because %s", err.Error())
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
oauth2Client := oauth2.NewClient(c, oauth2.StaticTokenSource(oauth2Token))
|
||||
resp, err := oauth2Client.Do(req)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[github_oauth2_provider.GetUserInfo] failed to get user info response, because %s", err.Error())
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
|
||||
log.Debugf(c, "[github_oauth2_provider.GetUserInfo] user profile response is %s", body)
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
log.Errorf(c, "[github_oauth2_provider.GetUserInfo] failed to get user info response, because response code is %d", resp.StatusCode)
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
userProfileResp, err := p.parseUserProfile(c, body)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// then get user primary email
|
||||
req, err = p.buildAPIRequest(githubUserEmailApiUrl)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[github_oauth2_provider.GetUserInfo] failed to get user emails request, because %s", err.Error())
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
resp, err = oauth2Client.Do(req)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[github_oauth2_provider.GetUserInfo] failed to get user emails response, because %s", err.Error())
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
body, err = io.ReadAll(resp.Body)
|
||||
|
||||
log.Debugf(c, "[github_oauth2_provider.GetUserInfo] user emails response is %s", body)
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
log.Errorf(c, "[github_oauth2_provider.GetUserInfo] failed to get user emails response, because response code is %d", resp.StatusCode)
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
email, err := p.parsePrimaryEmail(c, body)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &data.OAuth2UserInfo{
|
||||
UserName: userProfileResp.Login,
|
||||
Email: email,
|
||||
NickName: userProfileResp.Name,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *GithubOAuth2Provider) parseUserProfile(c core.Context, body []byte) (*githubUserProfileResponse, error) {
|
||||
userProfileResp := &githubUserProfileResponse{}
|
||||
err := json.Unmarshal(body, &userProfileResp)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[github_oauth2_provider.parseUserProfile] failed to parse user profile response body, because %s", err.Error())
|
||||
return nil, errs.ErrCannotRetrieveUserInfo
|
||||
}
|
||||
|
||||
if userProfileResp.Login == "" {
|
||||
log.Warnf(c, "[github_oauth2_provider.parseUserProfile] invalid user profile response body")
|
||||
return nil, errs.ErrCannotRetrieveUserInfo
|
||||
}
|
||||
|
||||
return userProfileResp, nil
|
||||
}
|
||||
|
||||
func (p *GithubOAuth2Provider) parsePrimaryEmail(c core.Context, body []byte) (string, error) {
|
||||
emailsResp := make([]githubUserEmailsResponse, 0)
|
||||
err := json.Unmarshal(body, &emailsResp)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[github_oauth2_provider.parsePrimaryEmail] failed to parse user emails response body, because %s", err.Error())
|
||||
return "", errs.ErrCannotRetrieveUserInfo
|
||||
}
|
||||
|
||||
for _, emailEntry := range emailsResp {
|
||||
if emailEntry.Primary && emailEntry.Verified {
|
||||
return emailEntry.Email, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (p *GithubOAuth2Provider) buildAPIRequest(url string) (*http.Request, error) {
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// NewGithubOAuth2Provider creates a new Github OAuth 2.0 provider instance
|
||||
func NewGithubOAuth2Provider(config *settings.Config, redirectUrl string) (provider.OAuth2Provider, error) {
|
||||
oauth2Config := &oauth2.Config{
|
||||
ClientID: config.OAuth2ClientID,
|
||||
ClientSecret: config.OAuth2ClientSecret,
|
||||
Endpoint: oauth2.Endpoint{
|
||||
AuthURL: githubOAuth2AuthUrl,
|
||||
TokenURL: githubOAuth2TokenUrl,
|
||||
},
|
||||
RedirectURL: redirectUrl,
|
||||
Scopes: githubOAuth2Scopes,
|
||||
}
|
||||
|
||||
return &GithubOAuth2Provider{
|
||||
oauth2Config: oauth2Config,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package github
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
)
|
||||
|
||||
func TestGithubOAuth2Datasource_ParseUserProfile_Success(t *testing.T) {
|
||||
datasource := &GithubOAuth2Provider{}
|
||||
responseContent := `{
|
||||
"login": "octocat",
|
||||
"id": 1,
|
||||
"node_id": "MDQ6VXNlcjE=",
|
||||
"avatar_url": "https://github.com/images/error/octocat_happy.gif",
|
||||
"gravatar_id": "",
|
||||
"url": "https://api.github.com/users/octocat",
|
||||
"html_url": "https://github.com/octocat",
|
||||
"followers_url": "https://api.github.com/users/octocat/followers",
|
||||
"following_url": "https://api.github.com/users/octocat/following{/other_user}",
|
||||
"gists_url": "https://api.github.com/users/octocat/gists{/gist_id}",
|
||||
"starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}",
|
||||
"subscriptions_url": "https://api.github.com/users/octocat/subscriptions",
|
||||
"organizations_url": "https://api.github.com/users/octocat/orgs",
|
||||
"repos_url": "https://api.github.com/users/octocat/repos",
|
||||
"events_url": "https://api.github.com/users/octocat/events{/privacy}",
|
||||
"received_events_url": "https://api.github.com/users/octocat/received_events",
|
||||
"type": "User",
|
||||
"site_admin": false,
|
||||
"name": "monalisa octocat",
|
||||
"company": "GitHub",
|
||||
"blog": "https://github.com/blog",
|
||||
"location": "San Francisco",
|
||||
"email": "octocat@github.com",
|
||||
"hireable": false,
|
||||
"bio": "There once was...",
|
||||
"twitter_username": "monatheoctocat",
|
||||
"public_repos": 2,
|
||||
"public_gists": 1,
|
||||
"followers": 20,
|
||||
"following": 0,
|
||||
"created_at": "2008-01-14T04:33:35Z",
|
||||
"updated_at": "2008-01-14T04:33:35Z",
|
||||
"private_gists": 81,
|
||||
"total_private_repos": 100,
|
||||
"owned_private_repos": 100,
|
||||
"disk_usage": 10000,
|
||||
"collaborators": 8,
|
||||
"two_factor_authentication": true,
|
||||
"plan": {
|
||||
"name": "Medium",
|
||||
"space": 400,
|
||||
"private_repos": 20,
|
||||
"collaborators": 0
|
||||
}
|
||||
}`
|
||||
info, err := datasource.parseUserProfile(core.NewNullContext(), []byte(responseContent))
|
||||
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, "octocat", info.Login)
|
||||
assert.Equal(t, "monalisa octocat", info.Name)
|
||||
}
|
||||
|
||||
func TestGithubOAuth2Datasource_ParseUserProfile_EmptyLogin(t *testing.T) {
|
||||
datasource := &GithubOAuth2Provider{}
|
||||
responseContent := `{"login": ""}`
|
||||
_, err := datasource.parseUserProfile(core.NewNullContext(), []byte(responseContent))
|
||||
|
||||
assert.Equal(t, errs.ErrCannotRetrieveUserInfo, err)
|
||||
}
|
||||
|
||||
func TestGithubOAuth2Datasource_ParsePrimaryEmail(t *testing.T) {
|
||||
datasource := &GithubOAuth2Provider{}
|
||||
responseContent := `[
|
||||
{
|
||||
"email": "foo@bar.com",
|
||||
"primary": false,
|
||||
"verified": true,
|
||||
"visibility": null
|
||||
},
|
||||
{
|
||||
"email": "octocat@github.com",
|
||||
"primary": true,
|
||||
"verified": true,
|
||||
"visibility": "public"
|
||||
}
|
||||
]`
|
||||
email, err := datasource.parsePrimaryEmail(core.NewNullContext(), []byte(responseContent))
|
||||
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, "octocat@github.com", email)
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package nextcloud
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/auth/oauth2/data"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/auth/oauth2/provider"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/auth/oauth2/provider/common"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||
)
|
||||
|
||||
type nextcloudUserInfoResponse struct {
|
||||
OCS *struct {
|
||||
Meta *struct {
|
||||
Status string `json:"status"`
|
||||
StatusCode int `json:"statuscode"`
|
||||
} `json:"meta"`
|
||||
Data *struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"display-name"`
|
||||
} `json:"data"`
|
||||
} `json:"ocs"`
|
||||
}
|
||||
|
||||
// NextcloudOAuth2DataSource represents Nextcloud OAuth 2.0 data source
|
||||
type NextcloudOAuth2DataSource struct {
|
||||
common.CommonOAuth2DataSource
|
||||
baseUrl string
|
||||
}
|
||||
|
||||
// GetAuthUrl returns the authentication url of the Nextcloud data source
|
||||
func (s *NextcloudOAuth2DataSource) GetAuthUrl() string {
|
||||
// Reference: https://docs.nextcloud.com/server/stable/developer_manual/_static/openapi.html#/operations/oauth2-login_redirector-authorize
|
||||
return s.baseUrl + "apps/oauth2/authorize"
|
||||
}
|
||||
|
||||
// GetTokenUrl returns the token url of the Nextcloud data source
|
||||
func (s *NextcloudOAuth2DataSource) GetTokenUrl() string {
|
||||
// Reference: https://docs.nextcloud.com/server/stable/developer_manual/_static/openapi.html#/operations/oauth2-oauth_api-get-token
|
||||
return s.baseUrl + "apps/oauth2/api/v1/token"
|
||||
}
|
||||
|
||||
// GetUserInfoRequest returns the user info request of the Nextcloud data source
|
||||
func (s *NextcloudOAuth2DataSource) GetUserInfoRequest() (*http.Request, error) {
|
||||
// Reference: https://docs.nextcloud.com/server/stable/developer_manual/_static/openapi.html#/operations/provisioning_api-users-get-current-user
|
||||
req, err := http.NewRequest("GET", s.baseUrl+"ocs/v2.php/cloud/user", nil)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("OCS-APIRequest", "true")
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// GetScopes returns the scopes required by the Nextcloud provider
|
||||
func (s *NextcloudOAuth2DataSource) GetScopes() []string {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
// ParseUserInfo returns the user info by parsing the response body
|
||||
func (s *NextcloudOAuth2DataSource) ParseUserInfo(c core.Context, body []byte) (*data.OAuth2UserInfo, error) {
|
||||
userInfoResp := &nextcloudUserInfoResponse{}
|
||||
err := json.Unmarshal(body, &userInfoResp)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[nextcloud_oauth2_datasource.ParseUserInfo] failed to parse user info response body, because %s", err.Error())
|
||||
return nil, errs.ErrCannotRetrieveUserInfo
|
||||
}
|
||||
|
||||
if userInfoResp.OCS == nil || userInfoResp.OCS.Meta == nil || userInfoResp.OCS.Data == nil {
|
||||
log.Warnf(c, "[nextcloud_oauth2_datasource.ParseUserInfo] invalid user info response body")
|
||||
return nil, errs.ErrCannotRetrieveUserInfo
|
||||
}
|
||||
|
||||
if userInfoResp.OCS.Meta.StatusCode != 200 {
|
||||
log.Warnf(c, "[nextcloud_oauth2_datasource.ParseUserInfo] user info response status code is %d", userInfoResp.OCS.Meta.StatusCode)
|
||||
return nil, errs.ErrCannotRetrieveUserInfo
|
||||
}
|
||||
|
||||
if userInfoResp.OCS.Data.ID == "" {
|
||||
log.Warnf(c, "[nextcloud_oauth2_datasource.ParseUserInfo] user info id is empty")
|
||||
return nil, errs.ErrCannotRetrieveUserInfo
|
||||
}
|
||||
|
||||
return &data.OAuth2UserInfo{
|
||||
UserName: userInfoResp.OCS.Data.ID,
|
||||
Email: userInfoResp.OCS.Data.Email,
|
||||
NickName: userInfoResp.OCS.Data.DisplayName,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NewNextcloudOAuth2Provider creates a new Nextcloud OAuth 2.0 provider instance
|
||||
func NewNextcloudOAuth2Provider(config *settings.Config, redirectUrl string) (provider.OAuth2Provider, error) {
|
||||
if len(config.OAuth2NextcloudBaseUrl) < 1 {
|
||||
return nil, errs.ErrInvalidOAuth2Config
|
||||
}
|
||||
|
||||
baseUrl := config.OAuth2NextcloudBaseUrl
|
||||
|
||||
if baseUrl[len(baseUrl)-1] != '/' {
|
||||
baseUrl += "/"
|
||||
}
|
||||
|
||||
return common.NewCommonOAuth2Provider(config, redirectUrl, &NextcloudOAuth2DataSource{
|
||||
baseUrl: baseUrl,
|
||||
}), nil
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package nextcloud
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/auth/oauth2/provider/common"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||
)
|
||||
|
||||
func TestNewNextcloudOAuth2Provider(t *testing.T) {
|
||||
provider, err := NewNextcloudOAuth2Provider(&settings.Config{
|
||||
OAuth2NextcloudBaseUrl: "https://example.com/",
|
||||
}, "")
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, "https://example.com/apps/oauth2/authorize", provider.(*common.CommonOAuth2Provider).GetDataSource().GetAuthUrl())
|
||||
assert.Equal(t, "https://example.com/apps/oauth2/api/v1/token", provider.(*common.CommonOAuth2Provider).GetDataSource().GetTokenUrl())
|
||||
|
||||
provider, err = NewNextcloudOAuth2Provider(&settings.Config{
|
||||
OAuth2NextcloudBaseUrl: "https://example.com/index.php",
|
||||
}, "")
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, "https://example.com/index.php/apps/oauth2/authorize", provider.(*common.CommonOAuth2Provider).GetDataSource().GetAuthUrl())
|
||||
assert.Equal(t, "https://example.com/index.php/apps/oauth2/api/v1/token", provider.(*common.CommonOAuth2Provider).GetDataSource().GetTokenUrl())
|
||||
|
||||
provider, err = NewNextcloudOAuth2Provider(&settings.Config{}, "")
|
||||
assert.Equal(t, errs.ErrInvalidOAuth2Config, err)
|
||||
}
|
||||
|
||||
func TestNextcloudOAuth2Datasource_GetUserInfoRequest(t *testing.T) {
|
||||
datasource := &NextcloudOAuth2DataSource{baseUrl: "https://example.com/"}
|
||||
req, err := datasource.GetUserInfoRequest()
|
||||
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, "GET", req.Method)
|
||||
assert.Equal(t, "https://example.com/ocs/v2.php/cloud/user", req.URL.String())
|
||||
assert.Equal(t, "application/json", req.Header.Get("Accept"))
|
||||
assert.Equal(t, "true", req.Header.Get("OCS-APIRequest"))
|
||||
}
|
||||
|
||||
func TestNextcloudOAuth2Datasource_ParseUserInfo_Success(t *testing.T) {
|
||||
datasource := &NextcloudOAuth2DataSource{}
|
||||
responseContent := `{
|
||||
"ocs": {
|
||||
"meta": {
|
||||
"status": "ok",
|
||||
"statuscode": 200
|
||||
},
|
||||
"data": {
|
||||
"id": "user1",
|
||||
"email": "user1@example.com",
|
||||
"display-name": "User"
|
||||
}
|
||||
}
|
||||
}`
|
||||
info, err := datasource.ParseUserInfo(core.NewNullContext(), []byte(responseContent))
|
||||
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, "user1", info.UserName)
|
||||
assert.Equal(t, "user1@example.com", info.Email)
|
||||
assert.Equal(t, "User", info.NickName)
|
||||
}
|
||||
|
||||
func TestNextcloudOAuth2Datasource_ParseUserInfo_InvalidJson(t *testing.T) {
|
||||
datasource := &NextcloudOAuth2DataSource{}
|
||||
_, err := datasource.ParseUserInfo(core.NewNullContext(), []byte("invalid"))
|
||||
|
||||
assert.Equal(t, errs.ErrCannotRetrieveUserInfo, err)
|
||||
}
|
||||
|
||||
func TestNextcloudOAuth2Datasource_ParseUserInfo_MissingFields(t *testing.T) {
|
||||
datasource := &NextcloudOAuth2DataSource{}
|
||||
responseContent := `{"ocs": {}}`
|
||||
_, err := datasource.ParseUserInfo(core.NewNullContext(), []byte(responseContent))
|
||||
|
||||
assert.Equal(t, errs.ErrCannotRetrieveUserInfo, err)
|
||||
}
|
||||
|
||||
func TestNextcloudOAuth2Datasource_ParseUserInfo_Non200StatusCode(t *testing.T) {
|
||||
datasource := &NextcloudOAuth2DataSource{}
|
||||
responseContent := `{
|
||||
"ocs": {
|
||||
"meta": {
|
||||
"status": "error",
|
||||
"statuscode": 400
|
||||
},
|
||||
"data": {}
|
||||
}
|
||||
}`
|
||||
_, err := datasource.ParseUserInfo(core.NewNullContext(), []byte(responseContent))
|
||||
|
||||
assert.Equal(t, errs.ErrCannotRetrieveUserInfo, err)
|
||||
}
|
||||
|
||||
func TestNextcloudOAuth2Datasource_ParseUserInfo_EmptyID(t *testing.T) {
|
||||
datasource := &NextcloudOAuth2DataSource{}
|
||||
responseContent := `{
|
||||
"ocs": {
|
||||
"meta": {
|
||||
"status": "ok",
|
||||
"statuscode": 200
|
||||
},
|
||||
"data": {
|
||||
"id": "",
|
||||
"email": "user1@example.com",
|
||||
"display-name": "User One"
|
||||
}
|
||||
}
|
||||
}`
|
||||
_, err := datasource.ParseUserInfo(core.NewNullContext(), []byte(responseContent))
|
||||
|
||||
assert.Equal(t, errs.ErrCannotRetrieveUserInfo, err)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/auth/oauth2/data"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
)
|
||||
|
||||
// OAuth2Provider defines the structure of OAuth 2.0 provider
|
||||
type OAuth2Provider interface {
|
||||
// GetOAuth2AuthUrl returns the authentication url of the provider
|
||||
GetOAuth2AuthUrl(c core.Context, state string, opts ...oauth2.AuthCodeOption) (string, error)
|
||||
|
||||
// GetOAuth2Token returns the OAuth 2.0 token of the provider
|
||||
GetOAuth2Token(c core.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error)
|
||||
|
||||
// GetUserInfo returns the user info
|
||||
GetUserInfo(c core.Context, oauth2Token *oauth2.Token) (*data.OAuth2UserInfo, error)
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/auth/oauth2/data"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/auth/oauth2/provider"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||
)
|
||||
|
||||
// OIDCClaims represents OIDC claims
|
||||
type OIDCClaims struct {
|
||||
PreferredUserName string `json:"preferred_username"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
// OIDCProvider represents OIDC provider
|
||||
type OIDCProvider struct {
|
||||
provider.OAuth2Provider
|
||||
oidcIssuerURL string
|
||||
oidcCheckIssuerURL bool
|
||||
redirectUrl string
|
||||
oauth2ClientID string
|
||||
oauth2ClientSecret string
|
||||
oauth2Config *oauth2.Config
|
||||
oidcProvider *oidc.Provider
|
||||
oidcVerifier *oidc.IDTokenVerifier
|
||||
}
|
||||
|
||||
// GetOAuth2AuthUrl returns the authentication url of the OIDC provider
|
||||
func (p *OIDCProvider) GetOAuth2AuthUrl(c core.Context, state string, opts ...oauth2.AuthCodeOption) (string, error) {
|
||||
oauth2Config, err := p.getOAuth2Config(c)
|
||||
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return oauth2Config.AuthCodeURL(state, opts...), nil
|
||||
}
|
||||
|
||||
// GetOAuth2Token returns the OAuth 2.0 token of the OIDC provider
|
||||
func (p *OIDCProvider) GetOAuth2Token(c core.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) {
|
||||
oauth2Config, err := p.getOAuth2Config(c)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return oauth2Config.Exchange(c, code, opts...)
|
||||
}
|
||||
|
||||
// GetUserInfo returns the user info by the OIDC provider
|
||||
func (p *OIDCProvider) GetUserInfo(c core.Context, oauth2Token *oauth2.Token) (*data.OAuth2UserInfo, error) {
|
||||
_, err := p.getOAuth2Config(c)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rawIDToken, ok := oauth2Token.Extra("id_token").(string)
|
||||
|
||||
if !ok {
|
||||
log.Errorf(c, "[oidc_provider.GetUserInfo] missing \"id_token\" field in oauth 2.0 token")
|
||||
return nil, errs.ErrInvalidOAuth2Token
|
||||
}
|
||||
|
||||
idToken, err := p.oidcVerifier.Verify(c, rawIDToken)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[oidc_provider.GetUserInfo] failed to verify \"id_token\" field in oauth 2.0 token, because %s", err.Error())
|
||||
return nil, errs.ErrInvalidOAuth2Token
|
||||
}
|
||||
|
||||
var claims OIDCClaims
|
||||
err = idToken.Claims(&claims)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[oidc_provider.GetUserInfo] failed to parse claims in oauth 2.0 token, because %s", err.Error())
|
||||
return nil, errs.ErrInvalidOAuth2Token
|
||||
}
|
||||
|
||||
userName := claims.PreferredUserName
|
||||
email := claims.Email
|
||||
nickName := claims.Name
|
||||
|
||||
if userName == "" || email == "" || nickName == "" {
|
||||
userInfo, err := p.oidcProvider.UserInfo(c, oauth2.StaticTokenSource(oauth2Token))
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[oidc_provider.GetUserInfo] failed to get user info, because %s", err.Error())
|
||||
return nil, errs.ErrCannotRetrieveUserInfo
|
||||
}
|
||||
|
||||
err = userInfo.Claims(&claims)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[oidc_provider.GetUserInfo] failed to parse user info, because %s", err.Error())
|
||||
return nil, errs.ErrCannotRetrieveUserInfo
|
||||
}
|
||||
|
||||
if userName == "" {
|
||||
userName = claims.PreferredUserName
|
||||
}
|
||||
|
||||
if email == "" {
|
||||
email = claims.Email
|
||||
}
|
||||
|
||||
if nickName == "" {
|
||||
nickName = claims.Name
|
||||
}
|
||||
}
|
||||
|
||||
return &data.OAuth2UserInfo{
|
||||
UserName: userName,
|
||||
Email: email,
|
||||
NickName: nickName,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *OIDCProvider) getOAuth2Config(c core.Context) (*oauth2.Config, error) {
|
||||
if p.oauth2Config != nil {
|
||||
return p.oauth2Config, nil
|
||||
}
|
||||
|
||||
var ctx context.Context = c
|
||||
|
||||
if !p.oidcCheckIssuerURL {
|
||||
ctx = oidc.InsecureIssuerURLContext(c, p.oidcIssuerURL)
|
||||
}
|
||||
|
||||
oidcProvider, err := oidc.NewProvider(ctx, p.oidcIssuerURL)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[oidc_provider.getOAuth2Config] failed to create oidc provider, because %s", err.Error())
|
||||
return nil, err
|
||||
}
|
||||
|
||||
oidcVerifier := oidcProvider.Verifier(&oidc.Config{
|
||||
ClientID: p.oauth2ClientID,
|
||||
SkipIssuerCheck: !p.oidcCheckIssuerURL,
|
||||
})
|
||||
|
||||
oauth2Config := &oauth2.Config{
|
||||
ClientID: p.oauth2ClientID,
|
||||
ClientSecret: p.oauth2ClientSecret,
|
||||
Endpoint: oidcProvider.Endpoint(),
|
||||
RedirectURL: p.redirectUrl,
|
||||
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
|
||||
}
|
||||
|
||||
p.oauth2Config = oauth2Config
|
||||
p.oidcProvider = oidcProvider
|
||||
p.oidcVerifier = oidcVerifier
|
||||
return oauth2Config, nil
|
||||
}
|
||||
|
||||
// NewOIDCProvider returns a new OIDC provider
|
||||
func NewOIDCProvider(config *settings.Config, redirectUrl string) (*OIDCProvider, error) {
|
||||
if len(config.OAuth2OIDCProviderIssuerURL) < 1 {
|
||||
return nil, errs.ErrInvalidOAuth2Config
|
||||
}
|
||||
|
||||
return &OIDCProvider{
|
||||
oidcIssuerURL: config.OAuth2OIDCProviderIssuerURL,
|
||||
oidcCheckIssuerURL: config.OAuth2OIDCProviderCheckIssuerURL,
|
||||
redirectUrl: redirectUrl,
|
||||
oauth2ClientID: config.OAuth2ClientID,
|
||||
oauth2ClientSecret: config.OAuth2ClientSecret,
|
||||
oauth2Config: nil,
|
||||
}, nil
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
|
||||
// AvatarProviderContainer contains the current user avatar provider
|
||||
type AvatarProviderContainer struct {
|
||||
Current AvatarProvider
|
||||
current AvatarProvider
|
||||
}
|
||||
|
||||
// Initialize a user avatar provider container singleton instance
|
||||
@@ -20,13 +20,13 @@ var (
|
||||
// InitializeAvatarProvider initializes the current user avatar provider according to the config
|
||||
func InitializeAvatarProvider(config *settings.Config) error {
|
||||
if config.AvatarProvider == core.USER_AVATAR_PROVIDER_INTERNAL {
|
||||
Container.Current = NewInternalStorageAvatarProvider(config)
|
||||
Container.current = NewInternalStorageAvatarProvider(config)
|
||||
return nil
|
||||
} else if config.AvatarProvider == core.USER_AVATAR_PROVIDER_GRAVATAR {
|
||||
Container.Current = NewGravatarAvatarProvider()
|
||||
Container.current = NewGravatarAvatarProvider()
|
||||
return nil
|
||||
} else if config.AvatarProvider == "" {
|
||||
Container.Current = NewNullAvatarProvider()
|
||||
Container.current = NewNullAvatarProvider()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -35,5 +35,9 @@ func InitializeAvatarProvider(config *settings.Config) error {
|
||||
|
||||
// GetAvatarUrl returns the avatar url by the current user avatar provider
|
||||
func (p *AvatarProviderContainer) GetAvatarUrl(user *models.User) string {
|
||||
return p.Current.GetAvatarUrl(user)
|
||||
if p.current == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return p.current.GetAvatarUrl(user)
|
||||
}
|
||||
|
||||
+1
-1
@@ -9,5 +9,5 @@ type CliUsingConfig struct {
|
||||
|
||||
// CurrentConfig returns the current config
|
||||
func (l *CliUsingConfig) CurrentConfig() *settings.Config {
|
||||
return l.container.Current
|
||||
return l.container.GetCurrentConfig()
|
||||
}
|
||||
|
||||
+48
-7
@@ -91,7 +91,7 @@ func (l *UserDataCli) AddNewUser(c *core.CliContext, username string, email stri
|
||||
FeatureRestriction: l.CurrentConfig().DefaultFeatureRestrictions,
|
||||
}
|
||||
|
||||
err := l.users.CreateUser(c, user)
|
||||
err := l.users.CreateUser(c, user, false)
|
||||
|
||||
if err != nil {
|
||||
log.CliErrorf(c, "[user_data.AddNewUser] failed to create user \"%s\", because %s", user.Username, err.Error())
|
||||
@@ -405,7 +405,7 @@ func (l *UserDataCli) ListUserTokens(c *core.CliContext, username string) ([]*mo
|
||||
}
|
||||
|
||||
// CreateNewUserToken returns a new token for the specified user
|
||||
func (l *UserDataCli) CreateNewUserToken(c *core.CliContext, username string, tokenType string) (*models.TokenRecord, string, error) {
|
||||
func (l *UserDataCli) CreateNewUserToken(c *core.CliContext, username string, tokenType string, expiresInSeconds int64) (*models.TokenRecord, string, error) {
|
||||
if username == "" {
|
||||
log.CliErrorf(c, "[user_data.CreateNewUserToken] user name is empty")
|
||||
return nil, "", errs.ErrUsernameIsEmpty
|
||||
@@ -421,7 +421,17 @@ func (l *UserDataCli) CreateNewUserToken(c *core.CliContext, username string, to
|
||||
var token string
|
||||
var tokenRecord *models.TokenRecord
|
||||
|
||||
if tokenType == "mcp" {
|
||||
if tokenType == "api" {
|
||||
if !l.CurrentConfig().EnableAPIToken {
|
||||
return nil, "", errs.ErrAPITokenNotEnabled
|
||||
}
|
||||
|
||||
if user.FeatureRestriction.Contains(core.USER_FEATURE_RESTRICTION_TYPE_GENERATE_API_TOKEN) {
|
||||
return nil, "", errs.ErrNotPermittedToPerformThisAction
|
||||
}
|
||||
|
||||
token, tokenRecord, err = l.tokens.CreateAPITokenViaCli(c, user, expiresInSeconds)
|
||||
} else if tokenType == "mcp" {
|
||||
if !l.CurrentConfig().EnableMCPServer {
|
||||
return nil, "", errs.ErrMCPServerNotEnabled
|
||||
}
|
||||
@@ -430,9 +440,7 @@ func (l *UserDataCli) CreateNewUserToken(c *core.CliContext, username string, to
|
||||
return nil, "", errs.ErrNotPermittedToPerformThisAction
|
||||
}
|
||||
|
||||
token, tokenRecord, err = l.tokens.CreateMCPTokenViaCli(c, user)
|
||||
} else if tokenType == "normal" {
|
||||
token, tokenRecord, err = l.tokens.CreateTokenViaCli(c, user)
|
||||
token, tokenRecord, err = l.tokens.CreateMCPTokenViaCli(c, user, expiresInSeconds)
|
||||
} else {
|
||||
return nil, "", errs.ErrParameterInvalid
|
||||
}
|
||||
@@ -445,6 +453,39 @@ func (l *UserDataCli) CreateNewUserToken(c *core.CliContext, username string, to
|
||||
return tokenRecord, token, nil
|
||||
}
|
||||
|
||||
// RevokeUserToken revokes the specified token of the user
|
||||
func (l *UserDataCli) RevokeUserToken(c *core.CliContext, token string) error {
|
||||
_, claims, _, err := l.tokens.ParseToken(c, token)
|
||||
|
||||
if err != nil {
|
||||
log.CliErrorf(c, "[user_data.RevokeUserToken] failed to parse token, because %s", err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
userTokenId, err := utils.StringToInt64(claims.UserTokenId)
|
||||
|
||||
if err != nil {
|
||||
log.CliErrorf(c, "[user_data.RevokeUserToken] failed to get user token id, because %s", err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
tokenRecord := &models.TokenRecord{
|
||||
Uid: claims.Uid,
|
||||
UserTokenId: userTokenId,
|
||||
CreatedUnixTime: claims.IssuedAt,
|
||||
}
|
||||
|
||||
tokenId := l.tokens.GenerateTokenId(tokenRecord)
|
||||
err = l.tokens.DeleteToken(c, tokenRecord)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[user_data.RevokeUserToken] failed to revoke token \"id:%s\" for user \"uid:%d\", because %s", tokenId, claims.Uid, err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearUserTokens clears all tokens of the specified user
|
||||
func (l *UserDataCli) ClearUserTokens(c *core.CliContext, username string) error {
|
||||
if username == "" {
|
||||
@@ -924,7 +965,7 @@ func (l *UserDataCli) getUserEssentialDataForImport(c *core.CliContext, uid int6
|
||||
return nil, nil, nil, nil, nil, err
|
||||
}
|
||||
|
||||
tagMap = l.tags.GetTagNameMapByList(tags)
|
||||
tagMap = l.tags.GetVisibleTagNameMapByList(tags)
|
||||
|
||||
return accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap, nil
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ var (
|
||||
AlipayAppTransactionDataCsvFileImporter = &alipayAppTransactionDataCsvFileImporter{
|
||||
alipayTransactionDataCsvFileImporter{
|
||||
fileHeaderLine: "------------------------------------------------------------------------------------",
|
||||
dataHeaderStartContent: "支付宝(中国)网络技术有限公司 电子客户回单",
|
||||
dataHeaderStartContent: []string{"支付宝(中国)网络技术有限公司 电子客户回单", "支付宝支付科技有限公司 电子客户回单"},
|
||||
originalColumnNames: alipayTransactionColumnNames{
|
||||
timeColumnName: "交易时间",
|
||||
categoryColumnName: "交易分类",
|
||||
|
||||
@@ -2,21 +2,17 @@ package alipay
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/csv"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/text/encoding/simplifiedchinese"
|
||||
"golang.org/x/text/transform"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/converter"
|
||||
csvdatatable "github.com/mayswind/ezbookkeeping/pkg/converters/csv"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/csv"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
|
||||
"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"
|
||||
)
|
||||
|
||||
var alipayTransactionSupportedColumns = map[datatable.TransactionDataTableColumn]bool{
|
||||
@@ -51,7 +47,7 @@ type alipayTransactionColumnNames struct {
|
||||
// alipayTransactionDataCsvFileImporter defines the structure of alipay csv importer for transaction data
|
||||
type alipayTransactionDataCsvFileImporter struct {
|
||||
fileHeaderLine string
|
||||
dataHeaderStartContent string
|
||||
dataHeaderStartContent []string
|
||||
dataBottomEndLineRune rune
|
||||
originalColumnNames alipayTransactionColumnNames
|
||||
}
|
||||
@@ -61,7 +57,13 @@ func (c *alipayTransactionDataCsvFileImporter) ParseImportedData(ctx core.Contex
|
||||
enc := simplifiedchinese.GB18030
|
||||
reader := transform.NewReader(bytes.NewReader(data), enc.NewDecoder())
|
||||
|
||||
dataTable, err := c.createNewAlipayBasicDataTable(ctx, reader, c.fileHeaderLine, c.dataHeaderStartContent, c.dataBottomEndLineRune)
|
||||
csvDataTable, err := csv.CreateNewCsvBasicDataTable(ctx, reader, false)
|
||||
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, nil, nil, err
|
||||
}
|
||||
|
||||
dataTable, err := createNewAlipayTransactionBasicDataTable(ctx, csvDataTable, c.fileHeaderLine, c.dataHeaderStartContent, c.dataBottomEndLineRune)
|
||||
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, nil, nil, err
|
||||
@@ -83,80 +85,3 @@ func (c *alipayTransactionDataCsvFileImporter) ParseImportedData(ctx core.Contex
|
||||
|
||||
return dataTableImporter.ParseImportedData(ctx, user, transactionDataTable, defaultTimezoneOffset, accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap)
|
||||
}
|
||||
|
||||
func (c *alipayTransactionDataCsvFileImporter) createNewAlipayBasicDataTable(ctx core.Context, reader io.Reader, fileHeaderLine string, dataHeaderStartContent string, dataBottomEndLineRune rune) (datatable.BasicDataTable, error) {
|
||||
csvReader := csv.NewReader(reader)
|
||||
csvReader.FieldsPerRecord = -1
|
||||
|
||||
allOriginalLines := make([][]string, 0)
|
||||
hasFileHeader := false
|
||||
foundContentBeforeDataHeaderLine := false
|
||||
|
||||
for {
|
||||
items, err := csvReader.Read()
|
||||
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(ctx, "[alipay_transaction_data_csv_file_importer.createNewAlipayBasicDataTable] cannot parse alipay csv data, because %s", err.Error())
|
||||
return nil, errs.ErrInvalidCSVFile
|
||||
}
|
||||
|
||||
if !hasFileHeader {
|
||||
if len(items) <= 0 {
|
||||
continue
|
||||
} else if strings.Index(items[0], fileHeaderLine) == 0 {
|
||||
hasFileHeader = true
|
||||
continue
|
||||
} else {
|
||||
log.Warnf(ctx, "[alipay_transaction_data_csv_file_importer.createNewAlipayBasicDataTable] read unexpected line before read file header, line content is %s", strings.Join(items, ","))
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if !foundContentBeforeDataHeaderLine {
|
||||
if len(items) <= 0 {
|
||||
continue
|
||||
} else if strings.Index(items[0], dataHeaderStartContent) >= 0 {
|
||||
foundContentBeforeDataHeaderLine = true
|
||||
continue
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if foundContentBeforeDataHeaderLine {
|
||||
if len(items) <= 0 {
|
||||
continue
|
||||
} else if len(items) == 1 && dataBottomEndLineRune > 0 && utils.ContainsOnlyOneRune(items[0], dataBottomEndLineRune) {
|
||||
break
|
||||
}
|
||||
|
||||
for i := 0; i < len(items); i++ {
|
||||
items[i] = strings.Trim(items[i], " ")
|
||||
}
|
||||
|
||||
if len(allOriginalLines) > 0 && len(items) < len(allOriginalLines[0]) {
|
||||
log.Errorf(ctx, "[alipay_transaction_data_csv_file_importer.createNewAlipayBasicDataTable] cannot parse row \"index:%d\", because may missing some columns (column count %d in data row is less than header column count %d)", len(allOriginalLines), len(items), len(allOriginalLines[0]))
|
||||
return nil, errs.ErrFewerFieldsInDataRowThanInHeaderRow
|
||||
}
|
||||
|
||||
allOriginalLines = append(allOriginalLines, items)
|
||||
}
|
||||
}
|
||||
|
||||
if !hasFileHeader || !foundContentBeforeDataHeaderLine {
|
||||
return nil, errs.ErrInvalidFileHeader
|
||||
}
|
||||
|
||||
if len(allOriginalLines) < 2 {
|
||||
log.Errorf(ctx, "[alipay_transaction_data_csv_file_importer.createNewAlipayBasicDataTable] cannot parse import data, because data table row count is less 1")
|
||||
return nil, errs.ErrNotFoundTransactionDataInFile
|
||||
}
|
||||
|
||||
dataTable := csvdatatable.CreateNewCustomCsvBasicDataTable(allOriginalLines)
|
||||
|
||||
return dataTable, nil
|
||||
}
|
||||
|
||||
@@ -102,6 +102,7 @@ func TestAlipayCsvFileImporterParseImportedData_ParseRefundTransaction(t *testin
|
||||
DefaultCurrency: "CNY",
|
||||
}
|
||||
|
||||
// refund
|
||||
data1, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" +
|
||||
"账号:[xxx@xxx.xxx]\n" +
|
||||
"起始日期:[2024-01-01 00:00:00] 终止日期:[2024-09-01 23:59:59]\n" +
|
||||
@@ -121,6 +122,7 @@ func TestAlipayCsvFileImporterParseImportedData_ParseRefundTransaction(t *testin
|
||||
assert.Equal(t, "", allNewTransactions[0].OriginalSourceAccountName)
|
||||
assert.Equal(t, "", allNewTransactions[0].OriginalCategoryName)
|
||||
|
||||
// tax refund
|
||||
data2, err := simplifiedchinese.GB18030.NewEncoder().String("支付宝交易记录明细查询\n" +
|
||||
"账号:[xxx@xxx.xxx]\n" +
|
||||
"起始日期:[2024-01-01 00:00:00] 终止日期:[2024-09-01 23:59:59]\n" +
|
||||
@@ -141,6 +143,46 @@ func TestAlipayCsvFileImporterParseImportedData_ParseRefundTransaction(t *testin
|
||||
assert.Equal(t, "", allNewTransactions[0].OriginalCategoryName)
|
||||
}
|
||||
|
||||
func TestAlipayCsvFileImporterParseImportedData_ParseInvestmentRefundTransaction(t *testing.T) {
|
||||
converter := AlipayAppTransactionDataCsvFileImporter
|
||||
context := core.NewNullContext()
|
||||
|
||||
user := &models.User{
|
||||
Uid: 1234567890,
|
||||
DefaultCurrency: "CNY",
|
||||
}
|
||||
|
||||
data1, err := simplifiedchinese.GB18030.NewEncoder().String("------------------------------------------------------------------------------------\n" +
|
||||
"导出信息:\n" +
|
||||
"姓名:xxx\n" +
|
||||
"支付宝账户:xxx@xxx.xxx\n" +
|
||||
"起始时间:[2024-01-01 00:00:00] 终止时间:[2024-09-01 23:59:59]\n" +
|
||||
"导出交易类型:[全部]\n" +
|
||||
"------------------------支付宝(中国)网络技术有限公司 电子客户回单------------------------\n" +
|
||||
"交易时间,交易对方,商品说明,收/支,金额,收/付款方式,交易状态,\n" +
|
||||
"2024-09-01 01:00:00,Test Account2,xxx-买入,不计收支,0.01,Test Account,退款成功,\n" +
|
||||
"2024-09-01 02:00:00,Test Account2,xxx-买入退款,不计收支,0.01,Test Account,退款成功,\n")
|
||||
|
||||
allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(data1), 0, nil, nil, nil, nil, nil)
|
||||
assert.Nil(t, err)
|
||||
|
||||
assert.Equal(t, 2, len(allNewTransactions))
|
||||
|
||||
assert.Equal(t, int64(1234567890), allNewTransactions[0].Uid)
|
||||
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[0].Type)
|
||||
assert.Equal(t, "2024-09-01 01:00:00", utils.FormatUnixTimeToLongDateTime(utils.GetUnixTimeFromTransactionTime(allNewTransactions[0].TransactionTime), time.UTC))
|
||||
assert.Equal(t, int64(1), allNewTransactions[0].Amount)
|
||||
assert.Equal(t, "Test Account", allNewTransactions[0].OriginalSourceAccountName)
|
||||
assert.Equal(t, "Test Account2", allNewTransactions[0].OriginalDestinationAccountName)
|
||||
|
||||
assert.Equal(t, int64(1234567890), allNewTransactions[1].Uid)
|
||||
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[1].Type)
|
||||
assert.Equal(t, "2024-09-01 02:00:00", utils.FormatUnixTimeToLongDateTime(utils.GetUnixTimeFromTransactionTime(allNewTransactions[1].TransactionTime), time.UTC))
|
||||
assert.Equal(t, int64(1), allNewTransactions[1].Amount)
|
||||
assert.Equal(t, "Test Account2", allNewTransactions[1].OriginalSourceAccountName)
|
||||
assert.Equal(t, "Test Account", allNewTransactions[1].OriginalDestinationAccountName)
|
||||
}
|
||||
|
||||
func TestAlipayCsvFileImporterParseImportedData_ParseInvalidTime(t *testing.T) {
|
||||
converter := AlipayWebTransactionDataCsvFileImporter
|
||||
context := core.NewNullContext()
|
||||
@@ -380,38 +422,110 @@ func TestAlipayCsvFileImporterParseImportedData_ParseRelatedAccount(t *testing.T
|
||||
"起始时间:[2024-01-01 00:00:00] 终止时间:[2024-09-01 23:59:59]\n" +
|
||||
"导出交易类型:[全部]\n" +
|
||||
"------------------------支付宝(中国)网络技术有限公司 电子客户回单------------------------\n" +
|
||||
"交易时间,商品说明,收/支,金额,收/付款方式,交易状态,\n" +
|
||||
"2024-09-01 03:45:07,余额宝-单次转入,不计收支,0.01,Test Account,交易成功,\n" +
|
||||
"2024-09-01 05:07:29,信用卡还款,不计收支,0.02,Test Account2,交易成功,\n")
|
||||
"交易时间,交易对方,商品说明,收/支,金额,收/付款方式,交易状态,备注,\n" +
|
||||
"2024-09-01 00:00:00,xxx,xxx-收益发放,不计收支,0.01,Test Account,交易成功,earning,\n" +
|
||||
"2024-09-01 01:00:00,Test Account2,xxx-买入,不计收支,0.01,Test Account,交易成功,purchase investment,\n" +
|
||||
"2024-09-01 02:00:00,Test Account2,xxx-卖出至xxx,不计收支,0.01,Test Account,交易成功,sell investment,\n" +
|
||||
"2024-09-01 03:00:00,xxx,充值-普通充值,不计收支,0.01,Test Account,交易成功,transfer to alipay wallet,\n" +
|
||||
"2024-09-01 04:00:00,Test Account3,提现-实时提现,不计收支,0.01,Test Account,交易成功,transfer from alipay wallet,\n" +
|
||||
"2024-09-01 05:00:00,Test Account3,xxx-单次转入,不计收支,0.01,Test Account,交易成功,transfer in,\n" +
|
||||
"2024-09-01 06:00:00,Test Account3,xxx-转出到银行卡,不计收支,0.01,Test Account,交易成功,transfer out,\n" +
|
||||
"2024-09-01 07:00:00,Test Account3,转账xxx,不计收支,0.01,Test Account,交易成功,transfer,\n" +
|
||||
"2024-09-01 08:00:00,Test Account4,信用卡还款,不计收支,0.01,Test Account,还款成功,repayment,\n")
|
||||
assert.Nil(t, err)
|
||||
|
||||
allNewTransactions, allNewAccounts, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(data1), 0, nil, nil, nil, nil, nil)
|
||||
assert.Nil(t, err)
|
||||
|
||||
assert.Equal(t, 2, len(allNewTransactions))
|
||||
assert.Equal(t, 3, len(allNewAccounts))
|
||||
assert.Equal(t, 9, len(allNewTransactions))
|
||||
assert.Equal(t, 6, len(allNewAccounts))
|
||||
|
||||
assert.Equal(t, models.TRANSACTION_DB_TYPE_INCOME, allNewTransactions[0].Type)
|
||||
assert.Equal(t, int64(1234567890), allNewTransactions[0].Uid)
|
||||
assert.Equal(t, int64(1), allNewTransactions[0].Amount)
|
||||
assert.Equal(t, "Test Account", allNewTransactions[0].OriginalSourceAccountName)
|
||||
assert.Equal(t, "", allNewTransactions[0].OriginalDestinationAccountName)
|
||||
assert.Equal(t, "earning", allNewTransactions[0].Comment)
|
||||
|
||||
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[1].Type)
|
||||
assert.Equal(t, int64(1234567890), allNewTransactions[1].Uid)
|
||||
assert.Equal(t, int64(2), allNewTransactions[1].Amount)
|
||||
assert.Equal(t, "Test Account2", allNewTransactions[1].OriginalSourceAccountName)
|
||||
assert.Equal(t, "", allNewTransactions[1].OriginalDestinationAccountName)
|
||||
assert.Equal(t, int64(1), allNewTransactions[1].Amount)
|
||||
assert.Equal(t, "Test Account", allNewTransactions[1].OriginalSourceAccountName)
|
||||
assert.Equal(t, "Test Account2", allNewTransactions[1].OriginalDestinationAccountName)
|
||||
assert.Equal(t, "purchase investment", allNewTransactions[1].Comment)
|
||||
|
||||
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[2].Type)
|
||||
assert.Equal(t, int64(1234567890), allNewTransactions[2].Uid)
|
||||
assert.Equal(t, int64(1), allNewTransactions[2].Amount)
|
||||
assert.Equal(t, "Test Account2", allNewTransactions[2].OriginalSourceAccountName)
|
||||
assert.Equal(t, "Test Account", allNewTransactions[2].OriginalDestinationAccountName)
|
||||
assert.Equal(t, "sell investment", allNewTransactions[2].Comment)
|
||||
|
||||
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[3].Type)
|
||||
assert.Equal(t, int64(1234567890), allNewTransactions[3].Uid)
|
||||
assert.Equal(t, int64(1), allNewTransactions[3].Amount)
|
||||
assert.Equal(t, "", allNewTransactions[3].OriginalSourceAccountName)
|
||||
assert.Equal(t, "Alipay", allNewTransactions[3].OriginalDestinationAccountName)
|
||||
assert.Equal(t, "transfer to alipay wallet", allNewTransactions[3].Comment)
|
||||
|
||||
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[4].Type)
|
||||
assert.Equal(t, int64(1234567890), allNewTransactions[4].Uid)
|
||||
assert.Equal(t, int64(1), allNewTransactions[4].Amount)
|
||||
assert.Equal(t, "Alipay", allNewTransactions[4].OriginalSourceAccountName)
|
||||
assert.Equal(t, "Test Account3", allNewTransactions[4].OriginalDestinationAccountName)
|
||||
assert.Equal(t, "transfer from alipay wallet", allNewTransactions[4].Comment)
|
||||
|
||||
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[5].Type)
|
||||
assert.Equal(t, int64(1234567890), allNewTransactions[5].Uid)
|
||||
assert.Equal(t, int64(1), allNewTransactions[5].Amount)
|
||||
assert.Equal(t, "Test Account", allNewTransactions[5].OriginalSourceAccountName)
|
||||
assert.Equal(t, "Test Account3", allNewTransactions[5].OriginalDestinationAccountName)
|
||||
assert.Equal(t, "transfer in", allNewTransactions[5].Comment)
|
||||
|
||||
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[6].Type)
|
||||
assert.Equal(t, int64(1234567890), allNewTransactions[6].Uid)
|
||||
assert.Equal(t, int64(1), allNewTransactions[6].Amount)
|
||||
assert.Equal(t, "Test Account", allNewTransactions[6].OriginalSourceAccountName)
|
||||
assert.Equal(t, "Test Account3", allNewTransactions[6].OriginalDestinationAccountName)
|
||||
assert.Equal(t, "transfer out", allNewTransactions[6].Comment)
|
||||
|
||||
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[7].Type)
|
||||
assert.Equal(t, int64(1234567890), allNewTransactions[7].Uid)
|
||||
assert.Equal(t, int64(1), allNewTransactions[7].Amount)
|
||||
assert.Equal(t, "Test Account", allNewTransactions[7].OriginalSourceAccountName)
|
||||
assert.Equal(t, "Test Account3", allNewTransactions[7].OriginalDestinationAccountName)
|
||||
assert.Equal(t, "transfer", allNewTransactions[7].Comment)
|
||||
|
||||
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[8].Type)
|
||||
assert.Equal(t, int64(1234567890), allNewTransactions[8].Uid)
|
||||
assert.Equal(t, int64(1), allNewTransactions[8].Amount)
|
||||
assert.Equal(t, "Test Account", allNewTransactions[8].OriginalSourceAccountName)
|
||||
assert.Equal(t, "Test Account4", allNewTransactions[8].OriginalDestinationAccountName)
|
||||
assert.Equal(t, "repayment", allNewTransactions[8].Comment)
|
||||
|
||||
assert.Equal(t, int64(1234567890), allNewAccounts[0].Uid)
|
||||
assert.Equal(t, "Test Account", allNewAccounts[0].Name)
|
||||
assert.Equal(t, "CNY", allNewAccounts[0].Currency)
|
||||
|
||||
assert.Equal(t, int64(1234567890), allNewAccounts[1].Uid)
|
||||
assert.Equal(t, "", allNewAccounts[1].Name)
|
||||
assert.Equal(t, "Test Account2", allNewAccounts[1].Name)
|
||||
assert.Equal(t, "CNY", allNewAccounts[1].Currency)
|
||||
|
||||
assert.Equal(t, int64(1234567890), allNewAccounts[2].Uid)
|
||||
assert.Equal(t, "Test Account2", allNewAccounts[2].Name)
|
||||
assert.Equal(t, "", allNewAccounts[2].Name)
|
||||
assert.Equal(t, "CNY", allNewAccounts[2].Currency)
|
||||
|
||||
assert.Equal(t, int64(1234567890), allNewAccounts[3].Uid)
|
||||
assert.Equal(t, "Alipay", allNewAccounts[3].Name)
|
||||
assert.Equal(t, "CNY", allNewAccounts[3].Currency)
|
||||
|
||||
assert.Equal(t, int64(1234567890), allNewAccounts[4].Uid)
|
||||
assert.Equal(t, "Test Account3", allNewAccounts[4].Name)
|
||||
assert.Equal(t, "CNY", allNewAccounts[4].Currency)
|
||||
|
||||
assert.Equal(t, int64(1234567890), allNewAccounts[5].Uid)
|
||||
assert.Equal(t, "Test Account4", allNewAccounts[5].Name)
|
||||
assert.Equal(t, "CNY", allNewAccounts[5].Currency)
|
||||
}
|
||||
|
||||
func TestAlipayCsvFileImporterParseImportedData_ParseDescription(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package alipay
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/csv"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||
)
|
||||
|
||||
func createNewAlipayTransactionBasicDataTable(ctx core.Context, originalDataTable datatable.BasicDataTable, fileHeaderLine string, dataHeaderStartContent []string, dataBottomEndLineRune rune) (datatable.BasicDataTable, error) {
|
||||
iterator := originalDataTable.DataRowIterator()
|
||||
allOriginalLines := make([][]string, 0)
|
||||
hasFileHeader := false
|
||||
foundContentBeforeDataHeaderLine := false
|
||||
|
||||
for iterator.HasNext() {
|
||||
row := iterator.Next()
|
||||
|
||||
if !hasFileHeader {
|
||||
if row.ColumnCount() <= 0 {
|
||||
continue
|
||||
} else if strings.Index(row.GetData(0), fileHeaderLine) == 0 {
|
||||
hasFileHeader = true
|
||||
continue
|
||||
} else {
|
||||
log.Warnf(ctx, "[alipay_transaction_data_extrator.createNewAlipayTransactionBasicDataTable] read unexpected line in row \"%s\" before read file header", iterator.CurrentRowId())
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if !foundContentBeforeDataHeaderLine {
|
||||
if row.ColumnCount() <= 0 {
|
||||
continue
|
||||
} else if utils.ContainsAnyString(row.GetData(0), dataHeaderStartContent) {
|
||||
foundContentBeforeDataHeaderLine = true
|
||||
continue
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if foundContentBeforeDataHeaderLine {
|
||||
if row.ColumnCount() <= 0 {
|
||||
continue
|
||||
} else if row.ColumnCount() == 1 && dataBottomEndLineRune > 0 && utils.ContainsOnlyOneRune(row.GetData(0), dataBottomEndLineRune) {
|
||||
break
|
||||
}
|
||||
|
||||
items := make([]string, row.ColumnCount())
|
||||
|
||||
for i := 0; i < row.ColumnCount(); i++ {
|
||||
items[i] = strings.Trim(row.GetData(i), " ")
|
||||
}
|
||||
|
||||
if len(allOriginalLines) > 0 && len(items) < len(allOriginalLines[0]) {
|
||||
log.Errorf(ctx, "[alipay_transaction_data_extrator.createNewAlipayTransactionBasicDataTable] cannot parse row \"%s\", because may missing some columns (column count %d in data row is less than header column count %d)", iterator.CurrentRowId(), len(items), len(allOriginalLines[0]))
|
||||
return nil, errs.ErrFewerFieldsInDataRowThanInHeaderRow
|
||||
}
|
||||
|
||||
allOriginalLines = append(allOriginalLines, items)
|
||||
}
|
||||
}
|
||||
|
||||
if !hasFileHeader || !foundContentBeforeDataHeaderLine {
|
||||
return nil, errs.ErrInvalidFileHeader
|
||||
}
|
||||
|
||||
if len(allOriginalLines) < 2 {
|
||||
log.Errorf(ctx, "[alipay_transaction_data_extrator.createNewAlipayTransactionBasicDataTable] cannot parse import data, because data table row count is less 1")
|
||||
return nil, errs.ErrNotFoundTransactionDataInFile
|
||||
}
|
||||
|
||||
return csv.CreateNewCustomCsvBasicDataTable(allOriginalLines, true), nil
|
||||
}
|
||||
@@ -18,10 +18,15 @@ const alipayTransactionDataStatusClosedName = "交易关闭"
|
||||
const alipayTransactionDataStatusRefundSuccessName = "退款成功"
|
||||
const alipayTransactionDataStatusTaxRefundSuccessName = "退税成功"
|
||||
|
||||
const alipayTransactionDataProductNameEarningText = "-收益发放"
|
||||
const alipayTransactionDataProductNamePurchaseInvestmentText = "-买入"
|
||||
const alipayTransactionDataProductNamePurchaseInvestmentRefundText = "-买入退款"
|
||||
const alipayTransactionDataProductNameSellInvestmentRefundText = "-卖出"
|
||||
const alipayTransactionDataProductNameTransferToAlipayPrefix = "充值-"
|
||||
const alipayTransactionDataProductNameTransferFromAlipayPrefix = "提现-"
|
||||
const alipayTransactionDataProductNameTransferInText = "转入"
|
||||
const alipayTransactionDataProductNameTransferOutText = "转出"
|
||||
const alipayTransactionDataProductNameTransferText = "转账"
|
||||
const alipayTransactionDataProductNameRepaymentText = "还款"
|
||||
|
||||
// alipayTransactionDataRowParser defines the structure of alipay transaction data row parser
|
||||
@@ -127,11 +132,29 @@ func (p *alipayTransactionDataRowParser) Parse(ctx core.Context, user *models.Us
|
||||
}
|
||||
|
||||
if statusName == alipayTransactionDataStatusRefundSuccessName {
|
||||
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = alipayTransactionTypeNameMapping[models.TRANSACTION_TYPE_INCOME]
|
||||
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = relatedAccountName
|
||||
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = ""
|
||||
if len(productName) > len(alipayTransactionDataProductNamePurchaseInvestmentText) && strings.Index(productName, alipayTransactionDataProductNamePurchaseInvestmentText) == len(productName)-len(alipayTransactionDataProductNamePurchaseInvestmentText) { // purchase investment
|
||||
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = relatedAccountName
|
||||
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = targetName
|
||||
} else if len(productName) > len(alipayTransactionDataProductNamePurchaseInvestmentRefundText) && strings.Index(productName, alipayTransactionDataProductNamePurchaseInvestmentRefundText) == len(productName)-len(alipayTransactionDataProductNamePurchaseInvestmentRefundText) { // purchase investment refund
|
||||
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = targetName
|
||||
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = relatedAccountName
|
||||
} else {
|
||||
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = alipayTransactionTypeNameMapping[models.TRANSACTION_TYPE_INCOME]
|
||||
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = relatedAccountName
|
||||
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = ""
|
||||
}
|
||||
} else {
|
||||
if strings.Index(productName, alipayTransactionDataProductNameTransferToAlipayPrefix) == 0 { // transfer to alipay wallet
|
||||
if len(productName) > len(alipayTransactionDataProductNameEarningText) && strings.Index(productName, alipayTransactionDataProductNameEarningText) == len(productName)-len(alipayTransactionDataProductNameEarningText) { // earning
|
||||
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = alipayTransactionTypeNameMapping[models.TRANSACTION_TYPE_INCOME]
|
||||
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = relatedAccountName
|
||||
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = targetName
|
||||
} else if len(productName) > len(alipayTransactionDataProductNamePurchaseInvestmentText) && strings.Index(productName, alipayTransactionDataProductNamePurchaseInvestmentText) == len(productName)-len(alipayTransactionDataProductNamePurchaseInvestmentText) { // purchase investment
|
||||
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = relatedAccountName
|
||||
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = targetName
|
||||
} else if strings.Index(productName, alipayTransactionDataProductNameSellInvestmentRefundText) >= 0 { // sell investment
|
||||
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = targetName
|
||||
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = relatedAccountName
|
||||
} else if strings.Index(productName, alipayTransactionDataProductNameTransferToAlipayPrefix) == 0 { // transfer to alipay wallet
|
||||
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = ""
|
||||
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = localeTextItems.DataConverterTextItems.Alipay
|
||||
} else if strings.Index(productName, alipayTransactionDataProductNameTransferFromAlipayPrefix) == 0 { // transfer from alipay wallet
|
||||
@@ -143,6 +166,9 @@ func (p *alipayTransactionDataRowParser) Parse(ctx core.Context, user *models.Us
|
||||
} else if strings.Index(productName, alipayTransactionDataProductNameTransferOutText) >= 0 { // transfer out
|
||||
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = relatedAccountName
|
||||
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = targetName
|
||||
} else if strings.Index(productName, alipayTransactionDataProductNameTransferText) >= 0 { // transfer
|
||||
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = relatedAccountName
|
||||
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = targetName
|
||||
} else if strings.Index(productName, alipayTransactionDataProductNameRepaymentText) >= 0 { // repayment
|
||||
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = relatedAccountName
|
||||
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = targetName
|
||||
|
||||
@@ -10,7 +10,7 @@ var (
|
||||
AlipayWebTransactionDataCsvFileImporter = &alipayWebTransactionDataCsvFileImporter{
|
||||
alipayTransactionDataCsvFileImporter{
|
||||
fileHeaderLine: "支付宝交易记录明细查询",
|
||||
dataHeaderStartContent: "交易记录明细列表",
|
||||
dataHeaderStartContent: []string{"交易记录明细列表"},
|
||||
dataBottomEndLineRune: '-',
|
||||
originalColumnNames: alipayTransactionColumnNames{
|
||||
timeColumnName: "交易创建时间",
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
package beancount
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"math/big"
|
||||
"strings"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||
)
|
||||
|
||||
const maxAllowedDecimalCount = 6
|
||||
const normalizeFactor = int64(1000000)
|
||||
const normalizedDecimalsMaxZeroString = "000000"
|
||||
const normalizedNumberToAmountFactor = int64(10000) // 1000000 / 100
|
||||
|
||||
var operatorPriority = map[rune]int{
|
||||
'+': 1,
|
||||
'-': 1,
|
||||
@@ -17,6 +22,44 @@ var operatorPriority = map[rune]int{
|
||||
'/': 2,
|
||||
}
|
||||
|
||||
func normalizeNumber(textualNumber string) (*big.Int, error) {
|
||||
decimalSeparatorPos := strings.Index(textualNumber, ".")
|
||||
|
||||
if decimalSeparatorPos < 0 {
|
||||
result := big.NewInt(0)
|
||||
_, ok := result.SetString(textualNumber+normalizedDecimalsMaxZeroString, 10)
|
||||
|
||||
if !ok {
|
||||
return nil, errs.ErrAmountInvalid
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
integer := utils.SubString(textualNumber, 0, decimalSeparatorPos)
|
||||
decimals := utils.SubString(textualNumber, decimalSeparatorPos+1, len(textualNumber))
|
||||
|
||||
if len(decimals) > maxAllowedDecimalCount {
|
||||
return nil, errs.ErrAmountInvalid
|
||||
}
|
||||
|
||||
paddedDecimals := utils.SubString(decimals+normalizedDecimalsMaxZeroString, 0, maxAllowedDecimalCount)
|
||||
result := big.NewInt(0)
|
||||
_, ok := result.SetString(integer+paddedDecimals, 10)
|
||||
|
||||
if !ok {
|
||||
return nil, errs.ErrAmountInvalid
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func denormalizeNumberToTextualAmount(num *big.Int) string {
|
||||
result := big.NewInt(0).Add(num, big.NewInt(0)) // make a copy of num
|
||||
result = result.Div(result, big.NewInt(normalizedNumberToAmountFactor))
|
||||
return utils.FormatAmount(result.Int64())
|
||||
}
|
||||
|
||||
func toPostfixExprTokens(ctx core.Context, expr string) ([]string, error) {
|
||||
finalTokens := make([]string, 0)
|
||||
operatorStack := make([]rune, 0)
|
||||
@@ -117,8 +160,8 @@ func toPostfixExprTokens(ctx core.Context, expr string) ([]string, error) {
|
||||
return finalTokens, nil
|
||||
}
|
||||
|
||||
func evaluatePostfixExpr(ctx core.Context, tokens []string) (float64, error) {
|
||||
stack := make([]float64, 0)
|
||||
func evaluatePostfixExpr(ctx core.Context, tokens []string) (*big.Int, error) {
|
||||
stack := make([]*big.Int, 0)
|
||||
|
||||
for i := 0; i < len(tokens); i++ {
|
||||
token := tokens[i]
|
||||
@@ -127,7 +170,7 @@ func evaluatePostfixExpr(ctx core.Context, tokens []string) (float64, error) {
|
||||
case "+", "-", "*", "/": // operators
|
||||
if len(stack) < 2 {
|
||||
log.Warnf(ctx, "[beancount_amount_expression_evaluator.evaluatePostfixExpr] cannot evaluate expression \"%s\", because not enough operands", strings.Join(tokens, " "))
|
||||
return 0, errs.ErrInvalidAmountExpression
|
||||
return nil, errs.ErrInvalidAmountExpression
|
||||
}
|
||||
|
||||
// pop the top two operands
|
||||
@@ -138,39 +181,41 @@ func evaluatePostfixExpr(ctx core.Context, tokens []string) (float64, error) {
|
||||
stack = stack[:len(stack)-1]
|
||||
|
||||
// evaluate the operation
|
||||
var result float64
|
||||
result := big.NewInt(0)
|
||||
switch token {
|
||||
case "+":
|
||||
result = a + b
|
||||
result.Add(a, b)
|
||||
case "-":
|
||||
result = a - b
|
||||
result.Sub(a, b)
|
||||
case "*":
|
||||
result = a * b
|
||||
result.Mul(a, b)
|
||||
result.Div(result, big.NewInt(normalizeFactor))
|
||||
case "/":
|
||||
if b == 0 {
|
||||
if b.Int64() == 0 {
|
||||
log.Warnf(ctx, "[beancount_amount_expression_evaluator.evaluatePostfixExpr] cannot evaluate expression \"%s\", because division by zero", strings.Join(tokens, " "))
|
||||
return 0, errs.ErrInvalidAmountExpression
|
||||
return nil, errs.ErrInvalidAmountExpression
|
||||
}
|
||||
result = a / b
|
||||
result.Mul(a, big.NewInt(normalizeFactor))
|
||||
result.Div(result, b)
|
||||
}
|
||||
|
||||
// push the result back to the stack
|
||||
stack = append(stack, result)
|
||||
default: // operands
|
||||
num, err := strconv.ParseFloat(token, 64)
|
||||
normalizedNum, err := normalizeNumber(token)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(ctx, "[beancount_amount_expression_evaluator.evaluatePostfixExpr] cannot evaluate expression \"%s\", because containing invalid number", strings.Join(tokens, " "))
|
||||
return 0, errs.ErrInvalidAmountExpression
|
||||
return nil, errs.ErrInvalidAmountExpression
|
||||
}
|
||||
|
||||
stack = append(stack, num)
|
||||
stack = append(stack, normalizedNum)
|
||||
}
|
||||
}
|
||||
|
||||
if len(stack) != 1 {
|
||||
log.Warnf(ctx, "[beancount_amount_expression_evaluator.evaluatePostfixExpr] cannot evaluate expression \"%s\", because missing operator", strings.Join(tokens, " "))
|
||||
return 0, errs.ErrInvalidAmountExpression
|
||||
return nil, errs.ErrInvalidAmountExpression
|
||||
}
|
||||
|
||||
return stack[0], nil
|
||||
@@ -193,5 +238,5 @@ func evaluateBeancountAmountExpression(ctx core.Context, expr string) (string, e
|
||||
return "", err
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%.2f", result), nil
|
||||
return denormalizeNumberToTextualAmount(result), nil
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package beancount
|
||||
|
||||
import (
|
||||
"math/big"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -97,23 +98,23 @@ func TestEvaluatePostfixExpr_ValidExpression(t *testing.T) {
|
||||
|
||||
result, err := evaluatePostfixExpr(context, []string{"1", "2", "+"})
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, float64(3), result)
|
||||
assert.Equal(t, big.NewInt(3000000), result)
|
||||
|
||||
result, err = evaluatePostfixExpr(context, []string{"5", "3", "-"})
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, float64(2), result)
|
||||
assert.Equal(t, big.NewInt(2000000), result)
|
||||
|
||||
result, err = evaluatePostfixExpr(context, []string{"4", "3", "*"})
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, float64(12), result)
|
||||
assert.Equal(t, big.NewInt(12000000), result)
|
||||
|
||||
result, err = evaluatePostfixExpr(context, []string{"6", "2", "/"})
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, float64(3), result)
|
||||
assert.Equal(t, big.NewInt(3000000), result)
|
||||
|
||||
result, err = evaluatePostfixExpr(context, []string{"1", "2", "3", "*", "+", "4", "2", "/", "-"})
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, float64(5), result)
|
||||
assert.Equal(t, big.NewInt(5000000), result)
|
||||
}
|
||||
|
||||
func TestEvaluatePostfixExpr_InvalidExpression(t *testing.T) {
|
||||
@@ -179,6 +180,18 @@ func TestEvaluateBeancountAmountExpression_ValidExpression(t *testing.T) {
|
||||
result, err = evaluateBeancountAmountExpression(context, "(((2+3)))*(((((-5+7)))))")
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, "10.00", result)
|
||||
|
||||
result, err = evaluateBeancountAmountExpression(context, "3.5+0.1")
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, "3.60", result)
|
||||
|
||||
result, err = evaluateBeancountAmountExpression(context, "3.55+0.11")
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, "3.66", result)
|
||||
|
||||
result, err = evaluateBeancountAmountExpression(context, "3.555+0.111")
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, "3.66", result)
|
||||
}
|
||||
|
||||
func TestEvaluateBeancountAmountExpression_InvalidExpression(t *testing.T) {
|
||||
@@ -213,4 +226,10 @@ func TestEvaluateBeancountAmountExpression_InvalidExpression(t *testing.T) {
|
||||
|
||||
_, err = evaluateBeancountAmountExpression(context, "1)*(2")
|
||||
assert.Equal(t, errs.ErrInvalidAmountExpression, err)
|
||||
|
||||
_, err = evaluateBeancountAmountExpression(context, "0.abcd+1")
|
||||
assert.Equal(t, errs.ErrInvalidAmountExpression, err)
|
||||
|
||||
_, err = evaluateBeancountAmountExpression(context, "0.1234567+1")
|
||||
assert.Equal(t, errs.ErrInvalidAmountExpression, err)
|
||||
}
|
||||
|
||||
@@ -13,7 +13,8 @@ import (
|
||||
|
||||
// CsvFileBasicDataTable defines the structure of csv data table
|
||||
type CsvFileBasicDataTable struct {
|
||||
allLines [][]string
|
||||
allLines [][]string
|
||||
hasTitleLine bool
|
||||
}
|
||||
|
||||
// CsvFileBasicDataTableRow defines the structure of csv data table row
|
||||
@@ -34,7 +35,11 @@ func (t *CsvFileBasicDataTable) DataRowCount() int {
|
||||
return 0
|
||||
}
|
||||
|
||||
return len(t.allLines) - 1
|
||||
if t.hasTitleLine {
|
||||
return len(t.allLines) - 1
|
||||
} else {
|
||||
return len(t.allLines)
|
||||
}
|
||||
}
|
||||
|
||||
// HeaderColumnNames returns the header column name list
|
||||
@@ -43,14 +48,24 @@ func (t *CsvFileBasicDataTable) HeaderColumnNames() []string {
|
||||
return nil
|
||||
}
|
||||
|
||||
return t.allLines[0]
|
||||
if t.hasTitleLine {
|
||||
return t.allLines[0]
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// DataRowIterator returns the iterator of data row
|
||||
func (t *CsvFileBasicDataTable) DataRowIterator() datatable.BasicDataTableRowIterator {
|
||||
startIndex := -1
|
||||
|
||||
if t.hasTitleLine {
|
||||
startIndex = 0
|
||||
}
|
||||
|
||||
return &CsvFileBasicDataTableRowIterator{
|
||||
dataTable: t,
|
||||
currentIndex: 0,
|
||||
currentIndex: startIndex,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,18 +110,19 @@ func (t *CsvFileBasicDataTableRowIterator) Next() datatable.BasicDataTableRow {
|
||||
}
|
||||
|
||||
// CreateNewCsvBasicDataTable returns comma separated values data table by io readers
|
||||
func CreateNewCsvBasicDataTable(ctx core.Context, reader io.Reader) (datatable.BasicDataTable, error) {
|
||||
return createNewCsvFileBasicDataTable(ctx, reader, ',')
|
||||
func CreateNewCsvBasicDataTable(ctx core.Context, reader io.Reader, hasTitleLine bool) (datatable.BasicDataTable, error) {
|
||||
return createNewCsvFileBasicDataTable(ctx, reader, ',', hasTitleLine)
|
||||
}
|
||||
|
||||
// CreateNewCustomCsvBasicDataTable returns character separated values data table by io readers
|
||||
func CreateNewCustomCsvBasicDataTable(allLines [][]string) datatable.BasicDataTable {
|
||||
func CreateNewCustomCsvBasicDataTable(allLines [][]string, hasTitleLine bool) datatable.BasicDataTable {
|
||||
return &CsvFileBasicDataTable{
|
||||
allLines: allLines,
|
||||
allLines: allLines,
|
||||
hasTitleLine: hasTitleLine,
|
||||
}
|
||||
}
|
||||
|
||||
func createNewCsvFileBasicDataTable(ctx core.Context, reader io.Reader, separator rune) (*CsvFileBasicDataTable, error) {
|
||||
func createNewCsvFileBasicDataTable(ctx core.Context, reader io.Reader, separator rune, hasTitleLine bool) (*CsvFileBasicDataTable, error) {
|
||||
csvReader := csv.NewReader(reader)
|
||||
csvReader.Comma = separator
|
||||
csvReader.FieldsPerRecord = -1
|
||||
@@ -133,6 +149,7 @@ func createNewCsvFileBasicDataTable(ctx core.Context, reader io.Reader, separato
|
||||
}
|
||||
|
||||
return &CsvFileBasicDataTable{
|
||||
allLines: allLines,
|
||||
allLines: allLines,
|
||||
hasTitleLine: hasTitleLine,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -14,7 +14,17 @@ func TestCsvFileBasicDataTableDataRowCount(t *testing.T) {
|
||||
{"A1", "B1", "C1"},
|
||||
{"A2", "B2", "C2"},
|
||||
{"A3", "B3", "C3"},
|
||||
})
|
||||
}, false)
|
||||
|
||||
assert.Equal(t, 3, datatable.DataRowCount())
|
||||
}
|
||||
|
||||
func TestCsvFileBasicDataTableDataRowCount_HasTitleLine(t *testing.T) {
|
||||
datatable := CreateNewCustomCsvBasicDataTable([][]string{
|
||||
{"A1", "B1", "C1"},
|
||||
{"A2", "B2", "C2"},
|
||||
{"A3", "B3", "C3"},
|
||||
}, true)
|
||||
|
||||
assert.Equal(t, 2, datatable.DataRowCount())
|
||||
}
|
||||
@@ -22,14 +32,16 @@ func TestCsvFileBasicDataTableDataRowCount(t *testing.T) {
|
||||
func TestCsvFileBasicDataTableDataRowCount_OnlyHeaderLine(t *testing.T) {
|
||||
datatable := CreateNewCustomCsvBasicDataTable([][]string{
|
||||
{"A1", "B1", "C1"},
|
||||
})
|
||||
}, true)
|
||||
|
||||
assert.Equal(t, 0, datatable.DataRowCount())
|
||||
}
|
||||
|
||||
func TestCsvFileBasicDataTableDataRowCount_EmptyContent(t *testing.T) {
|
||||
datatable := CreateNewCustomCsvBasicDataTable([][]string{})
|
||||
datatable := CreateNewCustomCsvBasicDataTable([][]string{}, false)
|
||||
assert.Equal(t, 0, datatable.DataRowCount())
|
||||
|
||||
datatable = CreateNewCustomCsvBasicDataTable([][]string{}, true)
|
||||
assert.Equal(t, 0, datatable.DataRowCount())
|
||||
}
|
||||
|
||||
@@ -38,14 +50,16 @@ func TestCsvFileBasicDataTableHeaderColumnNames(t *testing.T) {
|
||||
{"A1", "B1", "C1"},
|
||||
{"A2", "B2", "C2"},
|
||||
{"A3", "B3", "C3"},
|
||||
})
|
||||
}, true)
|
||||
|
||||
assert.EqualValues(t, []string{"A1", "B1", "C1"}, datatable.HeaderColumnNames())
|
||||
}
|
||||
|
||||
func TestCsvFileBasicDataTableHeaderColumnNames_EmptyContent(t *testing.T) {
|
||||
datatable := CreateNewCustomCsvBasicDataTable([][]string{})
|
||||
datatable := CreateNewCustomCsvBasicDataTable([][]string{}, false)
|
||||
assert.Nil(t, datatable.HeaderColumnNames())
|
||||
|
||||
datatable = CreateNewCustomCsvBasicDataTable([][]string{}, true)
|
||||
assert.Nil(t, datatable.HeaderColumnNames())
|
||||
}
|
||||
|
||||
@@ -54,7 +68,34 @@ func TestCsvFileBasicDataTableRowIterator(t *testing.T) {
|
||||
{"A1", "B1", "C1"},
|
||||
{"A2", "B2", "C2"},
|
||||
{"A3", "B3", "C3"},
|
||||
})
|
||||
}, false)
|
||||
|
||||
iterator := datatable.DataRowIterator()
|
||||
assert.True(t, iterator.HasNext())
|
||||
|
||||
// data row 1
|
||||
assert.NotNil(t, iterator.Next())
|
||||
assert.True(t, iterator.HasNext())
|
||||
|
||||
// data row 2
|
||||
assert.NotNil(t, iterator.Next())
|
||||
assert.True(t, iterator.HasNext())
|
||||
|
||||
// data row 3
|
||||
assert.NotNil(t, iterator.Next())
|
||||
assert.False(t, iterator.HasNext())
|
||||
|
||||
// not existed data row 4
|
||||
assert.Nil(t, iterator.Next())
|
||||
assert.False(t, iterator.HasNext())
|
||||
}
|
||||
|
||||
func TestCsvFileBasicDataTableRowIterator_HasTitleLine(t *testing.T) {
|
||||
datatable := CreateNewCustomCsvBasicDataTable([][]string{
|
||||
{"A1", "B1", "C1"},
|
||||
{"A2", "B2", "C2"},
|
||||
{"A3", "B3", "C3"},
|
||||
}, true)
|
||||
|
||||
iterator := datatable.DataRowIterator()
|
||||
assert.True(t, iterator.HasNext())
|
||||
@@ -81,7 +122,7 @@ func TestCsvFileBasicDataTableRowColumnCount(t *testing.T) {
|
||||
{"A1", "B1", "C1"},
|
||||
{"A2", "B2", "C2"},
|
||||
{"A3", "B3", "C3"},
|
||||
})
|
||||
}, true)
|
||||
|
||||
iterator := datatable.DataRowIterator()
|
||||
|
||||
@@ -97,7 +138,32 @@ func TestCsvFileBasicDataTableRowGetData(t *testing.T) {
|
||||
{"A1", "B1", "C1"},
|
||||
{"A2", "B2", "C2"},
|
||||
{"A3", "B3", "C3"},
|
||||
})
|
||||
}, false)
|
||||
|
||||
iterator := datatable.DataRowIterator()
|
||||
|
||||
row1 := iterator.Next()
|
||||
assert.Equal(t, "A1", row1.GetData(0))
|
||||
assert.Equal(t, "B1", row1.GetData(1))
|
||||
assert.Equal(t, "C1", row1.GetData(2))
|
||||
|
||||
row2 := iterator.Next()
|
||||
assert.Equal(t, "A2", row2.GetData(0))
|
||||
assert.Equal(t, "B2", row2.GetData(1))
|
||||
assert.Equal(t, "C2", row2.GetData(2))
|
||||
|
||||
row3 := iterator.Next()
|
||||
assert.Equal(t, "A3", row3.GetData(0))
|
||||
assert.Equal(t, "B3", row3.GetData(1))
|
||||
assert.Equal(t, "C3", row3.GetData(2))
|
||||
}
|
||||
|
||||
func TestCsvFileBasicDataTableRowGetData_HasTitleLine(t *testing.T) {
|
||||
datatable := CreateNewCustomCsvBasicDataTable([][]string{
|
||||
{"A1", "B1", "C1"},
|
||||
{"A2", "B2", "C2"},
|
||||
{"A3", "B3", "C3"},
|
||||
}, true)
|
||||
|
||||
iterator := datatable.DataRowIterator()
|
||||
|
||||
@@ -117,7 +183,7 @@ func TestCsvFileBasicDataTableRowGetData_GetNotExistedColumnData(t *testing.T) {
|
||||
{"A1", "B1", "C1"},
|
||||
{"A2", "B2", "C2"},
|
||||
{"A3", "B3", "C3"},
|
||||
})
|
||||
}, true)
|
||||
|
||||
iterator := datatable.DataRowIterator()
|
||||
|
||||
@@ -130,7 +196,7 @@ func TestCreateNewCsvBasicDataTable(t *testing.T) {
|
||||
reader := bytes.NewReader([]byte("A1,B1,C1\n" +
|
||||
"A2,B2,C2\n" +
|
||||
"A3,B3,C3\n"))
|
||||
datatable, err := CreateNewCsvBasicDataTable(context, reader)
|
||||
datatable, err := CreateNewCsvBasicDataTable(context, reader, true)
|
||||
assert.Nil(t, err)
|
||||
|
||||
assert.Equal(t, 2, datatable.DataRowCount())
|
||||
@@ -160,7 +226,7 @@ func TestCreateNewCsvBasicDataTable_SkipBlankLine(t *testing.T) {
|
||||
"A2,B2,C2\n" +
|
||||
"\n" +
|
||||
"A3,B3,C3\n"))
|
||||
datatable, err := CreateNewCsvBasicDataTable(context, reader)
|
||||
datatable, err := CreateNewCsvBasicDataTable(context, reader, true)
|
||||
assert.Nil(t, err)
|
||||
|
||||
assert.Equal(t, 2, datatable.DataRowCount())
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package datatable
|
||||
|
||||
type testBasicDataTable struct {
|
||||
headerColumns []string
|
||||
rows []*testBasicDataTableRow
|
||||
}
|
||||
|
||||
type testBasicDataTableRow struct {
|
||||
rowId string
|
||||
rowColumns []string
|
||||
}
|
||||
|
||||
type testBasicDataTableRowIterator struct {
|
||||
rows []*testBasicDataTableRow
|
||||
currentIndex int
|
||||
}
|
||||
|
||||
func (t *testBasicDataTable) HeaderColumnNames() []string {
|
||||
return t.headerColumns
|
||||
}
|
||||
|
||||
func (t *testBasicDataTable) DataRowCount() int {
|
||||
return len(t.rows)
|
||||
}
|
||||
|
||||
func (t *testBasicDataTable) DataRowIterator() BasicDataTableRowIterator {
|
||||
return &testBasicDataTableRowIterator{
|
||||
rows: t.rows,
|
||||
currentIndex: -1,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *testBasicDataTableRow) ColumnCount() int {
|
||||
return len(r.rowColumns)
|
||||
}
|
||||
|
||||
func (r *testBasicDataTableRow) GetData(columnIndex int) string {
|
||||
if columnIndex < 0 || columnIndex >= len(r.rowColumns) {
|
||||
return ""
|
||||
}
|
||||
|
||||
return r.rowColumns[columnIndex]
|
||||
}
|
||||
|
||||
func (t *testBasicDataTableRowIterator) HasNext() bool {
|
||||
return t.currentIndex+1 < len(t.rows)
|
||||
}
|
||||
|
||||
func (t *testBasicDataTableRowIterator) CurrentRowId() string {
|
||||
if t.currentIndex >= len(t.rows) {
|
||||
return ""
|
||||
}
|
||||
|
||||
return t.rows[t.currentIndex].rowId
|
||||
}
|
||||
|
||||
func (t *testBasicDataTableRowIterator) Next() BasicDataTableRow {
|
||||
if t.currentIndex+1 >= len(t.rows) {
|
||||
return nil
|
||||
}
|
||||
|
||||
t.currentIndex++
|
||||
row := t.rows[t.currentIndex]
|
||||
return row
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package datatable
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestBasicDataTableToCommonDataTableWrapper_HeaderColumnCount(t *testing.T) {
|
||||
columns := []string{"Col1", "Col2", "Col3"}
|
||||
basicDataTable := &testBasicDataTable{
|
||||
headerColumns: columns,
|
||||
rows: []*testBasicDataTableRow{},
|
||||
}
|
||||
|
||||
commonDataTable := CreateNewCommonDataTableFromBasicDataTable(basicDataTable)
|
||||
assert.Equal(t, len(columns), commonDataTable.HeaderColumnCount())
|
||||
}
|
||||
|
||||
func TestBasicDataTableToCommonDataTableWrapper_HasColumn(t *testing.T) {
|
||||
columns := []string{"Col1", "Col2", "Col3"}
|
||||
basicDataTable := &testBasicDataTable{
|
||||
headerColumns: columns,
|
||||
rows: []*testBasicDataTableRow{},
|
||||
}
|
||||
|
||||
commonDataTable := CreateNewCommonDataTableFromBasicDataTable(basicDataTable)
|
||||
|
||||
assert.True(t, commonDataTable.HasColumn("Col1"))
|
||||
assert.True(t, commonDataTable.HasColumn("Col2"))
|
||||
assert.True(t, commonDataTable.HasColumn("Col3"))
|
||||
|
||||
assert.False(t, commonDataTable.HasColumn("Col4"))
|
||||
assert.False(t, commonDataTable.HasColumn(""))
|
||||
}
|
||||
|
||||
func TestBasicDataTableToCommonDataTableWrapper_DataRowCount(t *testing.T) {
|
||||
columns := []string{"Col1", "Col2", "Col3"}
|
||||
rows := []*testBasicDataTableRow{
|
||||
{
|
||||
rowId: "1",
|
||||
rowColumns: []string{"A1", "B1", "C1"},
|
||||
},
|
||||
{
|
||||
rowId: "2",
|
||||
rowColumns: []string{"A2", "B2", "C2"},
|
||||
},
|
||||
{
|
||||
rowId: "3",
|
||||
rowColumns: []string{"A3", "B3", "C3"},
|
||||
},
|
||||
}
|
||||
|
||||
basicDataTable := &testBasicDataTable{
|
||||
headerColumns: columns,
|
||||
rows: rows,
|
||||
}
|
||||
|
||||
commonDataTable := CreateNewCommonDataTableFromBasicDataTable(basicDataTable)
|
||||
assert.Equal(t, len(rows), commonDataTable.DataRowCount())
|
||||
}
|
||||
|
||||
func TestBasicDataTableToCommonDataTableWrapper_DataRowIterator(t *testing.T) {
|
||||
columns := []string{"Col1", "Col2", "Col3"}
|
||||
rows := []*testBasicDataTableRow{
|
||||
{
|
||||
rowId: "1",
|
||||
rowColumns: []string{"A1", "B1", "C1"},
|
||||
},
|
||||
{
|
||||
rowId: "2",
|
||||
rowColumns: []string{"A2", "B2", "C2"},
|
||||
},
|
||||
}
|
||||
|
||||
basicDataTable := &testBasicDataTable{
|
||||
headerColumns: columns,
|
||||
rows: rows,
|
||||
}
|
||||
|
||||
commonDataTable := CreateNewCommonDataTableFromBasicDataTable(basicDataTable)
|
||||
iterator := commonDataTable.DataRowIterator()
|
||||
|
||||
assert.True(t, iterator.HasNext())
|
||||
firstRow := iterator.Next()
|
||||
assert.NotNil(t, firstRow)
|
||||
assert.Equal(t, len(columns), firstRow.ColumnCount())
|
||||
assert.True(t, firstRow.HasData("Col1"))
|
||||
assert.True(t, firstRow.HasData("Col2"))
|
||||
assert.True(t, firstRow.HasData("Col3"))
|
||||
assert.Equal(t, "A1", firstRow.GetData("Col1"))
|
||||
assert.Equal(t, "B1", firstRow.GetData("Col2"))
|
||||
assert.Equal(t, "C1", firstRow.GetData("Col3"))
|
||||
|
||||
assert.True(t, iterator.HasNext())
|
||||
secondRow := iterator.Next()
|
||||
assert.NotNil(t, secondRow)
|
||||
assert.Equal(t, len(columns), secondRow.ColumnCount())
|
||||
assert.True(t, secondRow.HasData("Col1"))
|
||||
assert.True(t, secondRow.HasData("Col2"))
|
||||
assert.True(t, secondRow.HasData("Col3"))
|
||||
assert.Equal(t, "A2", secondRow.GetData("Col1"))
|
||||
assert.Equal(t, "B2", secondRow.GetData("Col2"))
|
||||
assert.Equal(t, "C2", secondRow.GetData("Col3"))
|
||||
|
||||
assert.False(t, iterator.HasNext())
|
||||
assert.Nil(t, iterator.Next())
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package datatable
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
)
|
||||
|
||||
type testTransactionDataRowParser struct {
|
||||
}
|
||||
|
||||
func (p *testTransactionDataRowParser) Parse(rowData map[TransactionDataTableColumn]string) (map[TransactionDataTableColumn]string, bool, error) {
|
||||
rowData[TRANSACTION_DATA_TABLE_DESCRIPTION] = "Test Description"
|
||||
return rowData, true, nil
|
||||
}
|
||||
|
||||
func (p *testTransactionDataRowParser) GetAddedColumns() []TransactionDataTableColumn {
|
||||
return []TransactionDataTableColumn{TRANSACTION_DATA_TABLE_DESCRIPTION}
|
||||
}
|
||||
|
||||
func TestBasicDataTableToTransactionDataTableWrapper_HasColumn(t *testing.T) {
|
||||
columns := []string{"TransactionTime", "TransactionType", "Amount"}
|
||||
basicDataTable := &testBasicDataTable{
|
||||
headerColumns: columns,
|
||||
rows: []*testBasicDataTableRow{},
|
||||
}
|
||||
|
||||
columnMapping := map[TransactionDataTableColumn]string{
|
||||
TRANSACTION_DATA_TABLE_TRANSACTION_TIME: "TransactionTime",
|
||||
TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: "TransactionType",
|
||||
TRANSACTION_DATA_TABLE_AMOUNT: "Amount",
|
||||
}
|
||||
|
||||
transactionDataTable := CreateNewTransactionDataTableFromBasicDataTable(basicDataTable, columnMapping)
|
||||
|
||||
assert.True(t, transactionDataTable.HasColumn(TRANSACTION_DATA_TABLE_TRANSACTION_TIME))
|
||||
assert.True(t, transactionDataTable.HasColumn(TRANSACTION_DATA_TABLE_TRANSACTION_TYPE))
|
||||
assert.True(t, transactionDataTable.HasColumn(TRANSACTION_DATA_TABLE_AMOUNT))
|
||||
|
||||
assert.False(t, transactionDataTable.HasColumn(TRANSACTION_DATA_TABLE_DESCRIPTION))
|
||||
assert.False(t, transactionDataTable.HasColumn(TRANSACTION_DATA_TABLE_CATEGORY))
|
||||
}
|
||||
|
||||
func TestBasicDataTableToTransactionDataTableWrapper_TransactionRowCount(t *testing.T) {
|
||||
columns := []string{"TransactionTime", "TransactionType", "Amount"}
|
||||
rows := []*testBasicDataTableRow{
|
||||
{
|
||||
rowId: "1",
|
||||
rowColumns: []string{"2024-01-01", "1", "100"},
|
||||
},
|
||||
{
|
||||
rowId: "2",
|
||||
rowColumns: []string{"2024-01-02", "2", "200"},
|
||||
},
|
||||
{
|
||||
rowId: "3",
|
||||
rowColumns: []string{"2024-01-03", "1", "300"},
|
||||
},
|
||||
}
|
||||
|
||||
basicDataTable := &testBasicDataTable{
|
||||
headerColumns: columns,
|
||||
rows: rows,
|
||||
}
|
||||
|
||||
columnMapping := map[TransactionDataTableColumn]string{
|
||||
TRANSACTION_DATA_TABLE_TRANSACTION_TIME: "TransactionTime",
|
||||
TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: "TransactionType",
|
||||
TRANSACTION_DATA_TABLE_AMOUNT: "Amount",
|
||||
}
|
||||
|
||||
transactionDataTable := CreateNewTransactionDataTableFromBasicDataTable(basicDataTable, columnMapping)
|
||||
assert.Equal(t, len(rows), transactionDataTable.TransactionRowCount())
|
||||
}
|
||||
|
||||
func TestBasicDataTableToTransactionDataTableWrapper_TransactionRowIterator(t *testing.T) {
|
||||
columns := []string{"TransactionTime", "TransactionType", "Amount"}
|
||||
rows := []*testBasicDataTableRow{
|
||||
{
|
||||
rowId: "1",
|
||||
rowColumns: []string{"2024-01-01", "1", "100"},
|
||||
},
|
||||
{
|
||||
rowId: "2",
|
||||
rowColumns: []string{"2024-01-02", "2", "200"},
|
||||
},
|
||||
}
|
||||
|
||||
basicDataTable := &testBasicDataTable{
|
||||
headerColumns: columns,
|
||||
rows: rows,
|
||||
}
|
||||
|
||||
columnMapping := map[TransactionDataTableColumn]string{
|
||||
TRANSACTION_DATA_TABLE_TRANSACTION_TIME: "TransactionTime",
|
||||
TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: "TransactionType",
|
||||
TRANSACTION_DATA_TABLE_AMOUNT: "Amount",
|
||||
}
|
||||
|
||||
transactionDataTable := CreateNewTransactionDataTableFromBasicDataTable(basicDataTable, columnMapping)
|
||||
iterator := transactionDataTable.TransactionRowIterator()
|
||||
|
||||
assert.True(t, iterator.HasNext())
|
||||
firstRow, err := iterator.Next(nil, nil)
|
||||
assert.Nil(t, err)
|
||||
assert.NotNil(t, firstRow)
|
||||
assert.True(t, firstRow.IsValid())
|
||||
assert.Equal(t, "2024-01-01", firstRow.GetData(TRANSACTION_DATA_TABLE_TRANSACTION_TIME))
|
||||
assert.Equal(t, "1", firstRow.GetData(TRANSACTION_DATA_TABLE_TRANSACTION_TYPE))
|
||||
assert.Equal(t, "100", firstRow.GetData(TRANSACTION_DATA_TABLE_AMOUNT))
|
||||
|
||||
assert.True(t, iterator.HasNext())
|
||||
secondRow, err := iterator.Next(nil, nil)
|
||||
assert.Nil(t, err)
|
||||
assert.NotNil(t, secondRow)
|
||||
assert.True(t, secondRow.IsValid())
|
||||
assert.Equal(t, "2024-01-02", secondRow.GetData(TRANSACTION_DATA_TABLE_TRANSACTION_TIME))
|
||||
assert.Equal(t, "2", secondRow.GetData(TRANSACTION_DATA_TABLE_TRANSACTION_TYPE))
|
||||
assert.Equal(t, "200", secondRow.GetData(TRANSACTION_DATA_TABLE_AMOUNT))
|
||||
|
||||
assert.False(t, iterator.HasNext())
|
||||
emptyRow, err := iterator.Next(nil, nil)
|
||||
assert.Nil(t, err)
|
||||
assert.Nil(t, emptyRow)
|
||||
}
|
||||
|
||||
func TestBasicDataTableToTransactionDataTableWrapper_TransactionRowIterator_EmptyRow(t *testing.T) {
|
||||
columns := []string{"TransactionTime", "TransactionType", "Amount"}
|
||||
rows := []*testBasicDataTableRow{
|
||||
{
|
||||
rowId: "1",
|
||||
rowColumns: []string{""},
|
||||
},
|
||||
}
|
||||
|
||||
basicDataTable := &testBasicDataTable{
|
||||
headerColumns: columns,
|
||||
rows: rows,
|
||||
}
|
||||
|
||||
columnMapping := map[TransactionDataTableColumn]string{
|
||||
TRANSACTION_DATA_TABLE_TRANSACTION_TIME: "TransactionTime",
|
||||
TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: "TransactionType",
|
||||
TRANSACTION_DATA_TABLE_AMOUNT: "Amount",
|
||||
}
|
||||
|
||||
transactionDataTable := CreateNewTransactionDataTableFromBasicDataTable(basicDataTable, columnMapping)
|
||||
iterator := transactionDataTable.TransactionRowIterator()
|
||||
|
||||
assert.True(t, iterator.HasNext())
|
||||
row, err := iterator.Next(nil, nil)
|
||||
assert.Nil(t, err)
|
||||
assert.NotNil(t, row)
|
||||
assert.False(t, row.IsValid())
|
||||
}
|
||||
|
||||
func TestBasicDataTableToTransactionDataTableWrapper_TransactionRowIterator_InvalidRow(t *testing.T) {
|
||||
columns := []string{"TransactionTime", "TransactionType", "Amount"}
|
||||
rows := []*testBasicDataTableRow{
|
||||
{
|
||||
rowId: "1",
|
||||
rowColumns: []string{"2024-01-01", "1"},
|
||||
},
|
||||
}
|
||||
|
||||
basicDataTable := &testBasicDataTable{
|
||||
headerColumns: columns,
|
||||
rows: rows,
|
||||
}
|
||||
|
||||
columnMapping := map[TransactionDataTableColumn]string{
|
||||
TRANSACTION_DATA_TABLE_TRANSACTION_TIME: "TransactionTime",
|
||||
TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: "TransactionType",
|
||||
TRANSACTION_DATA_TABLE_AMOUNT: "Amount",
|
||||
}
|
||||
|
||||
transactionDataTable := CreateNewTransactionDataTableFromBasicDataTable(basicDataTable, columnMapping)
|
||||
iterator := transactionDataTable.TransactionRowIterator()
|
||||
|
||||
assert.True(t, iterator.HasNext())
|
||||
row, err := iterator.Next(nil, nil)
|
||||
assert.NotNil(t, err)
|
||||
assert.Equal(t, errs.ErrFewerFieldsInDataRowThanInHeaderRow, err)
|
||||
assert.Nil(t, row)
|
||||
}
|
||||
|
||||
func TestBasicDataTableToTransactionDataTableWrapper_TransactionRowIterator_WithRowParserAddedColumn(t *testing.T) {
|
||||
columns := []string{"TransactionTime", "TransactionType", "Amount"}
|
||||
rows := []*testBasicDataTableRow{
|
||||
{
|
||||
rowId: "1",
|
||||
rowColumns: []string{"2024-01-01", "1", "100"},
|
||||
},
|
||||
}
|
||||
|
||||
basicDataTable := &testBasicDataTable{
|
||||
headerColumns: columns,
|
||||
rows: rows,
|
||||
}
|
||||
|
||||
columnMapping := map[TransactionDataTableColumn]string{
|
||||
TRANSACTION_DATA_TABLE_TRANSACTION_TIME: "TransactionTime",
|
||||
TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: "TransactionType",
|
||||
TRANSACTION_DATA_TABLE_AMOUNT: "Amount",
|
||||
TRANSACTION_DATA_TABLE_DESCRIPTION: "Description",
|
||||
}
|
||||
|
||||
transactionDataTable := CreateNewTransactionDataTableFromBasicDataTableWithRowParser(basicDataTable, columnMapping, &testTransactionDataRowParser{})
|
||||
assert.True(t, transactionDataTable.HasColumn(TRANSACTION_DATA_TABLE_DESCRIPTION))
|
||||
|
||||
iterator := transactionDataTable.TransactionRowIterator()
|
||||
assert.True(t, iterator.HasNext())
|
||||
row, err := iterator.Next(nil, nil)
|
||||
assert.Nil(t, err)
|
||||
assert.NotNil(t, row)
|
||||
assert.True(t, row.IsValid())
|
||||
assert.Equal(t, "Test Description", row.GetData(TRANSACTION_DATA_TABLE_DESCRIPTION))
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
package datatable
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
)
|
||||
|
||||
type testCommonDataTable struct {
|
||||
headerColumns []string
|
||||
dataRows []*testCommonDataTableRow
|
||||
}
|
||||
|
||||
type testCommonDataTableRow struct {
|
||||
rowId string
|
||||
rowData map[string]string
|
||||
}
|
||||
|
||||
type testCommonDataTableRowIterator struct {
|
||||
dataTable *testCommonDataTable
|
||||
currentIndex int
|
||||
}
|
||||
|
||||
func (t *testCommonDataTable) DataRowCount() int {
|
||||
return len(t.dataRows)
|
||||
}
|
||||
|
||||
func (t *testCommonDataTable) HeaderColumnCount() int {
|
||||
return len(t.headerColumns)
|
||||
}
|
||||
|
||||
func (t *testCommonDataTable) HasColumn(columnName string) bool {
|
||||
for _, header := range t.headerColumns {
|
||||
if header == columnName {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (t *testCommonDataTable) DataRowIterator() CommonDataTableRowIterator {
|
||||
return &testCommonDataTableRowIterator{
|
||||
dataTable: t,
|
||||
currentIndex: -1,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *testCommonDataTableRow) GetData(dataKey string) string {
|
||||
return t.rowData[dataKey]
|
||||
}
|
||||
|
||||
func (t *testCommonDataTableRow) HasData(dataKey string) bool {
|
||||
_, exists := t.rowData[dataKey]
|
||||
return exists
|
||||
}
|
||||
|
||||
func (t *testCommonDataTableRow) ColumnCount() int {
|
||||
return len(t.rowData)
|
||||
}
|
||||
|
||||
func (t *testCommonDataTableRowIterator) HasNext() bool {
|
||||
return t.currentIndex+1 < len(t.dataTable.dataRows)
|
||||
}
|
||||
|
||||
func (t *testCommonDataTableRowIterator) Next() CommonDataTableRow {
|
||||
if !t.HasNext() {
|
||||
return nil
|
||||
}
|
||||
|
||||
t.currentIndex++
|
||||
return t.dataTable.dataRows[t.currentIndex]
|
||||
}
|
||||
|
||||
func (t *testCommonDataTableRowIterator) CurrentRowId() string {
|
||||
if t.currentIndex < 0 || t.currentIndex >= len(t.dataTable.dataRows) {
|
||||
return ""
|
||||
}
|
||||
|
||||
return t.dataTable.dataRows[t.currentIndex].rowId
|
||||
}
|
||||
|
||||
type testCommonTransactionDataRowParser struct {
|
||||
returnError bool
|
||||
}
|
||||
|
||||
func (p *testCommonTransactionDataRowParser) Parse(ctx core.Context, user *models.User, dataRow CommonDataTableRow, rowId string) (map[TransactionDataTableColumn]string, bool, error) {
|
||||
if p.returnError {
|
||||
return nil, false, errs.ErrOperationFailed
|
||||
}
|
||||
|
||||
rowData := make(map[TransactionDataTableColumn]string)
|
||||
rowData[TRANSACTION_DATA_TABLE_TRANSACTION_TIME] = dataRow.GetData("TransactionTime")
|
||||
rowData[TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = dataRow.GetData("TransactionType")
|
||||
rowData[TRANSACTION_DATA_TABLE_AMOUNT] = dataRow.GetData("Amount")
|
||||
rowData[TRANSACTION_DATA_TABLE_DESCRIPTION] = "Test Description"
|
||||
return rowData, true, nil
|
||||
}
|
||||
|
||||
func TestCommonDataTableToTransactionDataTableWrapper_HasColumn(t *testing.T) {
|
||||
basicDataTable := &testCommonDataTable{
|
||||
headerColumns: []string{"TransactionTime", "TransactionType", "Amount"},
|
||||
dataRows: []*testCommonDataTableRow{},
|
||||
}
|
||||
|
||||
supportedColumns := map[TransactionDataTableColumn]bool{
|
||||
TRANSACTION_DATA_TABLE_TRANSACTION_TIME: true,
|
||||
TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: true,
|
||||
TRANSACTION_DATA_TABLE_AMOUNT: true,
|
||||
}
|
||||
|
||||
transactionDataTable := CreateNewTransactionDataTableFromCommonDataTable(basicDataTable, supportedColumns, &testCommonTransactionDataRowParser{})
|
||||
|
||||
assert.True(t, transactionDataTable.HasColumn(TRANSACTION_DATA_TABLE_TRANSACTION_TIME))
|
||||
assert.True(t, transactionDataTable.HasColumn(TRANSACTION_DATA_TABLE_TRANSACTION_TYPE))
|
||||
assert.True(t, transactionDataTable.HasColumn(TRANSACTION_DATA_TABLE_AMOUNT))
|
||||
|
||||
assert.False(t, transactionDataTable.HasColumn(TRANSACTION_DATA_TABLE_CATEGORY))
|
||||
assert.False(t, transactionDataTable.HasColumn(TRANSACTION_DATA_TABLE_DESCRIPTION))
|
||||
}
|
||||
|
||||
func TestCommonDataTableToTransactionDataTableWrapper_TransactionRowCount(t *testing.T) {
|
||||
rows := []*testCommonDataTableRow{
|
||||
{
|
||||
rowId: "1",
|
||||
rowData: map[string]string{
|
||||
"TransactionTime": "2024-01-01",
|
||||
"TransactionType": "1",
|
||||
"Amount": "100",
|
||||
},
|
||||
},
|
||||
{
|
||||
rowId: "2",
|
||||
rowData: map[string]string{
|
||||
"TransactionTime": "2024-01-02",
|
||||
"TransactionType": "2",
|
||||
"Amount": "200",
|
||||
},
|
||||
},
|
||||
{
|
||||
rowId: "3",
|
||||
rowData: map[string]string{
|
||||
"TransactionTime": "2024-01-03",
|
||||
"TransactionType": "1",
|
||||
"Amount": "300",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
basicDataTable := &testCommonDataTable{
|
||||
headerColumns: []string{"TransactionTime", "TransactionType", "Amount"},
|
||||
dataRows: rows,
|
||||
}
|
||||
|
||||
supportedColumns := map[TransactionDataTableColumn]bool{
|
||||
TRANSACTION_DATA_TABLE_TRANSACTION_TIME: true,
|
||||
TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: true,
|
||||
TRANSACTION_DATA_TABLE_AMOUNT: true,
|
||||
}
|
||||
|
||||
transactionDataTable := CreateNewTransactionDataTableFromCommonDataTable(basicDataTable, supportedColumns, &testCommonTransactionDataRowParser{})
|
||||
assert.Equal(t, len(rows), transactionDataTable.TransactionRowCount())
|
||||
}
|
||||
|
||||
func TestCommonDataTableToTransactionDataTableWrapper_TransactionRowIterator(t *testing.T) {
|
||||
rows := []*testCommonDataTableRow{
|
||||
{
|
||||
rowId: "1",
|
||||
rowData: map[string]string{
|
||||
"TransactionTime": "2024-01-01",
|
||||
"TransactionType": "1",
|
||||
"Amount": "100",
|
||||
},
|
||||
},
|
||||
{
|
||||
rowId: "2",
|
||||
rowData: map[string]string{
|
||||
"TransactionTime": "2024-01-02",
|
||||
"TransactionType": "2",
|
||||
"Amount": "200",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
basicDataTable := &testCommonDataTable{
|
||||
headerColumns: []string{"TransactionTime", "TransactionType", "Amount"},
|
||||
dataRows: rows,
|
||||
}
|
||||
|
||||
supportedColumns := map[TransactionDataTableColumn]bool{
|
||||
TRANSACTION_DATA_TABLE_TRANSACTION_TIME: true,
|
||||
TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: true,
|
||||
TRANSACTION_DATA_TABLE_AMOUNT: true,
|
||||
}
|
||||
|
||||
transactionDataTable := CreateNewTransactionDataTableFromCommonDataTable(basicDataTable, supportedColumns, &testCommonTransactionDataRowParser{})
|
||||
iterator := transactionDataTable.TransactionRowIterator()
|
||||
|
||||
assert.True(t, iterator.HasNext())
|
||||
firstRow, err := iterator.Next(nil, nil)
|
||||
assert.Nil(t, err)
|
||||
assert.NotNil(t, firstRow)
|
||||
assert.True(t, firstRow.IsValid())
|
||||
assert.Equal(t, "2024-01-01", firstRow.GetData(TRANSACTION_DATA_TABLE_TRANSACTION_TIME))
|
||||
assert.Equal(t, "1", firstRow.GetData(TRANSACTION_DATA_TABLE_TRANSACTION_TYPE))
|
||||
assert.Equal(t, "100", firstRow.GetData(TRANSACTION_DATA_TABLE_AMOUNT))
|
||||
assert.Equal(t, "", firstRow.GetData(TRANSACTION_DATA_TABLE_DESCRIPTION))
|
||||
|
||||
assert.True(t, iterator.HasNext())
|
||||
secondRow, err := iterator.Next(nil, nil)
|
||||
assert.Nil(t, err)
|
||||
assert.NotNil(t, secondRow)
|
||||
assert.True(t, secondRow.IsValid())
|
||||
assert.Equal(t, "2024-01-02", secondRow.GetData(TRANSACTION_DATA_TABLE_TRANSACTION_TIME))
|
||||
assert.Equal(t, "2", secondRow.GetData(TRANSACTION_DATA_TABLE_TRANSACTION_TYPE))
|
||||
assert.Equal(t, "200", secondRow.GetData(TRANSACTION_DATA_TABLE_AMOUNT))
|
||||
assert.Equal(t, "", secondRow.GetData(TRANSACTION_DATA_TABLE_DESCRIPTION))
|
||||
|
||||
assert.False(t, iterator.HasNext())
|
||||
emptyRow, err := iterator.Next(nil, nil)
|
||||
assert.Nil(t, err)
|
||||
assert.Nil(t, emptyRow)
|
||||
}
|
||||
|
||||
func TestCommonDataTableToTransactionDataTableWrapper_TransactionRowIterator_EOF(t *testing.T) {
|
||||
rows := []*testCommonDataTableRow{
|
||||
{
|
||||
rowId: "1",
|
||||
rowData: map[string]string{
|
||||
"TransactionTime": "2024-01-01",
|
||||
"TransactionType": "1",
|
||||
"Amount": "100",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
basicDataTable := &testCommonDataTable{
|
||||
headerColumns: []string{"TransactionTime", "TransactionType", "Amount"},
|
||||
dataRows: rows,
|
||||
}
|
||||
|
||||
supportedColumns := map[TransactionDataTableColumn]bool{
|
||||
TRANSACTION_DATA_TABLE_TRANSACTION_TIME: true,
|
||||
TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: true,
|
||||
TRANSACTION_DATA_TABLE_AMOUNT: true,
|
||||
}
|
||||
|
||||
transactionDataTable := CreateNewTransactionDataTableFromCommonDataTable(basicDataTable, supportedColumns, &testCommonTransactionDataRowParser{returnError: true})
|
||||
iterator := transactionDataTable.TransactionRowIterator()
|
||||
|
||||
assert.True(t, iterator.HasNext())
|
||||
row, err := iterator.Next(nil, nil)
|
||||
assert.EqualError(t, err, errs.ErrOperationFailed.Message)
|
||||
assert.Nil(t, row)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package datatable
|
||||
|
||||
// SubBasicDataTable defines the structure of sub basic data table
|
||||
type SubBasicDataTable struct {
|
||||
baseTable BasicDataTable
|
||||
fromIndex int
|
||||
toIndex int
|
||||
}
|
||||
|
||||
// SubBasicDataTableRowIterator defines the structure of sub basic data table row iterator
|
||||
type SubBasicDataTableRowIterator struct {
|
||||
dataTable *SubBasicDataTable
|
||||
innerIterator BasicDataTableRowIterator
|
||||
currentIndex int
|
||||
}
|
||||
|
||||
// DataRowCount returns the total count of data row
|
||||
func (t *SubBasicDataTable) DataRowCount() int {
|
||||
return t.toIndex - t.fromIndex
|
||||
}
|
||||
|
||||
// HeaderColumnNames returns the header column name list
|
||||
func (t *SubBasicDataTable) HeaderColumnNames() []string {
|
||||
return t.baseTable.HeaderColumnNames()
|
||||
}
|
||||
|
||||
// DataRowIterator returns the iterator of data row
|
||||
func (t *SubBasicDataTable) DataRowIterator() BasicDataTableRowIterator {
|
||||
innerIterator := t.baseTable.DataRowIterator()
|
||||
currentIndex := -1
|
||||
|
||||
// skip rows until reaching the fromIndex
|
||||
for currentIndex = -1; currentIndex < t.fromIndex-1 && innerIterator.HasNext(); currentIndex++ {
|
||||
innerIterator.Next()
|
||||
}
|
||||
|
||||
return &SubBasicDataTableRowIterator{
|
||||
dataTable: t,
|
||||
innerIterator: innerIterator,
|
||||
currentIndex: currentIndex,
|
||||
}
|
||||
}
|
||||
|
||||
// HasNext returns whether the iterator does not reach the end
|
||||
func (t *SubBasicDataTableRowIterator) HasNext() bool {
|
||||
return t.currentIndex+1 < t.dataTable.toIndex && t.innerIterator.HasNext()
|
||||
}
|
||||
|
||||
// CurrentRowId returns current row id
|
||||
func (t *SubBasicDataTableRowIterator) CurrentRowId() string {
|
||||
return t.innerIterator.CurrentRowId()
|
||||
}
|
||||
|
||||
// Next returns the next basic data row
|
||||
func (t *SubBasicDataTableRowIterator) Next() BasicDataTableRow {
|
||||
if t.currentIndex+1 >= t.dataTable.toIndex {
|
||||
return nil
|
||||
}
|
||||
|
||||
t.currentIndex++
|
||||
return t.innerIterator.Next()
|
||||
}
|
||||
|
||||
// CreateSubBasicTable returns a sub basic data table that references a portion of the original table
|
||||
func CreateSubBasicTable(dataTable BasicDataTable, fromIndex, toIndex int) *SubBasicDataTable {
|
||||
if fromIndex < 0 {
|
||||
fromIndex = 0
|
||||
}
|
||||
|
||||
if fromIndex > dataTable.DataRowCount() {
|
||||
fromIndex = dataTable.DataRowCount()
|
||||
}
|
||||
|
||||
if toIndex > dataTable.DataRowCount() {
|
||||
toIndex = dataTable.DataRowCount()
|
||||
}
|
||||
|
||||
if toIndex < fromIndex {
|
||||
toIndex = fromIndex
|
||||
}
|
||||
|
||||
return &SubBasicDataTable{
|
||||
baseTable: dataTable,
|
||||
fromIndex: fromIndex,
|
||||
toIndex: toIndex,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package datatable
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestCreateSubBasicTable_WithValidInput(t *testing.T) {
|
||||
columns := []string{"Col1", "Col2", "Col3"}
|
||||
rows := []*testBasicDataTableRow{
|
||||
{
|
||||
rowId: "1",
|
||||
rowColumns: []string{"A1", "B1", "C1"},
|
||||
},
|
||||
{
|
||||
rowId: "2",
|
||||
rowColumns: []string{"A2", "B2", "C2"},
|
||||
},
|
||||
{
|
||||
rowId: "3",
|
||||
rowColumns: []string{"A3", "B3", "C3"},
|
||||
},
|
||||
}
|
||||
|
||||
basicDataTable := &testBasicDataTable{
|
||||
headerColumns: columns,
|
||||
rows: rows,
|
||||
}
|
||||
|
||||
subTable := CreateSubBasicTable(basicDataTable, 1, 2)
|
||||
assert.Equal(t, 1, subTable.DataRowCount())
|
||||
assert.Equal(t, columns, subTable.HeaderColumnNames())
|
||||
}
|
||||
|
||||
func TestCreateSubBasicTable_WithInvalidInput(t *testing.T) {
|
||||
columns := []string{"Col1", "Col2", "Col3"}
|
||||
rows := []*testBasicDataTableRow{
|
||||
{
|
||||
rowId: "1",
|
||||
rowColumns: []string{"A1", "B1", "C1"},
|
||||
},
|
||||
{
|
||||
rowId: "2",
|
||||
rowColumns: []string{"A2", "B2", "C2"},
|
||||
},
|
||||
}
|
||||
|
||||
basicDataTable := &testBasicDataTable{
|
||||
headerColumns: columns,
|
||||
rows: rows,
|
||||
}
|
||||
|
||||
subTable := CreateSubBasicTable(basicDataTable, -1, 2)
|
||||
assert.Equal(t, 0, subTable.fromIndex)
|
||||
assert.Equal(t, 2, subTable.toIndex)
|
||||
|
||||
subTable = CreateSubBasicTable(basicDataTable, 5, 2)
|
||||
assert.Equal(t, 2, subTable.fromIndex)
|
||||
assert.Equal(t, 2, subTable.toIndex)
|
||||
|
||||
subTable = CreateSubBasicTable(basicDataTable, 0, 5)
|
||||
assert.Equal(t, 0, subTable.fromIndex)
|
||||
assert.Equal(t, 2, subTable.toIndex)
|
||||
|
||||
subTable = CreateSubBasicTable(basicDataTable, 2, 1)
|
||||
assert.Equal(t, 2, subTable.fromIndex)
|
||||
assert.Equal(t, 2, subTable.toIndex)
|
||||
}
|
||||
|
||||
func TestSubBasicDataTable_DataRowIterator(t *testing.T) {
|
||||
columns := []string{"Col1", "Col2", "Col3"}
|
||||
rows := []*testBasicDataTableRow{
|
||||
{
|
||||
rowId: "1",
|
||||
rowColumns: []string{"A1", "B1", "C1"},
|
||||
},
|
||||
{
|
||||
rowId: "2",
|
||||
rowColumns: []string{"A2", "B2", "C2"},
|
||||
},
|
||||
{
|
||||
rowId: "3",
|
||||
rowColumns: []string{"A3", "B3", "C3"},
|
||||
},
|
||||
}
|
||||
|
||||
basicDataTable := &testBasicDataTable{
|
||||
headerColumns: columns,
|
||||
rows: rows,
|
||||
}
|
||||
|
||||
subTable := CreateSubBasicTable(basicDataTable, 1, 3)
|
||||
iterator := subTable.DataRowIterator()
|
||||
|
||||
assert.True(t, iterator.HasNext())
|
||||
firstRow := iterator.Next()
|
||||
assert.NotNil(t, firstRow)
|
||||
assert.Equal(t, "2", iterator.CurrentRowId())
|
||||
assert.Equal(t, "A2", firstRow.GetData(0))
|
||||
assert.Equal(t, "B2", firstRow.GetData(1))
|
||||
assert.Equal(t, "C2", firstRow.GetData(2))
|
||||
|
||||
assert.True(t, iterator.HasNext())
|
||||
secondRow := iterator.Next()
|
||||
assert.NotNil(t, secondRow)
|
||||
assert.Equal(t, "3", iterator.CurrentRowId())
|
||||
assert.Equal(t, "A3", secondRow.GetData(0))
|
||||
assert.Equal(t, "B3", secondRow.GetData(1))
|
||||
assert.Equal(t, "C3", secondRow.GetData(2))
|
||||
|
||||
assert.False(t, iterator.HasNext())
|
||||
assert.Nil(t, iterator.Next())
|
||||
}
|
||||
|
||||
func TestSubBasicDataTable_EmptyDataRange(t *testing.T) {
|
||||
columns := []string{"Col1", "Col2", "Col3"}
|
||||
rows := []*testBasicDataTableRow{
|
||||
{
|
||||
rowId: "1",
|
||||
rowColumns: []string{"A1", "B1", "C1"},
|
||||
},
|
||||
{
|
||||
rowId: "2",
|
||||
rowColumns: []string{"A2", "B2", "C2"},
|
||||
},
|
||||
}
|
||||
|
||||
basicDataTable := &testBasicDataTable{
|
||||
headerColumns: columns,
|
||||
rows: rows,
|
||||
}
|
||||
|
||||
subTable := CreateSubBasicTable(basicDataTable, 1, 1)
|
||||
assert.Equal(t, 0, subTable.DataRowCount())
|
||||
|
||||
iterator := subTable.DataRowIterator()
|
||||
assert.False(t, iterator.HasNext())
|
||||
assert.Nil(t, iterator.Next())
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package _default
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/converter"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||
)
|
||||
|
||||
var allJsonDataSupportedColumns = []datatable.TransactionDataTableColumn{
|
||||
datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME,
|
||||
datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE,
|
||||
datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE,
|
||||
datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY,
|
||||
datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME,
|
||||
datatable.TRANSACTION_DATA_TABLE_AMOUNT,
|
||||
datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME,
|
||||
datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT,
|
||||
datatable.TRANSACTION_DATA_TABLE_GEOGRAPHIC_LOCATION,
|
||||
datatable.TRANSACTION_DATA_TABLE_TAGS,
|
||||
datatable.TRANSACTION_DATA_TABLE_DESCRIPTION,
|
||||
}
|
||||
|
||||
// defaultTransactionDataJsonImporter defines the structure of ezbookkeeping default json importer for transaction data
|
||||
type defaultTransactionDataJsonImporter struct{}
|
||||
|
||||
// Initialize an ezbookkeeping default transaction data json file importer singleton instance
|
||||
var (
|
||||
DefaultTransactionDataJsonFileImporter = &defaultTransactionDataJsonImporter{}
|
||||
)
|
||||
|
||||
// ParseImportedData returns the imported data by parsing the transaction json data
|
||||
func (c *defaultTransactionDataJsonImporter) ParseImportedData(ctx core.Context, user *models.User, data []byte, defaultTimezoneOffset int16, 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) {
|
||||
var importRequest models.ImportTransactionRequest
|
||||
|
||||
if err := json.Unmarshal(data, &importRequest); err != nil {
|
||||
return nil, nil, nil, nil, nil, nil, errs.ErrInvalidJSONFile
|
||||
}
|
||||
|
||||
transactionDataTable, err := c.createNewDefaultTransactionDataTable(importRequest)
|
||||
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, nil, nil, err
|
||||
}
|
||||
|
||||
dataTableImporter := converter.CreateNewImporterWithTypeNameMapping(
|
||||
ezbookkeepingTransactionTypeNameMapping,
|
||||
ezbookkeepingGeoLocationSeparator,
|
||||
ezbookkeepingGeoLocationOrder,
|
||||
ezbookkeepingTagSeparator,
|
||||
)
|
||||
|
||||
return dataTableImporter.ParseImportedData(ctx, user, transactionDataTable, defaultTimezoneOffset, accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap)
|
||||
}
|
||||
|
||||
func (c *defaultTransactionDataJsonImporter) createNewDefaultTransactionDataTable(importRequest models.ImportTransactionRequest) (datatable.TransactionDataTable, error) {
|
||||
transactionDataTable := datatable.CreateNewWritableTransactionDataTable(allJsonDataSupportedColumns)
|
||||
|
||||
if importRequest.Transactions == nil || len(importRequest.Transactions) < 1 {
|
||||
return nil, errs.ErrNotFoundTransactionDataInFile
|
||||
}
|
||||
|
||||
for i := 0; i < len(importRequest.Transactions); i++ {
|
||||
transaction := importRequest.Transactions[i]
|
||||
|
||||
utcOffset, err := utils.StringToInt(transaction.UtcOffset)
|
||||
|
||||
if err != nil {
|
||||
return nil, errs.ErrTransactionTimeZoneInvalid
|
||||
}
|
||||
|
||||
timezone := time.FixedZone("Transaction Timezone", utcOffset*60)
|
||||
|
||||
row := make(map[datatable.TransactionDataTableColumn]string, len(allJsonDataSupportedColumns))
|
||||
row[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME] = transaction.Time
|
||||
row[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE] = utils.FormatTimezoneOffset(timezone)
|
||||
row[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = transaction.Type
|
||||
row[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] = transaction.CategoryName
|
||||
row[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = transaction.SourceAccountName
|
||||
row[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = transaction.SourceAmount
|
||||
row[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = transaction.DestinationAccountName
|
||||
row[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT] = transaction.DestinationAmount
|
||||
row[datatable.TRANSACTION_DATA_TABLE_GEOGRAPHIC_LOCATION] = transaction.GeoLocation
|
||||
row[datatable.TRANSACTION_DATA_TABLE_TAGS] = transaction.TagNames
|
||||
row[datatable.TRANSACTION_DATA_TABLE_DESCRIPTION] = transaction.Comment
|
||||
|
||||
transactionDataTable.Add(row)
|
||||
}
|
||||
|
||||
return transactionDataTable, nil
|
||||
}
|
||||
@@ -153,11 +153,7 @@ func (c *customTransactionDataDsvFileImporter) ParseImportedData(ctx core.Contex
|
||||
return nil, nil, nil, nil, nil, nil, err
|
||||
}
|
||||
|
||||
if !c.hasHeaderLine {
|
||||
allLines = append([][]string{{}}, allLines...)
|
||||
}
|
||||
|
||||
dataTable := csvconverter.CreateNewCustomCsvBasicDataTable(allLines)
|
||||
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)
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
type ExcelMSCFBFileBasicDataTable struct {
|
||||
workbook *xls.WorkBook
|
||||
headerLineColumnNames []string
|
||||
hasTitleLine bool
|
||||
}
|
||||
|
||||
// ExcelMSCFBFileBasicDataTableRow defines the structure of excel (microsoft compound file binary) file data table row
|
||||
@@ -26,7 +27,7 @@ type ExcelMSCFBFileBasicDataTableRow struct {
|
||||
type ExcelMSCFBFileBasicDataTableRowIterator struct {
|
||||
dataTable *ExcelMSCFBFileBasicDataTable
|
||||
currentSheetIndex int
|
||||
currentRowIndexInSheet uint16
|
||||
currentRowIndexInSheet int
|
||||
}
|
||||
|
||||
// DataRowCount returns the total count of data row
|
||||
@@ -36,11 +37,23 @@ func (t *ExcelMSCFBFileBasicDataTable) DataRowCount() int {
|
||||
for i := 0; i < t.workbook.NumSheets(); i++ {
|
||||
sheet := t.workbook.GetSheet(i)
|
||||
|
||||
if sheet.MaxRow < 1 {
|
||||
if sheet == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
totalDataRowCount += int(sheet.MaxRow)
|
||||
if t.hasTitleLine {
|
||||
if sheet.MaxRow < 1 {
|
||||
continue
|
||||
}
|
||||
|
||||
totalDataRowCount += int(sheet.MaxRow)
|
||||
} else {
|
||||
if sheet.MaxRow <= 0 && sheet.Row(0) == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
totalDataRowCount += int(sheet.MaxRow) + 1
|
||||
}
|
||||
}
|
||||
|
||||
return totalDataRowCount
|
||||
@@ -48,15 +61,25 @@ func (t *ExcelMSCFBFileBasicDataTable) DataRowCount() int {
|
||||
|
||||
// HeaderColumnNames returns the header column name list
|
||||
func (t *ExcelMSCFBFileBasicDataTable) HeaderColumnNames() []string {
|
||||
if !t.hasTitleLine {
|
||||
return nil
|
||||
}
|
||||
|
||||
return t.headerLineColumnNames
|
||||
}
|
||||
|
||||
// DataRowIterator returns the iterator of data row
|
||||
func (t *ExcelMSCFBFileBasicDataTable) DataRowIterator() datatable.BasicDataTableRowIterator {
|
||||
startIndex := -1
|
||||
|
||||
if t.hasTitleLine {
|
||||
startIndex = 0
|
||||
}
|
||||
|
||||
return &ExcelMSCFBFileBasicDataTableRowIterator{
|
||||
dataTable: t,
|
||||
currentSheetIndex: 0,
|
||||
currentRowIndexInSheet: 0,
|
||||
currentRowIndexInSheet: startIndex,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,15 +105,21 @@ func (t *ExcelMSCFBFileBasicDataTableRowIterator) HasNext() bool {
|
||||
|
||||
currentSheet := workbook.GetSheet(t.currentSheetIndex)
|
||||
|
||||
if t.currentRowIndexInSheet+1 <= currentSheet.MaxRow {
|
||||
if t.currentRowIndexInSheet+1 <= int(currentSheet.MaxRow) && currentSheet.Row(t.currentRowIndexInSheet+1) != nil {
|
||||
return true
|
||||
}
|
||||
|
||||
for i := t.currentSheetIndex + 1; i < workbook.NumSheets(); i++ {
|
||||
sheet := workbook.GetSheet(i)
|
||||
|
||||
if sheet.MaxRow < 1 {
|
||||
continue
|
||||
if t.dataTable.hasTitleLine {
|
||||
if sheet.MaxRow < 1 {
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
if sheet.MaxRow <= 0 && sheet.Row(0) == nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
@@ -107,20 +136,22 @@ func (t *ExcelMSCFBFileBasicDataTableRowIterator) CurrentRowId() string {
|
||||
// Next returns the next basic data row
|
||||
func (t *ExcelMSCFBFileBasicDataTableRowIterator) Next() datatable.BasicDataTableRow {
|
||||
workbook := t.dataTable.workbook
|
||||
currentRowIndexInTable := t.currentRowIndexInSheet
|
||||
|
||||
for i := t.currentSheetIndex; i < workbook.NumSheets(); i++ {
|
||||
sheet := workbook.GetSheet(i)
|
||||
|
||||
if currentRowIndexInTable+1 <= sheet.MaxRow {
|
||||
if t.currentRowIndexInSheet+1 <= int(sheet.MaxRow) && sheet.Row(t.currentRowIndexInSheet+1) != nil {
|
||||
t.currentRowIndexInSheet++
|
||||
currentRowIndexInTable = t.currentRowIndexInSheet
|
||||
break
|
||||
}
|
||||
|
||||
t.currentSheetIndex++
|
||||
t.currentRowIndexInSheet = 0
|
||||
currentRowIndexInTable = 0
|
||||
|
||||
if t.dataTable.hasTitleLine {
|
||||
t.currentRowIndexInSheet = 0
|
||||
} else {
|
||||
t.currentRowIndexInSheet = -1
|
||||
}
|
||||
}
|
||||
|
||||
if t.currentSheetIndex >= workbook.NumSheets() {
|
||||
@@ -129,7 +160,7 @@ func (t *ExcelMSCFBFileBasicDataTableRowIterator) Next() datatable.BasicDataTabl
|
||||
|
||||
currentSheet := workbook.GetSheet(t.currentSheetIndex)
|
||||
|
||||
if t.currentRowIndexInSheet > currentSheet.MaxRow {
|
||||
if t.currentRowIndexInSheet > int(currentSheet.MaxRow) || currentSheet.Row(t.currentRowIndexInSheet) == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -140,7 +171,7 @@ func (t *ExcelMSCFBFileBasicDataTableRowIterator) Next() datatable.BasicDataTabl
|
||||
}
|
||||
|
||||
// CreateNewExcelMSCFBFileBasicDataTable returns excel (microsoft compound file binary) data table by file binary data
|
||||
func CreateNewExcelMSCFBFileBasicDataTable(data []byte) (datatable.BasicDataTable, error) {
|
||||
func CreateNewExcelMSCFBFileBasicDataTable(data []byte, hasTitleLine bool) (datatable.BasicDataTable, error) {
|
||||
reader := bytes.NewReader(data)
|
||||
workbook, err := xls.OpenReader(reader, "")
|
||||
|
||||
@@ -148,12 +179,12 @@ func CreateNewExcelMSCFBFileBasicDataTable(data []byte) (datatable.BasicDataTabl
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var headerRowItems []string
|
||||
var firstRowItems []string
|
||||
|
||||
for i := 0; i < workbook.NumSheets(); i++ {
|
||||
sheet := workbook.GetSheet(i)
|
||||
|
||||
if sheet.MaxRow < 0 {
|
||||
if sheet.MaxRow <= 0 && sheet.Row(0) == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -171,21 +202,28 @@ func CreateNewExcelMSCFBFileBasicDataTable(data []byte) (datatable.BasicDataTabl
|
||||
break
|
||||
}
|
||||
|
||||
headerRowItems = append(headerRowItems, headerItem)
|
||||
firstRowItems = append(firstRowItems, headerItem)
|
||||
}
|
||||
} else {
|
||||
for j := 0; j <= min(row.LastCol(), len(headerRowItems)-1); j++ {
|
||||
for j := 0; j <= min(row.LastCol(), len(firstRowItems)-1); j++ {
|
||||
headerItem := row.Col(j)
|
||||
|
||||
if headerItem != headerRowItems[j] {
|
||||
if headerItem != firstRowItems[j] {
|
||||
return nil, errs.ErrFieldsInMultiTableAreDifferent
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var headerLineColumnNames []string = nil
|
||||
|
||||
if hasTitleLine {
|
||||
headerLineColumnNames = firstRowItems
|
||||
}
|
||||
|
||||
return &ExcelMSCFBFileBasicDataTable{
|
||||
workbook: workbook,
|
||||
headerLineColumnNames: headerRowItems,
|
||||
headerLineColumnNames: headerLineColumnNames,
|
||||
hasTitleLine: hasTitleLine,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -13,7 +13,16 @@ func TestExcelMSCFBFileBasicDataTableDataRowCount(t *testing.T) {
|
||||
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xls")
|
||||
assert.Nil(t, err)
|
||||
|
||||
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata)
|
||||
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata, false)
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, 3, datatable.DataRowCount())
|
||||
}
|
||||
|
||||
func TestExcelMSCFBFileBasicDataTableDataRowCount_HasTitleLine(t *testing.T) {
|
||||
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xls")
|
||||
assert.Nil(t, err)
|
||||
|
||||
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata, true)
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, 2, datatable.DataRowCount())
|
||||
}
|
||||
@@ -22,7 +31,16 @@ func TestExcelMSCFBFileBasicDataTableDataRowCount_MultipleSheets(t *testing.T) {
|
||||
testdata, err := os.ReadFile("../../../testdata/multiple_sheets_excel_file.xls")
|
||||
assert.Nil(t, err)
|
||||
|
||||
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata)
|
||||
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata, false)
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, 9, datatable.DataRowCount())
|
||||
}
|
||||
|
||||
func TestExcelMSCFBFileBasicDataTableDataRowCount_MultipleSheets_HasTitleLine(t *testing.T) {
|
||||
testdata, err := os.ReadFile("../../../testdata/multiple_sheets_excel_file.xls")
|
||||
assert.Nil(t, err)
|
||||
|
||||
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata, true)
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, 5, datatable.DataRowCount())
|
||||
}
|
||||
@@ -31,7 +49,7 @@ func TestExcelMSCFBFileBasicDataTableDataRowCount_OnlyHeaderLine(t *testing.T) {
|
||||
testdata, err := os.ReadFile("../../../testdata/only_one_row_excel_file.xls")
|
||||
assert.Nil(t, err)
|
||||
|
||||
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata)
|
||||
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata, true)
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, 0, datatable.DataRowCount())
|
||||
}
|
||||
@@ -40,7 +58,11 @@ func TestExcelMSCFBFileBasicDataTableDataRowCount_EmptyContent(t *testing.T) {
|
||||
testdata, err := os.ReadFile("../../../testdata/empty_excel_file.xls")
|
||||
assert.Nil(t, err)
|
||||
|
||||
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata)
|
||||
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata, false)
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, 0, datatable.DataRowCount())
|
||||
|
||||
datatable, err = CreateNewExcelMSCFBFileBasicDataTable(testdata, true)
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, 0, datatable.DataRowCount())
|
||||
}
|
||||
@@ -49,7 +71,17 @@ func TestExcelMSCFBFileBasicDataTableHeaderColumnNames(t *testing.T) {
|
||||
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xls")
|
||||
assert.Nil(t, err)
|
||||
|
||||
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata)
|
||||
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata, false)
|
||||
assert.Nil(t, err)
|
||||
assert.Nil(t, datatable.HeaderColumnNames())
|
||||
}
|
||||
|
||||
func TestExcelMSCFBFileBasicDataTableHeaderColumnNames_HasTitleLine(t *testing.T) {
|
||||
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xls")
|
||||
assert.Nil(t, err)
|
||||
|
||||
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata, true)
|
||||
assert.Nil(t, err)
|
||||
assert.EqualValues(t, []string{"A1", "B1", "C1"}, datatable.HeaderColumnNames())
|
||||
}
|
||||
|
||||
@@ -57,15 +89,47 @@ func TestExcelMSCFBFileBasicDataTableHeaderColumnNames_EmptyContent(t *testing.T
|
||||
testdata, err := os.ReadFile("../../../testdata/empty_excel_file.xls")
|
||||
assert.Nil(t, err)
|
||||
|
||||
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata)
|
||||
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata, false)
|
||||
assert.Nil(t, err)
|
||||
assert.Nil(t, datatable.HeaderColumnNames())
|
||||
|
||||
datatable, err = CreateNewExcelMSCFBFileBasicDataTable(testdata, true)
|
||||
assert.Nil(t, err)
|
||||
assert.Nil(t, datatable.HeaderColumnNames())
|
||||
}
|
||||
|
||||
func TestExcelMSCFBFileBasicDataTableRowIterator(t *testing.T) {
|
||||
func TestExcelMSCFBFileBasicDataRowIterator(t *testing.T) {
|
||||
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xls")
|
||||
assert.Nil(t, err)
|
||||
|
||||
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata)
|
||||
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata, false)
|
||||
assert.Nil(t, err)
|
||||
iterator := datatable.DataRowIterator()
|
||||
assert.True(t, iterator.HasNext())
|
||||
|
||||
// data row 1
|
||||
assert.NotNil(t, iterator.Next())
|
||||
assert.True(t, iterator.HasNext())
|
||||
|
||||
// data row 2
|
||||
assert.NotNil(t, iterator.Next())
|
||||
assert.True(t, iterator.HasNext())
|
||||
|
||||
// data row 3
|
||||
assert.NotNil(t, iterator.Next())
|
||||
assert.False(t, iterator.HasNext())
|
||||
|
||||
// not existed data row 4
|
||||
assert.Nil(t, iterator.Next())
|
||||
assert.False(t, iterator.HasNext())
|
||||
}
|
||||
|
||||
func TestExcelMSCFBFileBasicDataRowIterator_HasTitleLine(t *testing.T) {
|
||||
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xls")
|
||||
assert.Nil(t, err)
|
||||
|
||||
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata, true)
|
||||
assert.Nil(t, err)
|
||||
iterator := datatable.DataRowIterator()
|
||||
assert.True(t, iterator.HasNext())
|
||||
|
||||
@@ -86,11 +150,66 @@ func TestExcelMSCFBFileBasicDataTableRowIterator(t *testing.T) {
|
||||
assert.False(t, iterator.HasNext())
|
||||
}
|
||||
|
||||
func TestExcelMSCFBFileBasicDataTableRowIterator_MultipleSheets(t *testing.T) {
|
||||
func TestExcelMSCFBFileBasicDataRowIterator_MultipleSheets(t *testing.T) {
|
||||
testdata, err := os.ReadFile("../../../testdata/multiple_sheets_excel_file.xls")
|
||||
assert.Nil(t, err)
|
||||
|
||||
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata)
|
||||
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata, false)
|
||||
assert.Nil(t, err)
|
||||
iterator := datatable.DataRowIterator()
|
||||
assert.True(t, iterator.HasNext())
|
||||
|
||||
// sheet 1 data row 1
|
||||
assert.NotNil(t, iterator.Next())
|
||||
assert.True(t, iterator.HasNext())
|
||||
|
||||
// sheet 1 data row 2
|
||||
assert.NotNil(t, iterator.Next())
|
||||
assert.True(t, iterator.HasNext())
|
||||
|
||||
// sheet 1 data row 3
|
||||
assert.NotNil(t, iterator.Next())
|
||||
assert.True(t, iterator.HasNext())
|
||||
|
||||
// sheet 3 data row 1
|
||||
assert.NotNil(t, iterator.Next())
|
||||
assert.True(t, iterator.HasNext())
|
||||
|
||||
// sheet 3 data row 2
|
||||
assert.NotNil(t, iterator.Next())
|
||||
assert.True(t, iterator.HasNext())
|
||||
|
||||
// sheet 4 data row 1
|
||||
assert.NotNil(t, iterator.Next())
|
||||
assert.True(t, iterator.HasNext())
|
||||
|
||||
// sheet 5 data row 1
|
||||
assert.NotNil(t, iterator.Next())
|
||||
assert.True(t, iterator.HasNext())
|
||||
|
||||
// sheet 5 data row 2
|
||||
assert.NotNil(t, iterator.Next())
|
||||
assert.True(t, iterator.HasNext())
|
||||
|
||||
// sheet 5 data row 3
|
||||
assert.NotNil(t, iterator.Next())
|
||||
assert.False(t, iterator.HasNext())
|
||||
|
||||
// not existed data row
|
||||
assert.Nil(t, iterator.Next())
|
||||
assert.False(t, iterator.HasNext())
|
||||
|
||||
// not existed data row
|
||||
assert.Nil(t, iterator.Next())
|
||||
assert.False(t, iterator.HasNext())
|
||||
}
|
||||
|
||||
func TestExcelMSCFBFileBasicDataRowIterator_MultipleSheets_HasTitleLine(t *testing.T) {
|
||||
testdata, err := os.ReadFile("../../../testdata/multiple_sheets_excel_file.xls")
|
||||
assert.Nil(t, err)
|
||||
|
||||
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata, true)
|
||||
assert.Nil(t, err)
|
||||
iterator := datatable.DataRowIterator()
|
||||
assert.True(t, iterator.HasNext())
|
||||
|
||||
@@ -123,11 +242,12 @@ func TestExcelMSCFBFileBasicDataTableRowIterator_MultipleSheets(t *testing.T) {
|
||||
assert.False(t, iterator.HasNext())
|
||||
}
|
||||
|
||||
func TestExcelMSCFBFileBasicDataTableRowIterator_OnlyHeaderLine(t *testing.T) {
|
||||
func TestExcelMSCFBFileBasicDataRowIterator_OnlyHeaderLine(t *testing.T) {
|
||||
testdata, err := os.ReadFile("../../../testdata/only_one_row_excel_file.xls")
|
||||
assert.Nil(t, err)
|
||||
|
||||
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata)
|
||||
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata, true)
|
||||
assert.Nil(t, err)
|
||||
iterator := datatable.DataRowIterator()
|
||||
assert.False(t, iterator.HasNext())
|
||||
|
||||
@@ -140,11 +260,12 @@ func TestExcelMSCFBFileBasicDataTableRowIterator_OnlyHeaderLine(t *testing.T) {
|
||||
assert.False(t, iterator.HasNext())
|
||||
}
|
||||
|
||||
func TestExcelMSCFBFileBasicDataTableRowIterator_EmptyContent(t *testing.T) {
|
||||
func TestExcelMSCFBFileBasicDataRowIterator_EmptyContent(t *testing.T) {
|
||||
testdata, err := os.ReadFile("../../../testdata/empty_excel_file.xls")
|
||||
assert.Nil(t, err)
|
||||
|
||||
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata)
|
||||
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata, false)
|
||||
assert.Nil(t, err)
|
||||
iterator := datatable.DataRowIterator()
|
||||
assert.False(t, iterator.HasNext())
|
||||
|
||||
@@ -155,13 +276,27 @@ func TestExcelMSCFBFileBasicDataTableRowIterator_EmptyContent(t *testing.T) {
|
||||
// not existed data row 2
|
||||
assert.Nil(t, iterator.Next())
|
||||
assert.False(t, iterator.HasNext())
|
||||
|
||||
datatable, err = CreateNewExcelMSCFBFileBasicDataTable(testdata, true)
|
||||
assert.Nil(t, err)
|
||||
iterator = datatable.DataRowIterator()
|
||||
assert.False(t, iterator.HasNext())
|
||||
|
||||
// not existed data row 1
|
||||
assert.Nil(t, iterator.Next())
|
||||
assert.False(t, iterator.HasNext())
|
||||
|
||||
// not existed data row 2
|
||||
assert.Nil(t, iterator.Next())
|
||||
assert.False(t, iterator.HasNext())
|
||||
}
|
||||
|
||||
func TestExcelMSCFBFileBasicDataTableRowColumnCount(t *testing.T) {
|
||||
func TestExcelMSCFBFileBasicDataRowColumnCount(t *testing.T) {
|
||||
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xls")
|
||||
assert.Nil(t, err)
|
||||
|
||||
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata)
|
||||
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata, false)
|
||||
assert.Nil(t, err)
|
||||
iterator := datatable.DataRowIterator()
|
||||
|
||||
row1 := iterator.Next()
|
||||
@@ -171,11 +306,36 @@ func TestExcelMSCFBFileBasicDataTableRowColumnCount(t *testing.T) {
|
||||
assert.EqualValues(t, 4, row2.ColumnCount())
|
||||
}
|
||||
|
||||
func TestExcelMSCFBFileBasicDataTableRowGetData(t *testing.T) {
|
||||
func TestExcelMSCFBFileBasicDataRowGetData(t *testing.T) {
|
||||
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xls")
|
||||
assert.Nil(t, err)
|
||||
|
||||
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata)
|
||||
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata, false)
|
||||
assert.Nil(t, err)
|
||||
iterator := datatable.DataRowIterator()
|
||||
|
||||
row1 := iterator.Next()
|
||||
assert.Equal(t, "A1", row1.GetData(0))
|
||||
assert.Equal(t, "B1", row1.GetData(1))
|
||||
assert.Equal(t, "C1", row1.GetData(2))
|
||||
|
||||
row2 := iterator.Next()
|
||||
assert.Equal(t, "A2", row2.GetData(0))
|
||||
assert.Equal(t, "B2", row2.GetData(1))
|
||||
assert.Equal(t, "C2", row2.GetData(2))
|
||||
|
||||
row3 := iterator.Next()
|
||||
assert.Equal(t, "A3", row3.GetData(0))
|
||||
assert.Equal(t, "B3", row3.GetData(1))
|
||||
assert.Equal(t, "C3", row3.GetData(2))
|
||||
}
|
||||
|
||||
func TestExcelMSCFBFileBasicDataRowGetData_HasTitleLine(t *testing.T) {
|
||||
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xls")
|
||||
assert.Nil(t, err)
|
||||
|
||||
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata, true)
|
||||
assert.Nil(t, err)
|
||||
iterator := datatable.DataRowIterator()
|
||||
|
||||
row1 := iterator.Next()
|
||||
@@ -189,22 +349,80 @@ func TestExcelMSCFBFileBasicDataTableRowGetData(t *testing.T) {
|
||||
assert.Equal(t, "C3", row2.GetData(2))
|
||||
}
|
||||
|
||||
func TestExcelMSCFBFileBasicDataTableRowGetData_GetNotExistedColumnData(t *testing.T) {
|
||||
func TestExcelMSCFBFileBasicDataRowGetData_GetNotExistedColumnData(t *testing.T) {
|
||||
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xls")
|
||||
assert.Nil(t, err)
|
||||
|
||||
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata)
|
||||
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata, false)
|
||||
assert.Nil(t, err)
|
||||
iterator := datatable.DataRowIterator()
|
||||
|
||||
row1 := iterator.Next()
|
||||
assert.Equal(t, "", row1.GetData(3))
|
||||
}
|
||||
|
||||
func TestExcelMSCFBFileBasicDataTableRowGetData_MultipleSheets(t *testing.T) {
|
||||
func TestExcelMSCFBFileBasicDataRowGetData_MultipleSheets(t *testing.T) {
|
||||
testdata, err := os.ReadFile("../../../testdata/multiple_sheets_excel_file.xls")
|
||||
assert.Nil(t, err)
|
||||
|
||||
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata)
|
||||
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata, false)
|
||||
assert.Nil(t, err)
|
||||
iterator := datatable.DataRowIterator()
|
||||
|
||||
sheet1Row1 := iterator.Next()
|
||||
assert.Equal(t, "A1", sheet1Row1.GetData(0))
|
||||
assert.Equal(t, "B1", sheet1Row1.GetData(1))
|
||||
assert.Equal(t, "C1", sheet1Row1.GetData(2))
|
||||
|
||||
sheet1Row2 := iterator.Next()
|
||||
assert.Equal(t, "1-A2", sheet1Row2.GetData(0))
|
||||
assert.Equal(t, "1-B2", sheet1Row2.GetData(1))
|
||||
assert.Equal(t, "1-C2", sheet1Row2.GetData(2))
|
||||
|
||||
sheet1Row3 := iterator.Next()
|
||||
assert.Equal(t, "1-A3", sheet1Row3.GetData(0))
|
||||
assert.Equal(t, "1-B3", sheet1Row3.GetData(1))
|
||||
assert.Equal(t, "1-C3", sheet1Row3.GetData(2))
|
||||
|
||||
// skip empty sheet2
|
||||
|
||||
sheet3Row1 := iterator.Next()
|
||||
assert.Equal(t, "A1", sheet3Row1.GetData(0))
|
||||
assert.Equal(t, "B1", sheet3Row1.GetData(1))
|
||||
assert.Equal(t, "C1", sheet3Row1.GetData(2))
|
||||
|
||||
sheet3Row2 := iterator.Next()
|
||||
assert.Equal(t, "3-A2", sheet3Row2.GetData(0))
|
||||
assert.Equal(t, "3-B2", sheet3Row2.GetData(1))
|
||||
assert.Equal(t, "", sheet3Row2.GetData(2))
|
||||
|
||||
sheet4Row1 := iterator.Next()
|
||||
assert.Equal(t, "A1", sheet4Row1.GetData(0))
|
||||
assert.Equal(t, "B1", sheet4Row1.GetData(1))
|
||||
assert.Equal(t, "C1", sheet4Row1.GetData(2))
|
||||
|
||||
sheet5Row1 := iterator.Next()
|
||||
assert.Equal(t, "A1", sheet5Row1.GetData(0))
|
||||
assert.Equal(t, "B1", sheet5Row1.GetData(1))
|
||||
assert.Equal(t, "C1", sheet5Row1.GetData(2))
|
||||
|
||||
sheet5Row2 := iterator.Next()
|
||||
assert.Equal(t, "5-A2", sheet5Row2.GetData(0))
|
||||
assert.Equal(t, "5-B2", sheet5Row2.GetData(1))
|
||||
assert.Equal(t, "5-C2", sheet5Row2.GetData(2))
|
||||
|
||||
sheet5Row3 := iterator.Next()
|
||||
assert.Equal(t, "5-A3", sheet5Row3.GetData(0))
|
||||
assert.Equal(t, "5-B3", sheet5Row3.GetData(1))
|
||||
assert.Equal(t, "5-C3", sheet5Row3.GetData(2))
|
||||
}
|
||||
|
||||
func TestExcelMSCFBFileBasicDataRowGetData_MultipleSheets_HasTitleLine(t *testing.T) {
|
||||
testdata, err := os.ReadFile("../../../testdata/multiple_sheets_excel_file.xls")
|
||||
assert.Nil(t, err)
|
||||
|
||||
datatable, err := CreateNewExcelMSCFBFileBasicDataTable(testdata, true)
|
||||
assert.Nil(t, err)
|
||||
iterator := datatable.DataRowIterator()
|
||||
|
||||
sheet1Row1 := iterator.Next()
|
||||
@@ -241,6 +459,6 @@ func TestCreateNewExcelMSCFBFileBasicDataTable_MultipleSheetsWithDifferentHeader
|
||||
testdata, err := os.ReadFile("../../../testdata/multiple_sheets_with_different_header_row_excel_file.xls")
|
||||
assert.Nil(t, err)
|
||||
|
||||
_, err = CreateNewExcelMSCFBFileBasicDataTable(testdata)
|
||||
_, err = CreateNewExcelMSCFBFileBasicDataTable(testdata, true)
|
||||
assert.EqualError(t, err, errs.ErrFieldsInMultiTableAreDifferent.Message)
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ type excelOOXMLSheet struct {
|
||||
type ExcelOOXMLFileBasicDataTable struct {
|
||||
sheets []*excelOOXMLSheet
|
||||
headerLineColumnNames []string
|
||||
hasTitleLine bool
|
||||
}
|
||||
|
||||
// ExcelOOXMLFileBasicDataTableRow defines the structure of excel (Office Open XML) file data table row
|
||||
@@ -47,7 +48,11 @@ func (t *ExcelOOXMLFileBasicDataTable) DataRowCount() int {
|
||||
continue
|
||||
}
|
||||
|
||||
totalDataRowCount += len(sheet.allData) - 1
|
||||
if t.hasTitleLine {
|
||||
totalDataRowCount += len(sheet.allData) - 1
|
||||
} else {
|
||||
totalDataRowCount += len(sheet.allData)
|
||||
}
|
||||
}
|
||||
|
||||
return totalDataRowCount
|
||||
@@ -55,15 +60,25 @@ func (t *ExcelOOXMLFileBasicDataTable) DataRowCount() int {
|
||||
|
||||
// HeaderColumnNames returns the header column name list
|
||||
func (t *ExcelOOXMLFileBasicDataTable) HeaderColumnNames() []string {
|
||||
if !t.hasTitleLine {
|
||||
return nil
|
||||
}
|
||||
|
||||
return t.headerLineColumnNames
|
||||
}
|
||||
|
||||
// DataRowIterator returns the iterator of data row
|
||||
func (t *ExcelOOXMLFileBasicDataTable) DataRowIterator() datatable.BasicDataTableRowIterator {
|
||||
startIndex := -1
|
||||
|
||||
if t.hasTitleLine {
|
||||
startIndex = 0
|
||||
}
|
||||
|
||||
return &ExcelOOXMLFileBasicDataTableRowIterator{
|
||||
dataTable: t,
|
||||
currentSheetIndex: 0,
|
||||
currentRowIndexInSheet: 0,
|
||||
currentRowIndexInSheet: startIndex,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,8 +113,14 @@ func (t *ExcelOOXMLFileBasicDataTableRowIterator) HasNext() bool {
|
||||
for i := t.currentSheetIndex + 1; i < len(sheets); i++ {
|
||||
sheet := sheets[i]
|
||||
|
||||
if len(sheet.allData) <= 1 {
|
||||
continue
|
||||
if t.dataTable.hasTitleLine {
|
||||
if len(sheet.allData) <= 1 {
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
if len(sheet.allData) <= 0 {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
@@ -116,20 +137,22 @@ func (t *ExcelOOXMLFileBasicDataTableRowIterator) CurrentRowId() string {
|
||||
// Next returns the next basic data row
|
||||
func (t *ExcelOOXMLFileBasicDataTableRowIterator) Next() datatable.BasicDataTableRow {
|
||||
sheets := t.dataTable.sheets
|
||||
currentRowIndexInTable := t.currentRowIndexInSheet
|
||||
|
||||
for i := t.currentSheetIndex; i < len(sheets); i++ {
|
||||
sheet := sheets[i]
|
||||
|
||||
if currentRowIndexInTable+1 < len(sheet.allData) {
|
||||
if t.currentRowIndexInSheet+1 < len(sheet.allData) {
|
||||
t.currentRowIndexInSheet++
|
||||
currentRowIndexInTable = t.currentRowIndexInSheet
|
||||
break
|
||||
}
|
||||
|
||||
t.currentSheetIndex++
|
||||
t.currentRowIndexInSheet = 0
|
||||
currentRowIndexInTable = 0
|
||||
|
||||
if t.dataTable.hasTitleLine {
|
||||
t.currentRowIndexInSheet = 0
|
||||
} else {
|
||||
t.currentRowIndexInSheet = -1
|
||||
}
|
||||
}
|
||||
|
||||
if t.currentSheetIndex >= len(sheets) {
|
||||
@@ -150,7 +173,7 @@ func (t *ExcelOOXMLFileBasicDataTableRowIterator) Next() datatable.BasicDataTabl
|
||||
}
|
||||
|
||||
// CreateNewExcelOOXMLFileBasicDataTable returns excel (Office Open XML) data table by file binary data
|
||||
func CreateNewExcelOOXMLFileBasicDataTable(data []byte) (datatable.BasicDataTable, error) {
|
||||
func CreateNewExcelOOXMLFileBasicDataTable(data []byte, hasTitleLine bool) (datatable.BasicDataTable, error) {
|
||||
reader := bytes.NewReader(data)
|
||||
file, err := excelize.OpenReader(reader)
|
||||
|
||||
@@ -161,7 +184,7 @@ func CreateNewExcelOOXMLFileBasicDataTable(data []byte) (datatable.BasicDataTabl
|
||||
}
|
||||
|
||||
sheetNames := file.GetSheetList()
|
||||
var headerRowItems []string
|
||||
var firstRowItems []string
|
||||
var sheets []*excelOOXMLSheet
|
||||
|
||||
for i := 0; i < len(sheetNames); i++ {
|
||||
@@ -186,13 +209,13 @@ func CreateNewExcelOOXMLFileBasicDataTable(data []byte) (datatable.BasicDataTabl
|
||||
break
|
||||
}
|
||||
|
||||
headerRowItems = append(headerRowItems, headerItem)
|
||||
firstRowItems = append(firstRowItems, headerItem)
|
||||
}
|
||||
} else {
|
||||
for j := 0; j < min(len(row), len(headerRowItems)); j++ {
|
||||
for j := 0; j < min(len(row), len(firstRowItems)); j++ {
|
||||
headerItem := row[j]
|
||||
|
||||
if headerItem != headerRowItems[j] {
|
||||
if headerItem != firstRowItems[j] {
|
||||
return nil, errs.ErrFieldsInMultiTableAreDifferent
|
||||
}
|
||||
}
|
||||
@@ -204,8 +227,15 @@ func CreateNewExcelOOXMLFileBasicDataTable(data []byte) (datatable.BasicDataTabl
|
||||
})
|
||||
}
|
||||
|
||||
var headerLineColumnNames []string = nil
|
||||
|
||||
if hasTitleLine {
|
||||
headerLineColumnNames = firstRowItems
|
||||
}
|
||||
|
||||
return &ExcelOOXMLFileBasicDataTable{
|
||||
sheets: sheets,
|
||||
headerLineColumnNames: headerRowItems,
|
||||
headerLineColumnNames: headerLineColumnNames,
|
||||
hasTitleLine: hasTitleLine,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -13,7 +13,16 @@ func TestExcelOOXMLFileBasicDataTableDataRowCount(t *testing.T) {
|
||||
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xlsx")
|
||||
assert.Nil(t, err)
|
||||
|
||||
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata)
|
||||
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata, false)
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, 3, datatable.DataRowCount())
|
||||
}
|
||||
|
||||
func TestExcelOOXMLFileBasicDataTableDataRowCount_HasTitleLine(t *testing.T) {
|
||||
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xlsx")
|
||||
assert.Nil(t, err)
|
||||
|
||||
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata, true)
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, 2, datatable.DataRowCount())
|
||||
}
|
||||
@@ -22,7 +31,16 @@ func TestExcelOOXMLFileBasicDataTableDataRowCount_MultipleSheets(t *testing.T) {
|
||||
testdata, err := os.ReadFile("../../../testdata/multiple_sheets_excel_file.xlsx")
|
||||
assert.Nil(t, err)
|
||||
|
||||
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata)
|
||||
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata, false)
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, 9, datatable.DataRowCount())
|
||||
}
|
||||
|
||||
func TestExcelOOXMLFileBasicDataTableDataRowCount_MultipleSheets_HasTitleLine(t *testing.T) {
|
||||
testdata, err := os.ReadFile("../../../testdata/multiple_sheets_excel_file.xlsx")
|
||||
assert.Nil(t, err)
|
||||
|
||||
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata, true)
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, 5, datatable.DataRowCount())
|
||||
}
|
||||
@@ -31,7 +49,7 @@ func TestExcelOOXMLFileBasicDataTableDataRowCount_OnlyHeaderLine(t *testing.T) {
|
||||
testdata, err := os.ReadFile("../../../testdata/only_one_row_excel_file.xlsx")
|
||||
assert.Nil(t, err)
|
||||
|
||||
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata)
|
||||
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata, true)
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, 0, datatable.DataRowCount())
|
||||
}
|
||||
@@ -40,7 +58,11 @@ func TestExcelOOXMLFileBasicDataTableDataRowCount_EmptyContent(t *testing.T) {
|
||||
testdata, err := os.ReadFile("../../../testdata/empty_excel_file.xlsx")
|
||||
assert.Nil(t, err)
|
||||
|
||||
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata)
|
||||
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata, false)
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, 0, datatable.DataRowCount())
|
||||
|
||||
datatable, err = CreateNewExcelOOXMLFileBasicDataTable(testdata, true)
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, 0, datatable.DataRowCount())
|
||||
}
|
||||
@@ -49,7 +71,17 @@ func TestExcelOOXMLFileBasicDataTableHeaderColumnNames(t *testing.T) {
|
||||
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xlsx")
|
||||
assert.Nil(t, err)
|
||||
|
||||
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata)
|
||||
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata, false)
|
||||
assert.Nil(t, err)
|
||||
assert.Nil(t, datatable.HeaderColumnNames())
|
||||
}
|
||||
|
||||
func TestExcelOOXMLFileBasicDataTableHeaderColumnNames_HasTitleLine(t *testing.T) {
|
||||
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xlsx")
|
||||
assert.Nil(t, err)
|
||||
|
||||
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata, true)
|
||||
assert.Nil(t, err)
|
||||
assert.EqualValues(t, []string{"A1", "B1", "C1"}, datatable.HeaderColumnNames())
|
||||
}
|
||||
|
||||
@@ -57,7 +89,12 @@ func TestExcelOOXMLFileBasicDataTableHeaderColumnNames_EmptyContent(t *testing.T
|
||||
testdata, err := os.ReadFile("../../../testdata/empty_excel_file.xlsx")
|
||||
assert.Nil(t, err)
|
||||
|
||||
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata)
|
||||
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata, false)
|
||||
assert.Nil(t, err)
|
||||
assert.Nil(t, datatable.HeaderColumnNames())
|
||||
|
||||
datatable, err = CreateNewExcelOOXMLFileBasicDataTable(testdata, true)
|
||||
assert.Nil(t, err)
|
||||
assert.Nil(t, datatable.HeaderColumnNames())
|
||||
}
|
||||
|
||||
@@ -65,7 +102,34 @@ func TestExcelOOXMLFileBasicDataRowIterator(t *testing.T) {
|
||||
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xlsx")
|
||||
assert.Nil(t, err)
|
||||
|
||||
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata)
|
||||
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata, false)
|
||||
assert.Nil(t, err)
|
||||
iterator := datatable.DataRowIterator()
|
||||
assert.True(t, iterator.HasNext())
|
||||
|
||||
// data row 1
|
||||
assert.NotNil(t, iterator.Next())
|
||||
assert.True(t, iterator.HasNext())
|
||||
|
||||
// data row 2
|
||||
assert.NotNil(t, iterator.Next())
|
||||
assert.True(t, iterator.HasNext())
|
||||
|
||||
// data row 3
|
||||
assert.NotNil(t, iterator.Next())
|
||||
assert.False(t, iterator.HasNext())
|
||||
|
||||
// not existed data row 4
|
||||
assert.Nil(t, iterator.Next())
|
||||
assert.False(t, iterator.HasNext())
|
||||
}
|
||||
|
||||
func TestExcelOOXMLFileBasicDataRowIterator_HasTitleLine(t *testing.T) {
|
||||
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xlsx")
|
||||
assert.Nil(t, err)
|
||||
|
||||
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata, true)
|
||||
assert.Nil(t, err)
|
||||
iterator := datatable.DataRowIterator()
|
||||
assert.True(t, iterator.HasNext())
|
||||
|
||||
@@ -90,7 +154,62 @@ func TestExcelOOXMLFileBasicDataRowIterator_MultipleSheets(t *testing.T) {
|
||||
testdata, err := os.ReadFile("../../../testdata/multiple_sheets_excel_file.xlsx")
|
||||
assert.Nil(t, err)
|
||||
|
||||
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata)
|
||||
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata, false)
|
||||
assert.Nil(t, err)
|
||||
iterator := datatable.DataRowIterator()
|
||||
assert.True(t, iterator.HasNext())
|
||||
|
||||
// sheet 1 data row 1
|
||||
assert.NotNil(t, iterator.Next())
|
||||
assert.True(t, iterator.HasNext())
|
||||
|
||||
// sheet 1 data row 2
|
||||
assert.NotNil(t, iterator.Next())
|
||||
assert.True(t, iterator.HasNext())
|
||||
|
||||
// sheet 1 data row 3
|
||||
assert.NotNil(t, iterator.Next())
|
||||
assert.True(t, iterator.HasNext())
|
||||
|
||||
// sheet 3 data row 1
|
||||
assert.NotNil(t, iterator.Next())
|
||||
assert.True(t, iterator.HasNext())
|
||||
|
||||
// sheet 3 data row 2
|
||||
assert.NotNil(t, iterator.Next())
|
||||
assert.True(t, iterator.HasNext())
|
||||
|
||||
// sheet 4 data row 1
|
||||
assert.NotNil(t, iterator.Next())
|
||||
assert.True(t, iterator.HasNext())
|
||||
|
||||
// sheet 5 data row 1
|
||||
assert.NotNil(t, iterator.Next())
|
||||
assert.True(t, iterator.HasNext())
|
||||
|
||||
// sheet 5 data row 2
|
||||
assert.NotNil(t, iterator.Next())
|
||||
assert.True(t, iterator.HasNext())
|
||||
|
||||
// sheet 5 data row 3
|
||||
assert.NotNil(t, iterator.Next())
|
||||
assert.False(t, iterator.HasNext())
|
||||
|
||||
// not existed data row
|
||||
assert.Nil(t, iterator.Next())
|
||||
assert.False(t, iterator.HasNext())
|
||||
|
||||
// not existed data row
|
||||
assert.Nil(t, iterator.Next())
|
||||
assert.False(t, iterator.HasNext())
|
||||
}
|
||||
|
||||
func TestExcelOOXMLFileBasicDataRowIterator_MultipleSheets_HasTitleLine(t *testing.T) {
|
||||
testdata, err := os.ReadFile("../../../testdata/multiple_sheets_excel_file.xlsx")
|
||||
assert.Nil(t, err)
|
||||
|
||||
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata, true)
|
||||
assert.Nil(t, err)
|
||||
iterator := datatable.DataRowIterator()
|
||||
assert.True(t, iterator.HasNext())
|
||||
|
||||
@@ -127,7 +246,8 @@ func TestExcelOOXMLFileBasicDataRowIterator_OnlyHeaderLine(t *testing.T) {
|
||||
testdata, err := os.ReadFile("../../../testdata/only_one_row_excel_file.xlsx")
|
||||
assert.Nil(t, err)
|
||||
|
||||
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata)
|
||||
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata, true)
|
||||
assert.Nil(t, err)
|
||||
iterator := datatable.DataRowIterator()
|
||||
assert.False(t, iterator.HasNext())
|
||||
|
||||
@@ -144,7 +264,8 @@ func TestExcelOOXMLFileBasicDataRowIterator_EmptyContent(t *testing.T) {
|
||||
testdata, err := os.ReadFile("../../../testdata/empty_excel_file.xlsx")
|
||||
assert.Nil(t, err)
|
||||
|
||||
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata)
|
||||
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata, false)
|
||||
assert.Nil(t, err)
|
||||
iterator := datatable.DataRowIterator()
|
||||
assert.False(t, iterator.HasNext())
|
||||
|
||||
@@ -155,13 +276,27 @@ func TestExcelOOXMLFileBasicDataRowIterator_EmptyContent(t *testing.T) {
|
||||
// not existed data row 2
|
||||
assert.Nil(t, iterator.Next())
|
||||
assert.False(t, iterator.HasNext())
|
||||
|
||||
datatable, err = CreateNewExcelOOXMLFileBasicDataTable(testdata, true)
|
||||
assert.Nil(t, err)
|
||||
iterator = datatable.DataRowIterator()
|
||||
assert.False(t, iterator.HasNext())
|
||||
|
||||
// not existed data row 1
|
||||
assert.Nil(t, iterator.Next())
|
||||
assert.False(t, iterator.HasNext())
|
||||
|
||||
// not existed data row 2
|
||||
assert.Nil(t, iterator.Next())
|
||||
assert.False(t, iterator.HasNext())
|
||||
}
|
||||
|
||||
func TestExcelOOXMLFileBasicDataRowColumnCount(t *testing.T) {
|
||||
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xlsx")
|
||||
assert.Nil(t, err)
|
||||
|
||||
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata)
|
||||
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata, false)
|
||||
assert.Nil(t, err)
|
||||
iterator := datatable.DataRowIterator()
|
||||
|
||||
row1 := iterator.Next()
|
||||
@@ -175,7 +310,32 @@ func TestExcelOOXMLFileBasicDataRowGetData(t *testing.T) {
|
||||
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xlsx")
|
||||
assert.Nil(t, err)
|
||||
|
||||
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata)
|
||||
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata, false)
|
||||
assert.Nil(t, err)
|
||||
iterator := datatable.DataRowIterator()
|
||||
|
||||
row1 := iterator.Next()
|
||||
assert.Equal(t, "A1", row1.GetData(0))
|
||||
assert.Equal(t, "B1", row1.GetData(1))
|
||||
assert.Equal(t, "C1", row1.GetData(2))
|
||||
|
||||
row2 := iterator.Next()
|
||||
assert.Equal(t, "A2", row2.GetData(0))
|
||||
assert.Equal(t, "B2", row2.GetData(1))
|
||||
assert.Equal(t, "C2", row2.GetData(2))
|
||||
|
||||
row3 := iterator.Next()
|
||||
assert.Equal(t, "A3", row3.GetData(0))
|
||||
assert.Equal(t, "B3", row3.GetData(1))
|
||||
assert.Equal(t, "C3", row3.GetData(2))
|
||||
}
|
||||
|
||||
func TestExcelOOXMLFileBasicDataRowGetData_HasTitleLine(t *testing.T) {
|
||||
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xlsx")
|
||||
assert.Nil(t, err)
|
||||
|
||||
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata, true)
|
||||
assert.Nil(t, err)
|
||||
iterator := datatable.DataRowIterator()
|
||||
|
||||
row1 := iterator.Next()
|
||||
@@ -193,7 +353,8 @@ func TestExcelOOXMLFileBasicDataRowGetData_GetNotExistedColumnData(t *testing.T)
|
||||
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xlsx")
|
||||
assert.Nil(t, err)
|
||||
|
||||
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata)
|
||||
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata, false)
|
||||
assert.Nil(t, err)
|
||||
iterator := datatable.DataRowIterator()
|
||||
|
||||
row1 := iterator.Next()
|
||||
@@ -204,7 +365,64 @@ func TestExcelOOXMLFileBasicDataRowGetData_MultipleSheets(t *testing.T) {
|
||||
testdata, err := os.ReadFile("../../../testdata/multiple_sheets_excel_file.xlsx")
|
||||
assert.Nil(t, err)
|
||||
|
||||
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata)
|
||||
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata, false)
|
||||
assert.Nil(t, err)
|
||||
iterator := datatable.DataRowIterator()
|
||||
|
||||
sheet1Row1 := iterator.Next()
|
||||
assert.Equal(t, "A1", sheet1Row1.GetData(0))
|
||||
assert.Equal(t, "B1", sheet1Row1.GetData(1))
|
||||
assert.Equal(t, "C1", sheet1Row1.GetData(2))
|
||||
|
||||
sheet1Row2 := iterator.Next()
|
||||
assert.Equal(t, "1-A2", sheet1Row2.GetData(0))
|
||||
assert.Equal(t, "1-B2", sheet1Row2.GetData(1))
|
||||
assert.Equal(t, "1-C2", sheet1Row2.GetData(2))
|
||||
|
||||
sheet1Row3 := iterator.Next()
|
||||
assert.Equal(t, "1-A3", sheet1Row3.GetData(0))
|
||||
assert.Equal(t, "1-B3", sheet1Row3.GetData(1))
|
||||
assert.Equal(t, "1-C3", sheet1Row3.GetData(2))
|
||||
|
||||
// skip empty sheet2
|
||||
|
||||
sheet3Row1 := iterator.Next()
|
||||
assert.Equal(t, "A1", sheet3Row1.GetData(0))
|
||||
assert.Equal(t, "B1", sheet3Row1.GetData(1))
|
||||
assert.Equal(t, "C1", sheet3Row1.GetData(2))
|
||||
|
||||
sheet3Row2 := iterator.Next()
|
||||
assert.Equal(t, "3-A2", sheet3Row2.GetData(0))
|
||||
assert.Equal(t, "3-B2", sheet3Row2.GetData(1))
|
||||
assert.Equal(t, "", sheet3Row2.GetData(2))
|
||||
|
||||
sheet4Row1 := iterator.Next()
|
||||
assert.Equal(t, "A1", sheet4Row1.GetData(0))
|
||||
assert.Equal(t, "B1", sheet4Row1.GetData(1))
|
||||
assert.Equal(t, "C1", sheet4Row1.GetData(2))
|
||||
|
||||
sheet5Row1 := iterator.Next()
|
||||
assert.Equal(t, "A1", sheet5Row1.GetData(0))
|
||||
assert.Equal(t, "B1", sheet5Row1.GetData(1))
|
||||
assert.Equal(t, "C1", sheet5Row1.GetData(2))
|
||||
|
||||
sheet5Row2 := iterator.Next()
|
||||
assert.Equal(t, "5-A2", sheet5Row2.GetData(0))
|
||||
assert.Equal(t, "5-B2", sheet5Row2.GetData(1))
|
||||
assert.Equal(t, "5-C2", sheet5Row2.GetData(2))
|
||||
|
||||
sheet5Row3 := iterator.Next()
|
||||
assert.Equal(t, "5-A3", sheet5Row3.GetData(0))
|
||||
assert.Equal(t, "5-B3", sheet5Row3.GetData(1))
|
||||
assert.Equal(t, "5-C3", sheet5Row3.GetData(2))
|
||||
}
|
||||
|
||||
func TestExcelOOXMLFileBasicDataRowGetData_MultipleSheets_HasTitleLine(t *testing.T) {
|
||||
testdata, err := os.ReadFile("../../../testdata/multiple_sheets_excel_file.xlsx")
|
||||
assert.Nil(t, err)
|
||||
|
||||
datatable, err := CreateNewExcelOOXMLFileBasicDataTable(testdata, true)
|
||||
assert.Nil(t, err)
|
||||
iterator := datatable.DataRowIterator()
|
||||
|
||||
sheet1Row1 := iterator.Next()
|
||||
@@ -241,6 +459,6 @@ func TestCreateNewExcelOOXMLFileBasicDataTable_MultipleSheetsWithDifferentHeader
|
||||
testdata, err := os.ReadFile("../../../testdata/multiple_sheets_with_different_header_row_excel_file.xlsx")
|
||||
assert.Nil(t, err)
|
||||
|
||||
_, err = CreateNewExcelOOXMLFileBasicDataTable(testdata)
|
||||
_, err = CreateNewExcelOOXMLFileBasicDataTable(testdata, true)
|
||||
assert.EqualError(t, err, errs.ErrFieldsInMultiTableAreDifferent.Message)
|
||||
}
|
||||
|
||||
@@ -2,14 +2,13 @@ package feidee
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/csv"
|
||||
"golang.org/x/text/encoding/unicode"
|
||||
"golang.org/x/text/transform"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/text/encoding/unicode"
|
||||
"golang.org/x/text/transform"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/converter"
|
||||
csvdatatable "github.com/mayswind/ezbookkeeping/pkg/converters/csv"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/csv"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
@@ -60,7 +59,13 @@ func (c *feideeMymoneyAppTransactionDataCsvFileImporter) ParseImportedData(ctx c
|
||||
fallback := unicode.UTF8.NewDecoder()
|
||||
reader := transform.NewReader(bytes.NewReader(data), unicode.BOMOverride(fallback))
|
||||
|
||||
dataTable, err := c.createNewFeideeMymoneyAppBasicDataTable(ctx, reader)
|
||||
csvDataTable, err := csv.CreateNewCsvBasicDataTable(ctx, reader, false)
|
||||
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, nil, nil, err
|
||||
}
|
||||
|
||||
dataTable, err := createNewFeideeMymoneyAppTransactionBasicDataTable(ctx, csvDataTable)
|
||||
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, nil, nil, err
|
||||
@@ -89,54 +94,6 @@ func (c *feideeMymoneyAppTransactionDataCsvFileImporter) ParseImportedData(ctx c
|
||||
return dataTableImporter.ParseImportedData(ctx, user, transactionDataTable, defaultTimezoneOffset, accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap)
|
||||
}
|
||||
|
||||
func (c *feideeMymoneyAppTransactionDataCsvFileImporter) createNewFeideeMymoneyAppBasicDataTable(ctx core.Context, reader io.Reader) (datatable.BasicDataTable, error) {
|
||||
csvReader := csv.NewReader(reader)
|
||||
csvReader.FieldsPerRecord = -1
|
||||
|
||||
allOriginalLines := make([][]string, 0)
|
||||
hasFileHeader := false
|
||||
|
||||
for {
|
||||
items, err := csvReader.Read()
|
||||
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(ctx, "[feidee_mymoney_app_transaction_data_csv_file_importer.createNewFeideeMymoneyAppTransactionDataTable] cannot parse feidee mymoney csv data, because %s", err.Error())
|
||||
return nil, errs.ErrInvalidCSVFile
|
||||
}
|
||||
|
||||
if !hasFileHeader {
|
||||
if len(items) <= 0 {
|
||||
continue
|
||||
} else if strings.Index(items[0], feideeMymoneyAppTransactionDataCsvFileHeader) == 0 {
|
||||
hasFileHeader = true
|
||||
continue
|
||||
} else {
|
||||
log.Warnf(ctx, "[feidee_mymoney_app_transaction_data_csv_file_importer.createNewFeideeMymoneyAppTransactionDataTable] read unexpected line before read file header, line content is %s", strings.Join(items, ","))
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
allOriginalLines = append(allOriginalLines, items)
|
||||
}
|
||||
|
||||
if !hasFileHeader {
|
||||
return nil, errs.ErrInvalidFileHeader
|
||||
}
|
||||
|
||||
if len(allOriginalLines) < 2 {
|
||||
log.Errorf(ctx, "[feidee_mymoney_app_transaction_data_csv_file_importer.createNewFeideeMymoneyAppTransactionDataTable] cannot parse import data, because data table row count is less 1")
|
||||
return nil, errs.ErrNotFoundTransactionDataInFile
|
||||
}
|
||||
|
||||
dataTable := csvdatatable.CreateNewCustomCsvBasicDataTable(allOriginalLines)
|
||||
|
||||
return dataTable, nil
|
||||
}
|
||||
|
||||
func (c *feideeMymoneyAppTransactionDataCsvFileImporter) createNewFeideeMymoneyAppTransactionDataTable(ctx core.Context, commonDataTable datatable.CommonDataTable) (datatable.TransactionDataTable, error) {
|
||||
newColumns := make([]datatable.TransactionDataTableColumn, 0, 11)
|
||||
newColumns = append(newColumns, datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE)
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package feidee
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/csv"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||
)
|
||||
|
||||
func createNewFeideeMymoneyAppTransactionBasicDataTable(ctx core.Context, originalDataTable datatable.BasicDataTable) (datatable.BasicDataTable, error) {
|
||||
iterator := originalDataTable.DataRowIterator()
|
||||
allOriginalLines := make([][]string, 0)
|
||||
hasFileHeader := false
|
||||
|
||||
for iterator.HasNext() {
|
||||
row := iterator.Next()
|
||||
|
||||
if !hasFileHeader {
|
||||
if row.ColumnCount() <= 0 {
|
||||
continue
|
||||
} else if strings.Index(row.GetData(0), feideeMymoneyAppTransactionDataCsvFileHeader) == 0 {
|
||||
hasFileHeader = true
|
||||
continue
|
||||
} else {
|
||||
log.Warnf(ctx, "[feidee_mymoney_app_transaction_data_extrator.createNewFeideeMymoneyAppTransactionBasicDataTable] read unexpected line in row \"%s\" before read file header", iterator.CurrentRowId())
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
items := make([]string, row.ColumnCount())
|
||||
|
||||
for i := 0; i < row.ColumnCount(); i++ {
|
||||
items[i] = strings.Trim(row.GetData(i), " ")
|
||||
}
|
||||
|
||||
allOriginalLines = append(allOriginalLines, items)
|
||||
}
|
||||
|
||||
if !hasFileHeader {
|
||||
return nil, errs.ErrInvalidFileHeader
|
||||
}
|
||||
|
||||
if len(allOriginalLines) < 2 {
|
||||
log.Errorf(ctx, "[feidee_mymoney_app_transaction_data_extrator.createNewFeideeMymoneyAppTransactionBasicDataTable] cannot parse import data, because data table row count is less 1")
|
||||
return nil, errs.ErrNotFoundTransactionDataInFile
|
||||
}
|
||||
|
||||
return csv.CreateNewCustomCsvBasicDataTable(allOriginalLines, true), nil
|
||||
}
|
||||
+1
-1
@@ -32,7 +32,7 @@ var (
|
||||
|
||||
// ParseImportedData returns the imported data by parsing the feidee mymoney (elecloud) transaction xlsx data
|
||||
func (c *feideeMymoneyElecloudTransactionDataXlsxFileImporter) ParseImportedData(ctx core.Context, user *models.User, data []byte, defaultTimezoneOffset int16, 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) {
|
||||
dataTable, err := excel.CreateNewExcelOOXMLFileBasicDataTable(data)
|
||||
dataTable, err := excel.CreateNewExcelOOXMLFileBasicDataTable(data, true)
|
||||
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, nil, nil, err
|
||||
|
||||
@@ -31,7 +31,7 @@ var (
|
||||
|
||||
// ParseImportedData returns the imported data by parsing the feidee mymoney (web) transaction xls data
|
||||
func (c *feideeMymoneyWebTransactionDataXlsFileImporter) ParseImportedData(ctx core.Context, user *models.User, data []byte, defaultTimezoneOffset int16, 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) {
|
||||
dataTable, err := excel.CreateNewExcelMSCFBFileBasicDataTable(data)
|
||||
dataTable, err := excel.CreateNewExcelMSCFBFileBasicDataTable(data, true)
|
||||
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, nil, nil, err
|
||||
|
||||
@@ -7,24 +7,21 @@ import (
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/csv"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
)
|
||||
|
||||
var fireflyIIITransactionSupportedColumns = map[datatable.TransactionDataTableColumn]bool{
|
||||
datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME: true,
|
||||
datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE: true,
|
||||
datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: true,
|
||||
datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY: true,
|
||||
datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME: true,
|
||||
datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY: true,
|
||||
datatable.TRANSACTION_DATA_TABLE_AMOUNT: true,
|
||||
datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME: true,
|
||||
datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_CURRENCY: true,
|
||||
datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT: true,
|
||||
datatable.TRANSACTION_DATA_TABLE_TAGS: true,
|
||||
datatable.TRANSACTION_DATA_TABLE_DESCRIPTION: true,
|
||||
var fireflyIIITransactionDataColumnNameMapping = map[datatable.TransactionDataTableColumn]string{
|
||||
datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME: "date",
|
||||
datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: "type",
|
||||
datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY: "category",
|
||||
datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME: "source_name",
|
||||
datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY: "currency_code",
|
||||
datatable.TRANSACTION_DATA_TABLE_AMOUNT: "amount",
|
||||
datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME: "destination_name",
|
||||
datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_CURRENCY: "foreign_currency_code",
|
||||
datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT: "foreign_amount",
|
||||
datatable.TRANSACTION_DATA_TABLE_TAGS: "tags",
|
||||
datatable.TRANSACTION_DATA_TABLE_DESCRIPTION: "description",
|
||||
}
|
||||
|
||||
var fireflyIIITransactionTypeNameMapping = map[models.TransactionType]string{
|
||||
@@ -45,27 +42,14 @@ var (
|
||||
// ParseImportedData returns the imported data by parsing the firefly III transaction csv data
|
||||
func (c *fireflyIIITransactionDataCsvFileImporter) ParseImportedData(ctx core.Context, user *models.User, data []byte, defaultTimezoneOffset int16, 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) {
|
||||
reader := bytes.NewReader(data)
|
||||
dataTable, err := csv.CreateNewCsvBasicDataTable(ctx, reader)
|
||||
dataTable, err := csv.CreateNewCsvBasicDataTable(ctx, reader, true)
|
||||
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, nil, nil, err
|
||||
}
|
||||
|
||||
commonDataTable := datatable.CreateNewCommonDataTableFromBasicDataTable(dataTable)
|
||||
|
||||
if !commonDataTable.HasColumn(fireflyIIITransactionTimeColumnName) ||
|
||||
!commonDataTable.HasColumn(fireflyIIITransactionTypeColumnName) ||
|
||||
!commonDataTable.HasColumn(fireflyIIITransactionSourceAccountNameColumnName) ||
|
||||
!commonDataTable.HasColumn(fireflyIIITransactionSourceAccountTypeColumnName) ||
|
||||
!commonDataTable.HasColumn(fireflyIIITransactionDestinationAccountNameColumnName) ||
|
||||
!commonDataTable.HasColumn(fireflyIIITransactionDestinationAccountTypeColumnName) ||
|
||||
!commonDataTable.HasColumn(fireflyIIITransactionAmountColumnName) {
|
||||
log.Errorf(ctx, "[fireflyiii_transaction_data_csv_file_importer.ParseImportedData] cannot parse Firefly III csv data, because missing essential columns in header row")
|
||||
return nil, nil, nil, nil, nil, nil, errs.ErrMissingRequiredFieldInHeaderRow
|
||||
}
|
||||
|
||||
transactionRowParser := createFireflyIIITransactionDataRowParser()
|
||||
transactionDataTable := datatable.CreateNewTransactionDataTableFromCommonDataTable(commonDataTable, fireflyIIITransactionSupportedColumns, transactionRowParser)
|
||||
transactionDataTable := datatable.CreateNewTransactionDataTableFromBasicDataTableWithRowParser(dataTable, fireflyIIITransactionDataColumnNameMapping, transactionRowParser)
|
||||
dataTableImporter := converter.CreateNewImporterWithTypeNameMapping(fireflyIIITransactionTypeNameMapping, "", "", ",")
|
||||
|
||||
return dataTableImporter.ParseImportedData(ctx, user, transactionDataTable, defaultTimezoneOffset, accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap)
|
||||
|
||||
@@ -20,11 +20,11 @@ func TestFireFlyIIICsvFileConverterParseImportedData_MinimumValidData(t *testing
|
||||
DefaultCurrency: "CNY",
|
||||
}
|
||||
|
||||
allNewTransactions, allNewAccounts, allNewSubExpenseCategories, allNewSubIncomeCategories, allNewSubTransferCategories, allNewTags, err := converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,source_type,destination_name,destination_type,category\n"+
|
||||
"\"Opening balance\",123.45,2024-09-01T00:00:00+08:00,\"Initial balance for \"\"Test Account\"\"\",\"Initial balance account\",\"Test Account\",\"Asset account\",\n"+
|
||||
"Deposit,0.12,2024-09-01T01:23:45+08:00,\"A revenue account\",\"Revenue account\",\"Test Account\",\"Asset account\",\"Test Category\"\n"+
|
||||
"Withdrawal,-1.00,2024-09-01T12:34:56+08:00,\"Test Account\",\"Asset account\",\"A expense account\",\"Expense account\",\"Test Category2\"\n"+
|
||||
"Transfer,0.05,2024-09-01T23:59:59+08:00,\"Test Account\",\"Asset account\",\"Test Account2\",\"Asset account\",\"Test Category3\""), 0, nil, nil, nil, nil, nil)
|
||||
allNewTransactions, allNewAccounts, allNewSubExpenseCategories, allNewSubIncomeCategories, allNewSubTransferCategories, allNewTags, err := converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,destination_name,category\n"+
|
||||
"\"Opening balance\",123.45,2024-09-01T00:00:00+08:00,\"Initial balance for \"\"Test Account\"\"\",\"Test Account\",\n"+
|
||||
"Deposit,0.12,2024-09-01T01:23:45+08:00,\"A revenue account\",\"Test Account\",\"Test Category\"\n"+
|
||||
"Withdrawal,-1.00,2024-09-01T12:34:56+08:00,\"Test Account\",\"A expense account\",\"Test Category2\"\n"+
|
||||
"Transfer,0.05,2024-09-01T23:59:59+08:00,\"Test Account\",\"Test Account2\",\"Test Category3\""), 0, nil, nil, nil, nil, nil)
|
||||
|
||||
assert.Nil(t, err)
|
||||
|
||||
@@ -91,16 +91,16 @@ func TestFireFlyIIICsvFileConverterParseImportedData_ParseInvalidTime(t *testing
|
||||
DefaultCurrency: "CNY",
|
||||
}
|
||||
|
||||
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,source_type,destination_name,destination_type,category\n"+
|
||||
"Withdrawal,-1.00,2024-09-01T12:34:56,\"Test Account\",\"Asset account\",\"A expense account\",\"Expense account\",\"Test Category\""), 0, nil, nil, nil, nil, nil)
|
||||
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,destination_name,category\n"+
|
||||
"Withdrawal,-1.00,2024-09-01T12:34:56,\"Test Account\",\"A expense account\",\"Test Category\""), 0, nil, nil, nil, nil, nil)
|
||||
assert.EqualError(t, err, errs.ErrTransactionTimeInvalid.Message)
|
||||
|
||||
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,source_type,destination_name,destination_type,category\n"+
|
||||
"Withdrawal,-1.00,2024-09-01 12:34:56+08:00,\"Test Account\",\"Asset account\",\"A expense account\",\"Expense account\",\"Test Category\""), 0, nil, nil, nil, nil, nil)
|
||||
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,destination_name,category\n"+
|
||||
"Withdrawal,-1.00,2024-09-01 12:34:56+08:00,\"Test Account\",\"A expense account\",\"Test Category\""), 0, nil, nil, nil, nil, nil)
|
||||
assert.EqualError(t, err, errs.ErrTransactionTimeInvalid.Message)
|
||||
}
|
||||
|
||||
func TestFireFlyIIICsvFileConverterParseImportedData_ParseValidTransactionType(t *testing.T) {
|
||||
func TestFireFlyIIICsvFileConverterParseImportedData_ParseInvalidType(t *testing.T) {
|
||||
converter := FireflyIIITransactionDataCsvFileImporter
|
||||
context := core.NewNullContext()
|
||||
|
||||
@@ -109,107 +109,8 @@ func TestFireFlyIIICsvFileConverterParseImportedData_ParseValidTransactionType(t
|
||||
DefaultCurrency: "CNY",
|
||||
}
|
||||
|
||||
// income transactions
|
||||
allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,source_type,destination_name,destination_type,category\n"+
|
||||
"Deposit,10.00,2024-09-01T12:34:56+08:00,\"A revenue account\",\"Revenue account\",\"Test Account\",\"Asset account\",\"Test Category\""), 0, nil, nil, nil, nil, nil)
|
||||
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, 1, len(allNewTransactions))
|
||||
assert.Equal(t, models.TRANSACTION_DB_TYPE_INCOME, allNewTransactions[0].Type)
|
||||
assert.Equal(t, int64(1000), allNewTransactions[0].Amount)
|
||||
assert.Equal(t, "Test Account", allNewTransactions[0].OriginalSourceAccountName)
|
||||
|
||||
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,source_type,destination_name,destination_type,category\n"+
|
||||
"Deposit,10.00,2024-09-01T12:34:56+08:00,\"A revenue account\",\"Revenue account\",\"Test Account\",\"Debt\",\"Test Category\""), 0, nil, nil, nil, nil, nil)
|
||||
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, 1, len(allNewTransactions))
|
||||
assert.Equal(t, models.TRANSACTION_DB_TYPE_INCOME, allNewTransactions[0].Type)
|
||||
assert.Equal(t, int64(1000), allNewTransactions[0].Amount)
|
||||
assert.Equal(t, "Test Account", allNewTransactions[0].OriginalSourceAccountName)
|
||||
|
||||
// expense transactions
|
||||
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,source_type,destination_name,destination_type,category\n"+
|
||||
"Withdrawal,-10.00,2024-09-01T12:34:56+08:00,\"Test Account\",\"Asset account\",\"A expense account\",\"Expense account\",\"Test Category\""), 0, nil, nil, nil, nil, nil)
|
||||
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, 1, len(allNewTransactions))
|
||||
assert.Equal(t, models.TRANSACTION_DB_TYPE_EXPENSE, allNewTransactions[0].Type)
|
||||
assert.Equal(t, int64(1000), allNewTransactions[0].Amount)
|
||||
assert.Equal(t, "Test Account", allNewTransactions[0].OriginalSourceAccountName)
|
||||
|
||||
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,source_type,destination_name,destination_type,category\n"+
|
||||
"Withdrawal,-10.00,2024-09-01T12:34:56+08:00,\"Test Account\",\"Debt\",\"A expense account\",\"Expense account\",\"Test Category\""), 0, nil, nil, nil, nil, nil)
|
||||
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, 1, len(allNewTransactions))
|
||||
assert.Equal(t, models.TRANSACTION_DB_TYPE_EXPENSE, allNewTransactions[0].Type)
|
||||
assert.Equal(t, int64(1000), allNewTransactions[0].Amount)
|
||||
assert.Equal(t, "Test Account", allNewTransactions[0].OriginalSourceAccountName)
|
||||
|
||||
// opening balance transactions
|
||||
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,source_type,destination_name,destination_type,category\n"+
|
||||
"\"Opening balance\",10.00,2024-09-01T12:34:56+08:00,\"Initial balance\",\"Initial balance account\",\"Test Account\",\"Asset account\",\"\""), 0, nil, nil, nil, nil, nil)
|
||||
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, 1, len(allNewTransactions))
|
||||
assert.Equal(t, models.TRANSACTION_DB_TYPE_MODIFY_BALANCE, allNewTransactions[0].Type)
|
||||
assert.Equal(t, int64(1000), allNewTransactions[0].Amount)
|
||||
assert.Equal(t, "Test Account", allNewTransactions[0].OriginalSourceAccountName)
|
||||
|
||||
// transfer transactions
|
||||
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,source_type,destination_name,destination_type,category\n"+
|
||||
"Transfer,10.00,2024-09-01T12:34:56+08:00,\"Test Account\",\"Asset account\",\"Test Account2\",\"Asset account\",\"Test Category\""), 0, nil, nil, nil, nil, nil)
|
||||
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, 1, len(allNewTransactions))
|
||||
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[0].Type)
|
||||
assert.Equal(t, int64(1000), allNewTransactions[0].Amount)
|
||||
assert.Equal(t, "Test Account", allNewTransactions[0].OriginalSourceAccountName)
|
||||
assert.Equal(t, "Test Account2", allNewTransactions[0].OriginalDestinationAccountName)
|
||||
|
||||
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,source_type,destination_name,destination_type,category\n"+
|
||||
"Withdrawal,-10.00,2024-09-01T12:34:56+08:00,\"Test Account\",\"Asset account\",\"Test Account2\",\"Debt\",\"Test Category\""), 0, nil, nil, nil, nil, nil)
|
||||
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, 1, len(allNewTransactions))
|
||||
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[0].Type)
|
||||
assert.Equal(t, int64(1000), allNewTransactions[0].Amount)
|
||||
assert.Equal(t, "Test Account", allNewTransactions[0].OriginalSourceAccountName)
|
||||
assert.Equal(t, "Test Account2", allNewTransactions[0].OriginalDestinationAccountName)
|
||||
|
||||
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,source_type,destination_name,destination_type,category\n"+
|
||||
"Deposit,10.00,2024-09-01T12:34:56+08:00,\"Test Account\",\"Debt\",\"Test Account2\",\"Asset account\",\"Test Category\""), 0, nil, nil, nil, nil, nil)
|
||||
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, 1, len(allNewTransactions))
|
||||
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[0].Type)
|
||||
assert.Equal(t, int64(1000), allNewTransactions[0].Amount)
|
||||
assert.Equal(t, "Test Account", allNewTransactions[0].OriginalSourceAccountName)
|
||||
assert.Equal(t, "Test Account2", allNewTransactions[0].OriginalDestinationAccountName)
|
||||
|
||||
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,source_type,destination_name,destination_type,category\n"+
|
||||
"Transfer,10.00,2024-09-01T12:34:56+08:00,\"Test Account\",\"Debt\",\"Test Account2\",\"Debt\",\"Test Category\""), 0, nil, nil, nil, nil, nil)
|
||||
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, 1, len(allNewTransactions))
|
||||
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[0].Type)
|
||||
assert.Equal(t, int64(1000), allNewTransactions[0].Amount)
|
||||
assert.Equal(t, "Test Account", allNewTransactions[0].OriginalSourceAccountName)
|
||||
assert.Equal(t, "Test Account2", allNewTransactions[0].OriginalDestinationAccountName)
|
||||
}
|
||||
|
||||
func TestFireFlyIIICsvFileConverterParseImportedData_ParseInvalidTransactionType(t *testing.T) {
|
||||
converter := FireflyIIITransactionDataCsvFileImporter
|
||||
context := core.NewNullContext()
|
||||
|
||||
user := &models.User{
|
||||
Uid: 1234567890,
|
||||
DefaultCurrency: "CNY",
|
||||
}
|
||||
|
||||
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,source_type,destination_name,destination_type,category\n"+
|
||||
"Transfer,10.00,2024-09-01T12:34:56+08:00,\"Test Account\",\"Revenue account\",\"Test Account2\",\"Expense account\",\"Test Category\""), 0, nil, nil, nil, nil, nil)
|
||||
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,destination_name,category\n"+
|
||||
"Type,123.45,2024-09-01T12:34:56+08:00,\"Test Account\",\"A expense account\",\"Test Category\""), 0, nil, nil, nil, nil, nil)
|
||||
assert.EqualError(t, err, errs.ErrTransactionTypeInvalid.Message)
|
||||
}
|
||||
|
||||
@@ -222,15 +123,15 @@ func TestFireFlyIIICsvFileConverterParseImportedData_ParseAccountNameAsCategoryN
|
||||
DefaultCurrency: "CNY",
|
||||
}
|
||||
|
||||
allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,source_type,destination_name,destination_type,category\n"+
|
||||
"Withdrawal,-1.00,2024-09-01T12:34:56+08:00,\"Test Account\",\"Asset account\",\"A expense account\",\"Expense account\",\"\""), 0, nil, nil, nil, nil, nil)
|
||||
allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,destination_name,category\n"+
|
||||
"Withdrawal,-1.00,2024-09-01T12:34:56+08:00,\"Test Account\",\"A expense account\",\"\""), 0, nil, nil, nil, nil, nil)
|
||||
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, 1, len(allNewTransactions))
|
||||
assert.Equal(t, "A expense account", allNewTransactions[0].OriginalCategoryName)
|
||||
|
||||
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,source_type,destination_name,destination_type,category\n"+
|
||||
"Deposit,10.00,2024-09-01T12:34:56+08:00,\"A revenue account\",\"Revenue account\",\"Test Account\",\"Asset account\",\"\""), 0, nil, nil, nil, nil, nil)
|
||||
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,destination_name,category\n"+
|
||||
"Deposit,10.00,2024-09-01T12:34:56+08:00,\"A revenue account\",\"Test Account\",\"\""), 0, nil, nil, nil, nil, nil)
|
||||
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, 1, len(allNewTransactions))
|
||||
@@ -246,20 +147,20 @@ func TestFireFlyIIICsvFileConverterParseImportedData_ParseValidTimezone(t *testi
|
||||
DefaultCurrency: "CNY",
|
||||
}
|
||||
|
||||
allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,source_type,destination_name,destination_type,category\n"+
|
||||
"Withdrawal,-1.00,2024-09-01T12:34:56-10:00,\"Test Account\",\"Asset account\",\"A expense account\",\"Expense account\",\"Test Category\""), 0, nil, nil, nil, nil, nil)
|
||||
allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,destination_name,category\n"+
|
||||
"Withdrawal,-1.00,2024-09-01T12:34:56-10:00,\"Test Account\",\"A expense account\",\"Test Category\""), 0, nil, nil, nil, nil, nil)
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, 1, len(allNewTransactions))
|
||||
assert.Equal(t, int64(1725230096), utils.GetUnixTimeFromTransactionTime(allNewTransactions[0].TransactionTime))
|
||||
|
||||
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,source_type,destination_name,destination_type,category\n"+
|
||||
"Withdrawal,-1.00,2024-09-01T12:34:56+00:00,\"Test Account\",\"Asset account\",\"A expense account\",\"Expense account\",\"Test Category\""), 0, nil, nil, nil, nil, nil)
|
||||
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,destination_name,category\n"+
|
||||
"Withdrawal,-1.00,2024-09-01T12:34:56+00:00,\"Test Account\",\"A expense account\",\"Test Category\""), 0, nil, nil, nil, nil, nil)
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, 1, len(allNewTransactions))
|
||||
assert.Equal(t, int64(1725194096), utils.GetUnixTimeFromTransactionTime(allNewTransactions[0].TransactionTime))
|
||||
|
||||
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,source_type,destination_name,destination_type,category\n"+
|
||||
"Withdrawal,-1.00,2024-09-01T12:34:56+12:45,\"Test Account\",\"Asset account\",\"A expense account\",\"Expense account\",\"Test Category\""), 0, nil, nil, nil, nil, nil)
|
||||
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,destination_name,category\n"+
|
||||
"Withdrawal,-1.00,2024-09-01T12:34:56+12:45,\"Test Account\",\"A expense account\",\"Test Category\""), 0, nil, nil, nil, nil, nil)
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, 1, len(allNewTransactions))
|
||||
assert.Equal(t, int64(1725148196), utils.GetUnixTimeFromTransactionTime(allNewTransactions[0].TransactionTime))
|
||||
@@ -274,9 +175,9 @@ func TestFireFlyIIICsvFileConverterParseImportedData_ParseValidAccountCurrency(t
|
||||
DefaultCurrency: "CNY",
|
||||
}
|
||||
|
||||
allNewTransactions, allNewAccounts, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("type,amount,foreign_amount,date,currency_code,foreign_currency_code,source_name,source_type,destination_name,destination_type,category\n"+
|
||||
"\"Opening balance\",123.45,,2024-09-01T00:00:00+08:00,USD,,\"Initial balance for \"\"Test Account\"\"\",\"Initial balance account\",\"Test Account\",\"Asset account\",\n"+
|
||||
"Transfer,1.23,-1.10,2024-09-01T23:59:59+08:00,USD,EUR,\"Test Account\",\"Asset account\",\"Test Account2\",\"Asset account\",\"Test Category2\""), 0, nil, nil, nil, nil, nil)
|
||||
allNewTransactions, allNewAccounts, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("type,amount,foreign_amount,date,currency_code,foreign_currency_code,source_name,destination_name,category\n"+
|
||||
"\"Opening balance\",123.45,,2024-09-01T00:00:00+08:00,USD,,\"Initial balance for \"\"Test Account\"\"\",\"Test Account\",\n"+
|
||||
"Transfer,1.23,1.10,2024-09-01T23:59:59+08:00,USD,EUR,\"Test Account\",\"Test Account2\",\"Test Category2\""), 0, nil, nil, nil, nil, nil)
|
||||
|
||||
assert.Nil(t, err)
|
||||
|
||||
@@ -301,8 +202,8 @@ func TestFireFlyIIICsvFileConverterParseImportedData_ParseValidForeignAmountAndC
|
||||
DefaultCurrency: "CNY",
|
||||
}
|
||||
|
||||
allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("type,amount,foreign_amount,date,currency_code,foreign_currency_code,source_name,source_type,destination_name,destination_type,category\n"+
|
||||
"Transfer,10.00,15.00,2024-09-01T12:34:56+08:00,USD,EUR,\"Test Account\",\"Asset account\",\"Test Account2\",\"Asset account\",\"Test Category\""), 0, nil, nil, nil, nil, nil)
|
||||
allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("type,amount,foreign_amount,date,currency_code,foreign_currency_code,source_name,destination_name,category\n"+
|
||||
"Transfer,10.00,15.00,2024-09-01T12:34:56+08:00,USD,EUR,\"Test Account\",\"Test Account2\",\"Test Category\""), 0, nil, nil, nil, nil, nil)
|
||||
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, 1, len(allNewTransactions))
|
||||
@@ -312,8 +213,8 @@ func TestFireFlyIIICsvFileConverterParseImportedData_ParseValidForeignAmountAndC
|
||||
assert.Equal(t, "USD", allNewTransactions[0].OriginalSourceAccountCurrency)
|
||||
assert.Equal(t, "EUR", allNewTransactions[0].OriginalDestinationAccountCurrency)
|
||||
|
||||
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,date,currency_code,foreign_currency_code,source_name,source_type,destination_name,destination_type,category\n"+
|
||||
"Transfer,10.00,2024-09-01T12:34:56+08:00,USD,EUR,\"Test Account\",\"Asset account\",\"Test Account2\",\"Asset account\",\"Test Category\""), 0, nil, nil, nil, nil, nil)
|
||||
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,date,currency_code,foreign_currency_code,source_name,destination_name,category\n"+
|
||||
"Transfer,10.00,2024-09-01T12:34:56+08:00,USD,EUR,\"Test Account\",\"Test Account2\",\"Test Category\""), 0, nil, nil, nil, nil, nil)
|
||||
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, 1, len(allNewTransactions))
|
||||
@@ -322,8 +223,8 @@ func TestFireFlyIIICsvFileConverterParseImportedData_ParseValidForeignAmountAndC
|
||||
assert.Equal(t, "USD", allNewTransactions[0].OriginalSourceAccountCurrency)
|
||||
assert.Equal(t, "EUR", allNewTransactions[0].OriginalDestinationAccountCurrency)
|
||||
|
||||
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,date,currency_code,source_name,source_type,destination_name,destination_type,category\n"+
|
||||
"Transfer,10.00,2024-09-01T12:34:56+08:00,USD,\"Test Account\",\"Asset account\",\"Test Account2\",\"Asset account\",\"Test Category\""), 0, nil, nil, nil, nil, nil)
|
||||
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,date,currency_code,foreign_currency_code,source_name,destination_name,category\n"+
|
||||
"Transfer,10.00,2024-09-01T12:34:56+08:00,USD,,\"Test Account\",\"Test Account2\",\"Test Category\""), 0, nil, nil, nil, nil, nil)
|
||||
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, 1, len(allNewTransactions))
|
||||
@@ -340,14 +241,14 @@ func TestFireFlyIIICsvFileConverterParseImportedData_ParseInvalidAccountCurrency
|
||||
DefaultCurrency: "CNY",
|
||||
}
|
||||
|
||||
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("type,amount,foreign_amount,date,currency_code,foreign_currency_code,source_name,source_type,destination_name,destination_type,category\n"+
|
||||
"\"Opening balance\",123.45,,2024-09-01T00:00:00+08:00,USD,,\"Initial balance for \"\"Test Account\"\"\",\"Initial balance account\",\"Test Account\",\"Asset account\",\n"+
|
||||
"Transfer,1.23,1.10,2024-09-01T23:59:59+08:00,CNY,EUR,\"Test Account\",\"Asset account\",\"Test Account2\",\"Asset account\",\"Test Category3\""), 0, nil, nil, nil, nil, nil)
|
||||
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("type,amount,foreign_amount,date,currency_code,foreign_currency_code,source_name,destination_name,category\n"+
|
||||
"\"Opening balance\",123.45,,2024-09-01T00:00:00+08:00,USD,,\"Initial balance for \"\"Test Account\"\"\",\"Test Account\",\n"+
|
||||
"Transfer,1.23,1.10,2024-09-01T23:59:59+08:00,CNY,EUR,\"Test Account\",\"Test Account2\",\"Test Category3\""), 0, nil, nil, nil, nil, nil)
|
||||
assert.EqualError(t, err, errs.ErrAccountCurrencyInvalid.Message)
|
||||
|
||||
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,foreign_amount,date,currency_code,foreign_currency_code,source_name,source_type,destination_name,destination_type,category\n"+
|
||||
"\"Opening balance\",123.45,,2024-09-01T00:00:00+08:00,USD,,\"Initial balance for \"\"Test Account\"\"\",\"Initial balance account\",\"Test Account\",\n"+
|
||||
"Transfer,1.23,1.10,2024-09-01T23:59:59+08:00,CNY,EUR,\"Test Account2\",\"Asset account\",\"Test Account\",\"Asset account\",\"Test Category3\""), 0, nil, nil, nil, nil, nil)
|
||||
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,foreign_amount,date,currency_code,foreign_currency_code,source_name,destination_name,category\n"+
|
||||
"\"Opening balance\",123.45,,2024-09-01T00:00:00+08:00,USD,,\"Initial balance for \"\"Test Account\"\"\",\"Test Account\",\n"+
|
||||
"Transfer,1.23,1.10,2024-09-01T23:59:59+08:00,CNY,EUR,\"Test Account2\",\"Test Account\",\"Test Category3\""), 0, nil, nil, nil, nil, nil)
|
||||
assert.EqualError(t, err, errs.ErrAccountCurrencyInvalid.Message)
|
||||
}
|
||||
|
||||
@@ -360,12 +261,12 @@ func TestFireFlyIIICsvFileConverterParseImportedData_ParseNotSupportedCurrency(t
|
||||
DefaultCurrency: "CNY",
|
||||
}
|
||||
|
||||
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("type,amount,foreign_amount,date,currency_code,foreign_currency_code,source_name,source_type,destination_name,destination_type,category\n"+
|
||||
"\"Opening balance\",123.45,,2024-09-01T00:00:00+08:00,XXX,,\"Initial balance for \"\"Test Account\"\"\",\"Initial balance account\",\"Test Account\",\"Asset account\",\n"), 0, nil, nil, nil, nil, nil)
|
||||
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("type,amount,foreign_amount,date,currency_code,foreign_currency_code,source_name,destination_name,category\n"+
|
||||
"\"Opening balance\",123.45,,2024-09-01T00:00:00+08:00,XXX,,\"Initial balance for \"\"Test Account\"\"\",\"Test Account\",\n"), 0, nil, nil, nil, nil, nil)
|
||||
assert.EqualError(t, err, errs.ErrAccountCurrencyInvalid.Message)
|
||||
|
||||
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,foreign_amount,date,currency_code,foreign_currency_code,source_name,source_type,destination_name,destination_type,category\n"+
|
||||
"Transfer,123.45,123.45,2024-09-01T23:59:59+08:00,USD,XXX,\"Test Account\",\"Asset account\",\"Test Account2\",\"Asset account\",\"Test Category2\""), 0, nil, nil, nil, nil, nil)
|
||||
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,foreign_amount,date,currency_code,foreign_currency_code,source_name,destination_name,category\n"+
|
||||
"Transfer,123.45,123.45,2024-09-01T23:59:59+08:00,USD,XXX,\"Test Account\",\"Test Account2\",\"Test Category2\""), 0, nil, nil, nil, nil, nil)
|
||||
assert.EqualError(t, err, errs.ErrAccountCurrencyInvalid.Message)
|
||||
}
|
||||
|
||||
@@ -378,12 +279,12 @@ func TestFireFlyIIICsvFileConverterParseImportedData_ParseInvalidAmount(t *testi
|
||||
DefaultCurrency: "CNY",
|
||||
}
|
||||
|
||||
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,source_type,destination_name,destination_type,category\n"+
|
||||
"Withdrawal,-123 45,2024-09-01T12:34:56+08:00,\"Test Account\",\"Asset account\",\"A expense account\",\"Expense account\",\"Test Category\"\n"), 0, nil, nil, nil, nil, nil)
|
||||
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,destination_name,category\n"+
|
||||
"Withdrawal,-123 45,2024-09-01T12:34:56+08:00,\"Test Account\",\"A expense account\",\"Test Category\"\n"), 0, nil, nil, nil, nil, nil)
|
||||
assert.EqualError(t, err, errs.ErrAmountInvalid.Message)
|
||||
|
||||
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,foreign_amount,date,source_name,source_type,destination_name,destination_type,category\n"+
|
||||
"Transfer,123.45,123 45,2024-09-01T23:59:59+08:00,\"Test Account\",\"Asset account\",\"Test Account2\",\"Asset account\",\"Test Category2\""), 0, nil, nil, nil, nil, nil)
|
||||
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,foreign_amount,date,source_name,destination_name,category\n"+
|
||||
"Transfer,123.45,123 45,2024-09-01T23:59:59+08:00,\"Test Account\",\"Test Account2\",\"Test Category2\""), 0, nil, nil, nil, nil, nil)
|
||||
assert.EqualError(t, err, errs.ErrAmountInvalid.Message)
|
||||
}
|
||||
|
||||
@@ -396,8 +297,8 @@ func TestFireFlyIIICsvFileConverterParseImportedData_ParseDescription(t *testing
|
||||
DefaultCurrency: "CNY",
|
||||
}
|
||||
|
||||
allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("type,amount,description,date,source_name,source_type,destination_name,destination_type,category\n"+
|
||||
"Withdrawal,-123.45,\"foo bar\t#test\",2024-09-01T12:34:56+08:00,\"Test Account\",\"Asset account\",\"A expense account\",\"Expense account\",\"Test Category\"\n"), 0, nil, nil, nil, nil, nil)
|
||||
allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("type,amount,description,date,source_name,destination_name,category\n"+
|
||||
"Withdrawal,-123.45,\"foo bar\t#test\",2024-09-01T12:34:56+08:00,\"Test Account\",\"A expense account\",\"Test Category\"\n"), 0, nil, nil, nil, nil, nil)
|
||||
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, 1, len(allNewTransactions))
|
||||
@@ -413,8 +314,8 @@ func TestFireFlyIIICsvFileConverterParseImportedData_ParseTags(t *testing.T) {
|
||||
DefaultCurrency: "CNY",
|
||||
}
|
||||
|
||||
allNewTransactions, _, _, _, _, allNewTags, err := converter.ParseImportedData(context, user, []byte("type,amount,tags,date,source_name,source_type,destination_name,destination_type,category\n"+
|
||||
"Withdrawal,-123.45,\"tag1,tag2,tag3\",2024-09-01T12:34:56+08:00,\"Test Account\",\"Asset account\",\"A expense account\",\"Expense account\",\"Test Category\"\n"), 0, nil, nil, nil, nil, nil)
|
||||
allNewTransactions, _, _, _, _, allNewTags, err := converter.ParseImportedData(context, user, []byte("type,amount,tags,date,source_name,destination_name,category\n"+
|
||||
"Withdrawal,-123.45,\"tag1,tag2,tag3\",2024-09-01T12:34:56+08:00,\"Test Account\",\"A expense account\",\"Test Category\"\n"), 0, nil, nil, nil, nil, nil)
|
||||
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, 1, len(allNewTransactions))
|
||||
@@ -437,7 +338,7 @@ func TestFireFlyIIICsvFileConverterParseImportedData_MissingFileHeader(t *testin
|
||||
}
|
||||
|
||||
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(""), 0, nil, nil, nil, nil, nil)
|
||||
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
|
||||
assert.EqualError(t, err, errs.ErrNotFoundTransactionDataInFile.Message)
|
||||
}
|
||||
|
||||
func TestFireFlyIIICsvFileConverterParseImportedData_MissingRequiredColumn(t *testing.T) {
|
||||
@@ -450,13 +351,18 @@ func TestFireFlyIIICsvFileConverterParseImportedData_MissingRequiredColumn(t *te
|
||||
}
|
||||
|
||||
// Missing Time Column
|
||||
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("type,amount,source_name,source_type,destination_name,destination_type,category\n"+
|
||||
"\"Opening balance\",123.45,\"Initial balance for \"\"Test Account\"\"\",\"Initial balance account\",\"Test Account\",\"Asset account\",\n"), 0, nil, nil, nil, nil, nil)
|
||||
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte("type,amount,source_name,destination_name,category\n"+
|
||||
"\"Opening balance\",123.45,\"Initial balance for \"\"Test Account\"\"\",\"Test Account\",\n"), 0, nil, nil, nil, nil, nil)
|
||||
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
|
||||
|
||||
// Missing Type Column
|
||||
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("amount,date,source_name,source_type,destination_name,destination_type,category\n"+
|
||||
"123.45,2024-09-01T00:00:00+08:00,\"Initial balance for \"\"Test Account\"\"\",\"Initial balance account\",\"Test Account\",\"Asset account\",\n"), 0, nil, nil, nil, nil, nil)
|
||||
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("amount,date,source_name,destination_name,category\n"+
|
||||
"123.45,2024-09-01T00:00:00+08:00,\"Initial balance for \"\"Test Account\"\"\",\"Test Account\",\n"), 0, nil, nil, nil, nil, nil)
|
||||
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
|
||||
|
||||
// Missing Sub Category Column
|
||||
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,destination_name\n"+
|
||||
"\"Opening balance\",123.45,2024-09-01T00:00:00+08:00,\"Initial balance for \"\"Test Account\"\"\",\"Test Account\"\n"), 0, nil, nil, nil, nil, nil)
|
||||
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
|
||||
|
||||
// Missing Account Name Column
|
||||
@@ -465,22 +371,12 @@ func TestFireFlyIIICsvFileConverterParseImportedData_MissingRequiredColumn(t *te
|
||||
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
|
||||
|
||||
// Missing Amount Column
|
||||
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,date,source_name,source_type,destination_name,destination_type,category\n"+
|
||||
"\"Opening balance\",2024-09-01T00:00:00+08:00,\"Initial balance for \"\"Test Account\"\"\",\"Initial balance account\",\"Test Account\",\"Asset account\",\n"), 0, nil, nil, nil, nil, nil)
|
||||
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,date,source_name,destination_name,category\n"+
|
||||
"\"Opening balance\",2024-09-01T00:00:00+08:00,\"Initial balance for \"\"Test Account\"\"\",\"Test Account\",\n"), 0, nil, nil, nil, nil, nil)
|
||||
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
|
||||
|
||||
// Missing Account2 Name Column
|
||||
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,category\n"+
|
||||
"\"Opening balance\",123.45,2024-09-01T00:00:00+08:00,\"Initial balance for \"\"Test Account\"\"\",\n"), 0, nil, nil, nil, nil, nil)
|
||||
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
|
||||
|
||||
// Missing Source Account Type Column
|
||||
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,destination_name,destination_type,category\n"+
|
||||
"\"Opening balance\",123.45,2024-09-01T00:00:00+08:00,\"Initial balance for \"\"Test Account\"\"\",\"Test Account\",\"Asset account\",\"Asset account\",\n"), 0, nil, nil, nil, nil, nil)
|
||||
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
|
||||
|
||||
// Missing Destination Account Type Column
|
||||
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte("type,amount,date,source_name,source_type,destination_name,category\n"+
|
||||
"\"Opening balance\",123.45,2024-09-01T00:00:00+08:00,\"Initial balance for \"\"Test Account\"\"\",\"Asset account\",\"Test Account\",\n"), 0, nil, nil, nil, nil, nil)
|
||||
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
|
||||
}
|
||||
|
||||
@@ -2,132 +2,103 @@ package fireflyIII
|
||||
|
||||
import (
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
|
||||
"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"
|
||||
)
|
||||
|
||||
const fireflyIIITransactionTimeColumnName = "date"
|
||||
const fireflyIIITransactionTypeColumnName = "type"
|
||||
const fireflyIIITransactionCategoryColumnName = "category"
|
||||
const fireflyIIITransactionSourceAccountNameColumnName = "source_name"
|
||||
const fireflyIIITransactionSourceAccountTypeColumnName = "source_type"
|
||||
const fireflyIIITransactionCurrencyCodeColumnName = "currency_code"
|
||||
const fireflyIIITransactionAmountColumnName = "amount"
|
||||
const fireflyIIITransactionDestinationAccountNameColumnName = "destination_name"
|
||||
const fireflyIIITransactionDestinationAccountTypeColumnName = "destination_type"
|
||||
const fireflyIIITransactionForeignCurrencyCodeColumnName = "foreign_currency_code"
|
||||
const fireflyIIITransactionForeignAmountColumnName = "foreign_amount"
|
||||
const fireflyIIITransactionTagsColumnName = "tags"
|
||||
const fireflyIIITransactionDescriptionColumnName = "description"
|
||||
|
||||
const fireflyIIIAssetAccountName = "Asset account"
|
||||
const fireflyIIIExpenseAccountName = "Expense account"
|
||||
const fireflyIIIRevenueAccountName = "Revenue account"
|
||||
const fireflyIIIDebtAccountName = "Debt"
|
||||
|
||||
// fireflyIIITransactionDataRowParser defines the structure of firefly III transaction data row parser
|
||||
type fireflyIIITransactionDataRowParser struct {
|
||||
}
|
||||
|
||||
// GetAddedColumns returns the added columns after converting the data row
|
||||
func (p *fireflyIIITransactionDataRowParser) GetAddedColumns() []datatable.TransactionDataTableColumn {
|
||||
return []datatable.TransactionDataTableColumn{
|
||||
datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE,
|
||||
}
|
||||
}
|
||||
|
||||
// Parse returns the converted transaction data row
|
||||
func (p *fireflyIIITransactionDataRowParser) Parse(ctx core.Context, user *models.User, dataRow datatable.CommonDataTableRow, rowId string) (rowData map[datatable.TransactionDataTableColumn]string, rowDataValid bool, err error) {
|
||||
rowData = make(map[datatable.TransactionDataTableColumn]string, len(fireflyIIITransactionSupportedColumns))
|
||||
func (p *fireflyIIITransactionDataRowParser) Parse(data map[datatable.TransactionDataTableColumn]string) (rowData map[datatable.TransactionDataTableColumn]string, rowDataValid bool, err error) {
|
||||
rowData = make(map[datatable.TransactionDataTableColumn]string, len(data))
|
||||
|
||||
for column, value := range data {
|
||||
rowData[column] = value
|
||||
}
|
||||
|
||||
// use the expense and revenue account name as category names if the category name is empty
|
||||
if rowData[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] == "" {
|
||||
if rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] == fireflyIIITransactionTypeNameMapping[models.TRANSACTION_TYPE_INCOME] {
|
||||
rowData[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] = rowData[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME]
|
||||
} else if rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] == fireflyIIITransactionTypeNameMapping[models.TRANSACTION_TYPE_EXPENSE] {
|
||||
rowData[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] = rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME]
|
||||
}
|
||||
}
|
||||
|
||||
// parse long date time and timezone
|
||||
dateTime, err := utils.ParseFromLongDateTimeWithTimezoneRFC3339Format(dataRow.GetData(fireflyIIITransactionTimeColumnName))
|
||||
if rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME] != "" {
|
||||
dateTime, err := utils.ParseFromLongDateTimeWithTimezoneRFC3339Format(rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME])
|
||||
|
||||
if err != nil {
|
||||
return nil, false, errs.ErrTransactionTimeInvalid
|
||||
if err != nil {
|
||||
return nil, false, errs.ErrTransactionTimeInvalid
|
||||
}
|
||||
|
||||
rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME] = utils.FormatUnixTimeToLongDateTime(dateTime.Unix(), dateTime.Location())
|
||||
rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE] = utils.FormatTimezoneOffset(dateTime.Location())
|
||||
}
|
||||
|
||||
rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME] = utils.FormatUnixTimeToLongDateTime(dateTime.Unix(), dateTime.Location())
|
||||
rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE] = utils.FormatTimezoneOffset(dateTime.Location())
|
||||
|
||||
// parse transaction type, transaction category and amount
|
||||
transactionType := dataRow.GetData(fireflyIIITransactionTypeColumnName)
|
||||
sourceAccountType := dataRow.GetData(fireflyIIITransactionSourceAccountTypeColumnName)
|
||||
destinationAccountType := dataRow.GetData(fireflyIIITransactionDestinationAccountTypeColumnName)
|
||||
|
||||
amount, err := utils.ParseAmount(utils.TrimTrailingZerosInDecimal(dataRow.GetData(fireflyIIITransactionAmountColumnName)))
|
||||
|
||||
if err != nil {
|
||||
return nil, false, errs.ErrAmountInvalid
|
||||
}
|
||||
|
||||
foreignAmount := amount
|
||||
|
||||
if dataRow.HasData(fireflyIIITransactionForeignAmountColumnName) && dataRow.GetData(fireflyIIITransactionForeignAmountColumnName) != "" {
|
||||
foreignAmount, err = utils.ParseAmount(utils.TrimTrailingZerosInDecimal(dataRow.GetData(fireflyIIITransactionForeignAmountColumnName)))
|
||||
// trim trailing zero in decimal
|
||||
if rowData[datatable.TRANSACTION_DATA_TABLE_AMOUNT] != "" {
|
||||
rowData[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.TrimTrailingZerosInDecimal(rowData[datatable.TRANSACTION_DATA_TABLE_AMOUNT])
|
||||
amount, err := utils.ParseAmount(rowData[datatable.TRANSACTION_DATA_TABLE_AMOUNT])
|
||||
|
||||
if err != nil {
|
||||
return nil, false, errs.ErrAmountInvalid
|
||||
}
|
||||
}
|
||||
|
||||
rowData[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] = dataRow.GetData(fireflyIIITransactionCategoryColumnName)
|
||||
|
||||
if sourceAccountType == fireflyIIIRevenueAccountName && (destinationAccountType == fireflyIIIAssetAccountName || destinationAccountType == fireflyIIIDebtAccountName) { // income
|
||||
rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = fireflyIIITransactionTypeNameMapping[models.TRANSACTION_TYPE_INCOME]
|
||||
|
||||
// if the category is empty, use the source account (revenue account) name as the category name
|
||||
if rowData[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] == "" {
|
||||
rowData[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] = dataRow.GetData(fireflyIIITransactionSourceAccountNameColumnName)
|
||||
}
|
||||
|
||||
rowData[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = dataRow.GetData(fireflyIIITransactionDestinationAccountNameColumnName)
|
||||
rowData[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(amount)
|
||||
} else if (sourceAccountType == fireflyIIIAssetAccountName || sourceAccountType == fireflyIIIDebtAccountName) && destinationAccountType == fireflyIIIExpenseAccountName { // expense
|
||||
rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = fireflyIIITransactionTypeNameMapping[models.TRANSACTION_TYPE_EXPENSE]
|
||||
|
||||
// if the category is empty, use the destination account (expense account) name as the category name
|
||||
if rowData[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] == "" {
|
||||
rowData[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] = dataRow.GetData(fireflyIIITransactionDestinationAccountNameColumnName)
|
||||
}
|
||||
|
||||
rowData[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = dataRow.GetData(fireflyIIITransactionSourceAccountNameColumnName)
|
||||
rowData[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(-amount)
|
||||
} else if transactionType == fireflyIIITransactionTypeNameMapping[models.TRANSACTION_TYPE_MODIFY_BALANCE] { // opening balance
|
||||
rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = fireflyIIITransactionTypeNameMapping[models.TRANSACTION_TYPE_MODIFY_BALANCE]
|
||||
rowData[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] = ""
|
||||
rowData[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = dataRow.GetData(fireflyIIITransactionDestinationAccountNameColumnName)
|
||||
rowData[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(amount)
|
||||
} else if (sourceAccountType == fireflyIIIAssetAccountName || sourceAccountType == fireflyIIIDebtAccountName) && (destinationAccountType == fireflyIIIAssetAccountName || destinationAccountType == fireflyIIIDebtAccountName) { // transfer
|
||||
rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = fireflyIIITransactionTypeNameMapping[models.TRANSACTION_TYPE_TRANSFER]
|
||||
rowData[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = dataRow.GetData(fireflyIIITransactionSourceAccountNameColumnName)
|
||||
rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = dataRow.GetData(fireflyIIITransactionDestinationAccountNameColumnName)
|
||||
|
||||
if transactionType == fireflyIIITransactionTypeNameMapping[models.TRANSACTION_TYPE_EXPENSE] {
|
||||
if rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] == fireflyIIITransactionTypeNameMapping[models.TRANSACTION_TYPE_EXPENSE] {
|
||||
rowData[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(-amount)
|
||||
rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT] = utils.FormatAmount(-foreignAmount)
|
||||
} else {
|
||||
rowData[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(amount)
|
||||
rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT] = utils.FormatAmount(foreignAmount)
|
||||
}
|
||||
} else {
|
||||
log.Errorf(ctx, "[fireflyiii_transaction_data_row_parser.Parse] cannot detect transaction type, source account type is \"%s\", destination account type is \"%s\", Firefly III transaction type is \"%s\"", sourceAccountType, destinationAccountType, transactionType)
|
||||
return nil, false, errs.ErrTransactionTypeInvalid
|
||||
}
|
||||
|
||||
// parse account currency
|
||||
rowData[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY] = dataRow.GetData(fireflyIIITransactionCurrencyCodeColumnName)
|
||||
rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_CURRENCY] = dataRow.GetData(fireflyIIITransactionForeignCurrencyCodeColumnName)
|
||||
if rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT] != "" {
|
||||
rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT] = utils.TrimTrailingZerosInDecimal(rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT])
|
||||
amount, err := utils.ParseAmount(rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT])
|
||||
|
||||
if err != nil {
|
||||
return nil, false, errs.ErrAmountInvalid
|
||||
}
|
||||
|
||||
if rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] == fireflyIIITransactionTypeNameMapping[models.TRANSACTION_TYPE_EXPENSE] {
|
||||
rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT] = utils.FormatAmount(-amount)
|
||||
} else {
|
||||
rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT] = utils.FormatAmount(amount)
|
||||
}
|
||||
} else {
|
||||
rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT] = rowData[datatable.TRANSACTION_DATA_TABLE_AMOUNT]
|
||||
}
|
||||
|
||||
// the related account currency field is foreign currency in firefly III actually
|
||||
if rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_CURRENCY] == "" && rowData[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY] != "" {
|
||||
if rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_CURRENCY] == "" {
|
||||
rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_CURRENCY] = rowData[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_CURRENCY]
|
||||
}
|
||||
|
||||
// parse tags / description
|
||||
rowData[datatable.TRANSACTION_DATA_TABLE_TAGS] = dataRow.GetData(fireflyIIITransactionTagsColumnName)
|
||||
rowData[datatable.TRANSACTION_DATA_TABLE_DESCRIPTION] = dataRow.GetData(fireflyIIITransactionDescriptionColumnName)
|
||||
// the destination account of modify balance transaction in firefly III is the asset account
|
||||
if rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] == fireflyIIITransactionTypeNameMapping[models.TRANSACTION_TYPE_MODIFY_BALANCE] {
|
||||
rowData[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME]
|
||||
}
|
||||
|
||||
// the destination account of income transaction in firefly III is the asset account
|
||||
if rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] == fireflyIIITransactionTypeNameMapping[models.TRANSACTION_TYPE_INCOME] {
|
||||
rowData[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME]
|
||||
}
|
||||
|
||||
return rowData, true, nil
|
||||
}
|
||||
|
||||
// createFireflyIIITransactionDataRowParser returns firefly III transaction data row parser
|
||||
func createFireflyIIITransactionDataRowParser() datatable.CommonTransactionDataRowParser {
|
||||
func createFireflyIIITransactionDataRowParser() datatable.TransactionDataRowParser {
|
||||
return &fireflyIIITransactionDataRowParser{}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package jdcom
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
|
||||
"golang.org/x/text/encoding/unicode"
|
||||
"golang.org/x/text/transform"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/converter"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/csv"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
)
|
||||
|
||||
// jdComFinanceTransactionDataCsvFileImporter defines the structure of jd.com finance csv importer for transaction data
|
||||
type jdComFinanceTransactionDataCsvFileImporter struct {
|
||||
fileHeaderLineBeginning string
|
||||
dataHeaderStartContentBeginning string
|
||||
}
|
||||
|
||||
// Initialize a jd.com finance transaction data csv file importer singleton instance
|
||||
var (
|
||||
JDComFinanceTransactionDataCsvFileImporter = &jdComFinanceTransactionDataCsvFileImporter{}
|
||||
)
|
||||
|
||||
// ParseImportedData returns the imported data by parsing the jd.com finance transaction csv data
|
||||
func (c *jdComFinanceTransactionDataCsvFileImporter) ParseImportedData(ctx core.Context, user *models.User, data []byte, defaultTimezoneOffset int16, 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) {
|
||||
fallback := unicode.UTF8.NewDecoder()
|
||||
reader := transform.NewReader(bytes.NewReader(data), unicode.BOMOverride(fallback))
|
||||
|
||||
csvDataTable, err := csv.CreateNewCsvBasicDataTable(ctx, reader, false)
|
||||
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, nil, nil, err
|
||||
}
|
||||
|
||||
dataTable, err := createNewJDComFinanceTransactionBasicDataTable(ctx, csvDataTable)
|
||||
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, nil, nil, err
|
||||
}
|
||||
|
||||
commonDataTable := datatable.CreateNewCommonDataTableFromBasicDataTable(dataTable)
|
||||
|
||||
if !commonDataTable.HasColumn(jdComFinanceTransactionTimeColumnName) ||
|
||||
!commonDataTable.HasColumn(jdComFinanceTransactionMerchantNameColumnName) ||
|
||||
!commonDataTable.HasColumn(jdComFinanceTransactionMemoColumnName) ||
|
||||
!commonDataTable.HasColumn(jdComFinanceTransactionAmountColumnName) ||
|
||||
!commonDataTable.HasColumn(jdComFinanceTransactionRelatedAccountColumnName) ||
|
||||
!commonDataTable.HasColumn(jdComFinanceTransactionStatusColumnName) ||
|
||||
!commonDataTable.HasColumn(jdComFinanceTransactionTypeColumnName) {
|
||||
log.Errorf(ctx, "[jdcom_finance_transaction_data_csv_file_importer.ParseImportedData] cannot parse jd.com finance csv data, because missing essential columns in header row")
|
||||
return nil, nil, nil, nil, nil, nil, errs.ErrMissingRequiredFieldInHeaderRow
|
||||
}
|
||||
|
||||
transactionRowParser := createJDComFinanceTransactionDataRowParser(dataTable.HeaderColumnNames())
|
||||
transactionDataTable := datatable.CreateNewTransactionDataTableFromCommonDataTable(commonDataTable, jdComFinanceTransactionSupportedColumns, transactionRowParser)
|
||||
dataTableImporter := converter.CreateNewSimpleImporterWithTypeNameMapping(jdComFinanceTransactionTypeNameMapping)
|
||||
|
||||
return dataTableImporter.ParseImportedData(ctx, user, transactionDataTable, defaultTimezoneOffset, accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap)
|
||||
}
|
||||
@@ -0,0 +1,508 @@
|
||||
package jdcom
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||
)
|
||||
|
||||
func TestJDComFinanceCsvFileImporterParseImportedData_MinimumValidData(t *testing.T) {
|
||||
converter := JDComFinanceTransactionDataCsvFileImporter
|
||||
context := core.NewNullContext()
|
||||
|
||||
user := &models.User{
|
||||
Uid: 1234567890,
|
||||
DefaultCurrency: "CNY",
|
||||
}
|
||||
|
||||
data := "导出信息:\n" +
|
||||
"京东账号名:xxxxxx\n" +
|
||||
"日期区间:2025-01-01 至 2025-09-01\n" +
|
||||
"\n" +
|
||||
"交易时间,商户名称,交易说明,金额,收/付款方式,交易状态,收/支,交易分类\n" +
|
||||
"2025-09-01 01:23:45,xxx,xxx,0.12,余额,交易成功,收入,其他\n" +
|
||||
"2025-09-01 12:34:56,xxx,xxx,123.45,银行卡,交易成功,支出,其他网购\n" +
|
||||
"2025-09-01 23:59:59,xxx,京东钱包余额充值,0.05,银行卡,交易成功,不计收支,余额\n" +
|
||||
"2025-09-02 23:59:59,xxx,京东余额提现,0.03,银行卡,交易成功,不计收支,余额\n"
|
||||
allNewTransactions, allNewAccounts, allNewSubExpenseCategories, allNewSubIncomeCategories, allNewSubTransferCategories, allNewTags, err := converter.ParseImportedData(context, user, []byte(data), 0, nil, nil, nil, nil, nil)
|
||||
assert.Nil(t, err)
|
||||
|
||||
assert.Equal(t, 4, len(allNewTransactions))
|
||||
assert.Equal(t, 3, len(allNewAccounts))
|
||||
assert.Equal(t, 1, len(allNewSubExpenseCategories))
|
||||
assert.Equal(t, 1, len(allNewSubIncomeCategories))
|
||||
assert.Equal(t, 1, len(allNewSubTransferCategories))
|
||||
assert.Equal(t, 0, len(allNewTags))
|
||||
|
||||
assert.Equal(t, int64(1234567890), allNewTransactions[0].Uid)
|
||||
assert.Equal(t, models.TRANSACTION_DB_TYPE_INCOME, allNewTransactions[0].Type)
|
||||
assert.Equal(t, "2025-09-01 01:23:45", utils.FormatUnixTimeToLongDateTime(utils.GetUnixTimeFromTransactionTime(allNewTransactions[0].TransactionTime), time.UTC))
|
||||
assert.Equal(t, int64(12), allNewTransactions[0].Amount)
|
||||
assert.Equal(t, "余额", allNewTransactions[0].OriginalSourceAccountName)
|
||||
assert.Equal(t, "其他", allNewTransactions[0].OriginalCategoryName)
|
||||
|
||||
assert.Equal(t, int64(1234567890), allNewTransactions[1].Uid)
|
||||
assert.Equal(t, models.TRANSACTION_DB_TYPE_EXPENSE, allNewTransactions[1].Type)
|
||||
assert.Equal(t, "2025-09-01 12:34:56", utils.FormatUnixTimeToLongDateTime(utils.GetUnixTimeFromTransactionTime(allNewTransactions[1].TransactionTime), time.UTC))
|
||||
assert.Equal(t, int64(12345), allNewTransactions[1].Amount)
|
||||
assert.Equal(t, "银行卡", allNewTransactions[1].OriginalSourceAccountName)
|
||||
assert.Equal(t, "其他网购", allNewTransactions[1].OriginalCategoryName)
|
||||
|
||||
assert.Equal(t, int64(1234567890), allNewTransactions[2].Uid)
|
||||
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[2].Type)
|
||||
assert.Equal(t, "2025-09-01 23:59:59", utils.FormatUnixTimeToLongDateTime(utils.GetUnixTimeFromTransactionTime(allNewTransactions[2].TransactionTime), time.UTC))
|
||||
assert.Equal(t, int64(5), allNewTransactions[2].Amount)
|
||||
assert.Equal(t, "银行卡", allNewTransactions[2].OriginalSourceAccountName)
|
||||
assert.Equal(t, "xxx", allNewTransactions[2].OriginalDestinationAccountName)
|
||||
assert.Equal(t, "余额", allNewTransactions[2].OriginalCategoryName)
|
||||
|
||||
assert.Equal(t, int64(1234567890), allNewTransactions[3].Uid)
|
||||
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[3].Type)
|
||||
assert.Equal(t, "2025-09-02 23:59:59", utils.FormatUnixTimeToLongDateTime(utils.GetUnixTimeFromTransactionTime(allNewTransactions[3].TransactionTime), time.UTC))
|
||||
assert.Equal(t, int64(3), allNewTransactions[3].Amount)
|
||||
assert.Equal(t, "xxx", allNewTransactions[3].OriginalSourceAccountName)
|
||||
assert.Equal(t, "银行卡", allNewTransactions[3].OriginalDestinationAccountName)
|
||||
assert.Equal(t, "余额", allNewTransactions[3].OriginalCategoryName)
|
||||
|
||||
assert.Equal(t, int64(1234567890), allNewAccounts[0].Uid)
|
||||
assert.Equal(t, "余额", allNewAccounts[0].Name)
|
||||
assert.Equal(t, "CNY", allNewAccounts[0].Currency)
|
||||
|
||||
assert.Equal(t, int64(1234567890), allNewAccounts[1].Uid)
|
||||
assert.Equal(t, "银行卡", allNewAccounts[1].Name)
|
||||
assert.Equal(t, "CNY", allNewAccounts[1].Currency)
|
||||
|
||||
assert.Equal(t, int64(1234567890), allNewAccounts[2].Uid)
|
||||
assert.Equal(t, "xxx", allNewAccounts[2].Name)
|
||||
assert.Equal(t, "CNY", allNewAccounts[2].Currency)
|
||||
|
||||
assert.Equal(t, int64(1234567890), allNewSubExpenseCategories[0].Uid)
|
||||
assert.Equal(t, "其他网购", allNewSubExpenseCategories[0].Name)
|
||||
|
||||
assert.Equal(t, int64(1234567890), allNewSubIncomeCategories[0].Uid)
|
||||
assert.Equal(t, "其他", allNewSubIncomeCategories[0].Name)
|
||||
|
||||
assert.Equal(t, int64(1234567890), allNewSubTransferCategories[0].Uid)
|
||||
assert.Equal(t, "余额", allNewSubTransferCategories[0].Name)
|
||||
}
|
||||
|
||||
func TestJDComFinanceCsvFileImporterParseImportedData_ParseRefundTransaction(t *testing.T) {
|
||||
converter := JDComFinanceTransactionDataCsvFileImporter
|
||||
context := core.NewNullContext()
|
||||
|
||||
user := &models.User{
|
||||
Uid: 1234567890,
|
||||
DefaultCurrency: "CNY",
|
||||
}
|
||||
|
||||
data1 := "导出信息:\n" +
|
||||
"京东账号名:xxxxxx\n" +
|
||||
"日期区间:2025-01-01 至 2025-09-01\n" +
|
||||
"\n" +
|
||||
"交易时间,商户名称,交易说明,金额,收/付款方式,交易状态,收/支\n" +
|
||||
"2025-09-01 01:23:45,xxx,xxx,0.12,银行卡,退款成功,不计收支\n" +
|
||||
"2025-09-01 02:34:56,xxx,xxx,0.12(已全额退款),银行卡,交易成功,不计收支\n" +
|
||||
"2025-09-02 01:23:45,xxx,xxx,3.45,银行卡,退款成功,不计收支\n" +
|
||||
"2025-09-02 02:34:56,xxx,xxx,123.45(已退款3.45),银行卡,交易成功,支出\n"
|
||||
allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(data1), 0, nil, nil, nil, nil, nil)
|
||||
assert.Nil(t, err)
|
||||
|
||||
assert.Equal(t, int64(1234567890), allNewTransactions[0].Uid)
|
||||
assert.Equal(t, models.TRANSACTION_DB_TYPE_EXPENSE, allNewTransactions[0].Type)
|
||||
assert.Equal(t, "2025-09-01 01:23:45", utils.FormatUnixTimeToLongDateTime(utils.GetUnixTimeFromTransactionTime(allNewTransactions[0].TransactionTime), time.UTC))
|
||||
assert.Equal(t, int64(-12), allNewTransactions[0].Amount)
|
||||
assert.Equal(t, "银行卡", allNewTransactions[0].OriginalSourceAccountName)
|
||||
|
||||
assert.Equal(t, int64(1234567890), allNewTransactions[1].Uid)
|
||||
assert.Equal(t, models.TRANSACTION_DB_TYPE_EXPENSE, allNewTransactions[1].Type)
|
||||
assert.Equal(t, "2025-09-01 02:34:56", utils.FormatUnixTimeToLongDateTime(utils.GetUnixTimeFromTransactionTime(allNewTransactions[1].TransactionTime), time.UTC))
|
||||
assert.Equal(t, int64(12), allNewTransactions[1].Amount)
|
||||
assert.Equal(t, "银行卡", allNewTransactions[1].OriginalSourceAccountName)
|
||||
|
||||
assert.Equal(t, int64(1234567890), allNewTransactions[2].Uid)
|
||||
assert.Equal(t, models.TRANSACTION_DB_TYPE_EXPENSE, allNewTransactions[2].Type)
|
||||
assert.Equal(t, "2025-09-02 01:23:45", utils.FormatUnixTimeToLongDateTime(utils.GetUnixTimeFromTransactionTime(allNewTransactions[2].TransactionTime), time.UTC))
|
||||
assert.Equal(t, int64(-345), allNewTransactions[2].Amount)
|
||||
assert.Equal(t, "银行卡", allNewTransactions[2].OriginalSourceAccountName)
|
||||
|
||||
assert.Equal(t, int64(1234567890), allNewTransactions[3].Uid)
|
||||
assert.Equal(t, models.TRANSACTION_DB_TYPE_EXPENSE, allNewTransactions[3].Type)
|
||||
assert.Equal(t, "2025-09-02 02:34:56", utils.FormatUnixTimeToLongDateTime(utils.GetUnixTimeFromTransactionTime(allNewTransactions[3].TransactionTime), time.UTC))
|
||||
assert.Equal(t, int64(12345), allNewTransactions[3].Amount)
|
||||
assert.Equal(t, "银行卡", allNewTransactions[3].OriginalSourceAccountName)
|
||||
}
|
||||
|
||||
func TestJDComFinanceCsvFileImporterParseImportedData_ParseInvalidTime(t *testing.T) {
|
||||
converter := JDComFinanceTransactionDataCsvFileImporter
|
||||
context := core.NewNullContext()
|
||||
|
||||
user := &models.User{
|
||||
Uid: 1234567890,
|
||||
DefaultCurrency: "CNY",
|
||||
}
|
||||
|
||||
data1 := "导出信息:\n" +
|
||||
"京东账号名:xxxxxx\n" +
|
||||
"日期区间:2025-01-01 至 2025-09-01\n" +
|
||||
"\n" +
|
||||
"交易时间,商户名称,交易说明,金额,收/付款方式,交易状态,收/支\n" +
|
||||
"2025-09-01T01:23:45,xxx,xxx,0.12,银行卡,交易成功,支出\n"
|
||||
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(data1), 0, nil, nil, nil, nil, nil)
|
||||
assert.EqualError(t, err, errs.ErrTransactionTimeInvalid.Message)
|
||||
|
||||
data2 := "导出信息:\n" +
|
||||
"京东账号名:xxxxxx\n" +
|
||||
"日期区间:2025-01-01 至 2025-09-01\n" +
|
||||
"\n" +
|
||||
"交易时间,商户名称,交易说明,金额,收/付款方式,交易状态,收/支\n" +
|
||||
"09/01/2025 01:23:45,xxx,xxx,0.12,银行卡,交易成功,支出\n"
|
||||
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data2), 0, nil, nil, nil, nil, nil)
|
||||
assert.EqualError(t, err, errs.ErrTransactionTimeInvalid.Message)
|
||||
}
|
||||
|
||||
func TestJDComFinanceCsvFileImporterParseImportedData_ParseInvalidType(t *testing.T) {
|
||||
converter := JDComFinanceTransactionDataCsvFileImporter
|
||||
context := core.NewNullContext()
|
||||
|
||||
user := &models.User{
|
||||
Uid: 1234567890,
|
||||
DefaultCurrency: "CNY",
|
||||
}
|
||||
|
||||
data := "导出信息:\n" +
|
||||
"京东账号名:xxxxxx\n" +
|
||||
"日期区间:2025-01-01 至 2025-09-01\n" +
|
||||
"\n" +
|
||||
"交易时间,商户名称,交易说明,金额,收/付款方式,交易状态,收/支\n" +
|
||||
"2025-09-01 01:23:45,xxx,xxx,0.12,银行卡,交易成功,转账\n"
|
||||
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(data), 0, nil, nil, nil, nil, nil)
|
||||
assert.EqualError(t, err, errs.ErrNotFoundTransactionDataInFile.Message)
|
||||
}
|
||||
|
||||
func TestJDComFinanceCsvFileImporterParseImportedData_ParseInvalidAmount(t *testing.T) {
|
||||
converter := JDComFinanceTransactionDataCsvFileImporter
|
||||
context := core.NewNullContext()
|
||||
|
||||
user := &models.User{
|
||||
Uid: 1234567890,
|
||||
DefaultCurrency: "CNY",
|
||||
}
|
||||
|
||||
data := "导出信息:\n" +
|
||||
"京东账号名:xxxxxx\n" +
|
||||
"日期区间:2025-01-01 至 2025-09-01\n" +
|
||||
"\n" +
|
||||
"交易时间,商户名称,交易说明,金额,收/付款方式,交易状态,收/支\n" +
|
||||
"2025-09-01 01:23:45,xxx,xxx,¥0.12,银行卡,交易成功,支出\n"
|
||||
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(data), 0, nil, nil, nil, nil, nil)
|
||||
assert.EqualError(t, err, errs.ErrAmountInvalid.Message)
|
||||
}
|
||||
|
||||
func TestJDComFinanceCsvFileImporterParseImportedData_ParseAccountName(t *testing.T) {
|
||||
converter := JDComFinanceTransactionDataCsvFileImporter
|
||||
context := core.NewNullContext()
|
||||
|
||||
user := &models.User{
|
||||
Uid: 1234567890,
|
||||
DefaultCurrency: "CNY",
|
||||
}
|
||||
|
||||
// transfer to jd.com finance wallet
|
||||
data1 := "导出信息:\n" +
|
||||
"京东账号名:xxxxxx\n" +
|
||||
"日期区间:2025-01-01 至 2025-09-01\n" +
|
||||
"\n" +
|
||||
"交易时间,商户名称,交易说明,金额,收/付款方式,交易状态,收/支,交易分类\n" +
|
||||
"2025-09-01 01:23:45,xxx,京东钱包余额充值,0.05,银行卡,交易成功,不计收支,余额\n"
|
||||
allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(data1), 0, nil, nil, nil, nil, nil)
|
||||
assert.Nil(t, err)
|
||||
|
||||
assert.Equal(t, 1, len(allNewTransactions))
|
||||
assert.Equal(t, "银行卡", allNewTransactions[0].OriginalSourceAccountName)
|
||||
assert.Equal(t, "xxx", allNewTransactions[0].OriginalDestinationAccountName)
|
||||
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[0].Type)
|
||||
|
||||
// transfer from jd.com finance wallet
|
||||
data2 := "导出信息:\n" +
|
||||
"京东账号名:xxxxxx\n" +
|
||||
"日期区间:2025-01-01 至 2025-09-01\n" +
|
||||
"\n" +
|
||||
"交易时间,商户名称,交易说明,金额,收/付款方式,交易状态,收/支,交易分类\n" +
|
||||
"2025-09-01 01:23:45,xxx,京东余额提现,0.05,银行卡,交易成功,不计收支,余额\n"
|
||||
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data2), 0, nil, nil, nil, nil, nil)
|
||||
assert.Nil(t, err)
|
||||
|
||||
assert.Equal(t, 1, len(allNewTransactions))
|
||||
assert.Equal(t, "xxx", allNewTransactions[0].OriginalSourceAccountName)
|
||||
assert.Equal(t, "银行卡", allNewTransactions[0].OriginalDestinationAccountName)
|
||||
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[0].Type)
|
||||
|
||||
// transfer from other account
|
||||
data3 := "导出信息:\n" +
|
||||
"京东账号名:xxxxxx\n" +
|
||||
"日期区间:2025-01-01 至 2025-09-01\n" +
|
||||
"\n" +
|
||||
"交易时间,商户名称,交易说明,金额,收/付款方式,交易状态,收/支,交易分类\n" +
|
||||
"2025-09-01 01:23:45,xxx,京东小金库-转入,0.05,余额,交易成功,不计收支,小金库\n"
|
||||
assert.Nil(t, err)
|
||||
|
||||
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data3), 0, nil, nil, nil, nil, nil)
|
||||
assert.Nil(t, err)
|
||||
|
||||
assert.Equal(t, 1, len(allNewTransactions))
|
||||
assert.Equal(t, "余额", allNewTransactions[0].OriginalSourceAccountName)
|
||||
assert.Equal(t, "xxx", allNewTransactions[0].OriginalDestinationAccountName)
|
||||
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[0].Type)
|
||||
|
||||
// transfer to other account
|
||||
data4 := "导出信息:\n" +
|
||||
"京东账号名:xxxxxx\n" +
|
||||
"日期区间:2025-01-01 至 2025-09-01\n" +
|
||||
"\n" +
|
||||
"交易时间,商户名称,交易说明,金额,收/付款方式,交易状态,收/支,交易分类\n" +
|
||||
"2025-09-01 01:23:45,xxx,京东小金库-转出,0.05,余额,交易成功,不计收支,小金库\n"
|
||||
assert.Nil(t, err)
|
||||
|
||||
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data4), 0, nil, nil, nil, nil, nil)
|
||||
assert.Nil(t, err)
|
||||
|
||||
assert.Equal(t, 1, len(allNewTransactions))
|
||||
assert.Equal(t, "xxx", allNewTransactions[0].OriginalSourceAccountName)
|
||||
assert.Equal(t, "余额", allNewTransactions[0].OriginalDestinationAccountName)
|
||||
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[0].Type)
|
||||
|
||||
// refund
|
||||
data5 := "导出信息:\n" +
|
||||
"京东账号名:xxxxxx\n" +
|
||||
"日期区间:2025-01-01 至 2025-09-01\n" +
|
||||
"\n" +
|
||||
"交易时间,商户名称,交易说明,金额,收/付款方式,交易状态,收/支,交易分类\n" +
|
||||
"2025-09-01 01:23:45,xxx,价保退款,0.05,银行卡,交易成功,不计收支,其他\n"
|
||||
assert.Nil(t, err)
|
||||
|
||||
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data5), 0, nil, nil, nil, nil, nil)
|
||||
assert.Nil(t, err)
|
||||
|
||||
assert.Equal(t, 1, len(allNewTransactions))
|
||||
assert.Equal(t, "银行卡", allNewTransactions[0].OriginalSourceAccountName)
|
||||
assert.Equal(t, models.TRANSACTION_DB_TYPE_EXPENSE, allNewTransactions[0].Type)
|
||||
|
||||
// repayment
|
||||
data6 := "导出信息:\n" +
|
||||
"京东账号名:xxxxxx\n" +
|
||||
"日期区间:2025-01-01 至 2025-09-01\n" +
|
||||
"\n" +
|
||||
"交易时间,商户名称,交易说明,金额,收/付款方式,交易状态,收/支,交易分类\n" +
|
||||
"2025-09-01 01:23:45,xxx,白条主动还款,0.05,银行卡,交易成功,不计收支,白条\n"
|
||||
assert.Nil(t, err)
|
||||
|
||||
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data6), 0, nil, nil, nil, nil, nil)
|
||||
assert.Nil(t, err)
|
||||
|
||||
assert.Equal(t, 1, len(allNewTransactions))
|
||||
assert.Equal(t, "银行卡", allNewTransactions[0].OriginalSourceAccountName)
|
||||
assert.Equal(t, "xxx", allNewTransactions[0].OriginalDestinationAccountName)
|
||||
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[0].Type)
|
||||
}
|
||||
|
||||
func TestJDComFinanceCsvFileImporterParseImportedData_ParseDescription(t *testing.T) {
|
||||
converter := JDComFinanceTransactionDataCsvFileImporter
|
||||
context := core.NewNullContext()
|
||||
|
||||
user := &models.User{
|
||||
Uid: 1234567890,
|
||||
DefaultCurrency: "CNY",
|
||||
}
|
||||
|
||||
data1 := "导出信息:\n" +
|
||||
"京东账号名:xxxxxx\n" +
|
||||
"日期区间:2025-01-01 至 2025-09-01\n" +
|
||||
"\n" +
|
||||
"交易时间,商户名称,交易说明,金额,收/付款方式,交易状态,收/支\n" +
|
||||
"2025-09-01 01:23:45,xxx,,0.12,银行卡,交易成功,支出\n"
|
||||
allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(data1), 0, nil, nil, nil, nil, nil)
|
||||
assert.Nil(t, err)
|
||||
|
||||
assert.Equal(t, 1, len(allNewTransactions))
|
||||
assert.Equal(t, "", allNewTransactions[0].Comment)
|
||||
|
||||
data2 := "导出信息:\n" +
|
||||
"京东账号名:xxxxxx\n" +
|
||||
"日期区间:2025-01-01 至 2025-09-01\n" +
|
||||
"\n" +
|
||||
"交易时间,商户名称,交易说明,交易说明,金额,收/付款方式,交易状态,收/支,备注\n" +
|
||||
"2025-09-01 01:23:45,xxx,xxx,Test,0.12,银行卡,交易成功,支出,\"foo\"\"bar,\ntest\"\n"
|
||||
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data2), 0, nil, nil, nil, nil, nil)
|
||||
assert.Equal(t, 1, len(allNewTransactions))
|
||||
assert.Equal(t, "foo\"bar,\ntest", allNewTransactions[0].Comment)
|
||||
|
||||
data3 := "导出信息:\n" +
|
||||
"京东账号名:xxxxxx\n" +
|
||||
"日期区间:2025-01-01 至 2025-09-01\n" +
|
||||
"\n" +
|
||||
"交易时间,商户名称,交易说明,交易说明,金额,收/付款方式,交易状态,收/支,备注\n" +
|
||||
"2025-09-01 01:23:45,xxx,xxx,Test,0.12,银行卡,交易成功,支出,\n"
|
||||
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data3), 0, nil, nil, nil, nil, nil)
|
||||
assert.Equal(t, 1, len(allNewTransactions))
|
||||
assert.Equal(t, "Test", allNewTransactions[0].Comment)
|
||||
}
|
||||
|
||||
func TestJDComFinanceCsvFileImporterParseImportedData_SkipUnknownStatusTransaction(t *testing.T) {
|
||||
converter := JDComFinanceTransactionDataCsvFileImporter
|
||||
context := core.NewNullContext()
|
||||
|
||||
user := &models.User{
|
||||
Uid: 1234567890,
|
||||
DefaultCurrency: "CNY",
|
||||
}
|
||||
|
||||
data := "导出信息:\n" +
|
||||
"京东账号名:xxxxxx\n" +
|
||||
"日期区间:2025-01-01 至 2025-09-01\n" +
|
||||
"\n" +
|
||||
"交易时间,商户名称,交易说明,金额,收/付款方式,交易状态,收/支\n" +
|
||||
"2025-09-01 01:23:45,xxx,xxx,0.12,银行卡,xxxx,支出\n"
|
||||
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(data), 0, nil, nil, nil, nil, nil)
|
||||
assert.EqualError(t, err, errs.ErrNotFoundTransactionDataInFile.Message)
|
||||
}
|
||||
|
||||
func TestJDComFinanceCsvFileImporterParseImportedData_SkipUnknownMemoTransferTransaction(t *testing.T) {
|
||||
converter := JDComFinanceTransactionDataCsvFileImporter
|
||||
context := core.NewNullContext()
|
||||
|
||||
user := &models.User{
|
||||
Uid: 1234567890,
|
||||
DefaultCurrency: "CNY",
|
||||
}
|
||||
|
||||
data := "导出信息:\n" +
|
||||
"京东账号名:xxxxxx\n" +
|
||||
"日期区间:2025-01-01 至 2025-09-01\n" +
|
||||
"\n" +
|
||||
"交易时间,商户名称,交易说明,金额,收/付款方式,交易状态,收/支\n" +
|
||||
"2025-09-01 01:23:45,xxx,xxx,0.12,银行卡,交易成功,不计收支\n"
|
||||
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(data), 0, nil, nil, nil, nil, nil)
|
||||
assert.EqualError(t, err, errs.ErrNotFoundTransactionDataInFile.Message)
|
||||
}
|
||||
|
||||
func TestJDComFinanceCsvFileImporterParseImportedData_MissingFileHeader(t *testing.T) {
|
||||
converter := JDComFinanceTransactionDataCsvFileImporter
|
||||
context := core.NewNullContext()
|
||||
|
||||
user := &models.User{
|
||||
Uid: 1,
|
||||
DefaultCurrency: "CNY",
|
||||
}
|
||||
|
||||
data := "交易时间,商户名称,交易说明,金额,收/付款方式,交易状态,收/支\n" +
|
||||
"2025-09-01 01:23:45,xxx,xxx,0.12,银行卡,交易成功,支出\n"
|
||||
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(data), 0, nil, nil, nil, nil, nil)
|
||||
assert.EqualError(t, err, errs.ErrInvalidFileHeader.Message)
|
||||
|
||||
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(""), 0, nil, nil, nil, nil, nil)
|
||||
assert.EqualError(t, err, errs.ErrInvalidFileHeader.Message)
|
||||
}
|
||||
|
||||
func TestJDComFinanceCsvFileImporterParseImportedData_MissingRequiredColumn(t *testing.T) {
|
||||
converter := JDComFinanceTransactionDataCsvFileImporter
|
||||
context := core.NewNullContext()
|
||||
|
||||
user := &models.User{
|
||||
Uid: 1,
|
||||
DefaultCurrency: "CNY",
|
||||
}
|
||||
|
||||
// Missing Time Column
|
||||
data1 := "导出信息:\n" +
|
||||
"京东账号名:xxxxxx\n" +
|
||||
"日期区间:2025-01-01 至 2025-09-01\n" +
|
||||
"\n" +
|
||||
"商户名称,交易说明,金额,收/付款方式,交易状态,收/支\n" +
|
||||
"xxx,xxx,0.12,银行卡,交易成功,支出\n"
|
||||
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(data1), 0, nil, nil, nil, nil, nil)
|
||||
assert.EqualError(t, err, errs.ErrInvalidFileHeader.Message)
|
||||
|
||||
// Missing Merchant Name Column
|
||||
data2 := "导出信息:\n" +
|
||||
"京东账号名:xxxxxx\n" +
|
||||
"日期区间:2025-01-01 至 2025-09-01\n" +
|
||||
"\n" +
|
||||
"交易时间,交易说明,金额,收/付款方式,交易状态,收/支\n" +
|
||||
"2025-09-01 01:23:45,xxx,0.12,银行卡,交易成功,支出\n"
|
||||
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data2), 0, nil, nil, nil, nil, nil)
|
||||
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
|
||||
|
||||
// Missing Transaction Memo Column
|
||||
data3 := "导出信息:\n" +
|
||||
"京东账号名:xxxxxx\n" +
|
||||
"日期区间:2025-01-01 至 2025-09-01\n" +
|
||||
"\n" +
|
||||
"交易时间,商户名称,金额,收/付款方式,交易状态,收/支\n" +
|
||||
"2025-09-01 01:23:45,xxx,0.12,银行卡,交易成功,支出\n"
|
||||
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data3), 0, nil, nil, nil, nil, nil)
|
||||
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
|
||||
|
||||
// Missing Amount Column
|
||||
data4 := "导出信息:\n" +
|
||||
"京东账号名:xxxxxx\n" +
|
||||
"日期区间:2025-01-01 至 2025-09-01\n" +
|
||||
"\n" +
|
||||
"交易时间,商户名称,交易说明,收/付款方式,交易状态,收/支\n" +
|
||||
"2025-09-01 01:23:45,xxx,xxx,银行卡,交易成功,支出\n"
|
||||
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data4), 0, nil, nil, nil, nil, nil)
|
||||
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
|
||||
|
||||
// Missing Related Account Column
|
||||
data5 := "导出信息:\n" +
|
||||
"京东账号名:xxxxxx\n" +
|
||||
"日期区间:2025-01-01 至 2025-09-01\n" +
|
||||
"\n" +
|
||||
"交易时间,商户名称,交易说明,金额,交易状态,收/支\n" +
|
||||
"2025-09-01 01:23:45,xxx,xxx,0.12,交易成功,支出\n"
|
||||
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data5), 0, nil, nil, nil, nil, nil)
|
||||
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
|
||||
|
||||
// Missing Status Column
|
||||
data6 := "导出信息:\n" +
|
||||
"京东账号名:xxxxxx\n" +
|
||||
"日期区间:2025-01-01 至 2025-09-01\n" +
|
||||
"\n" +
|
||||
"交易时间,商户名称,交易说明,金额,收/付款方式,收/支\n" +
|
||||
"2025-09-01 01:23:45,xxx,xxx,0.12,银行卡,支出\n"
|
||||
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data6), 0, nil, nil, nil, nil, nil)
|
||||
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
|
||||
|
||||
// Missing Type Column
|
||||
data7 := "导出信息:\n" +
|
||||
"京东账号名:xxxxxx\n" +
|
||||
"日期区间:2025-01-01 至 2025-09-01\n" +
|
||||
"\n" +
|
||||
"交易时间,商户名称,交易说明,金额,收/付款方式,交易状态\n" +
|
||||
"2025-09-01 01:23:45,xxx,xxx,0.12,银行卡,交易成功\n"
|
||||
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data7), 0, nil, nil, nil, nil, nil)
|
||||
assert.EqualError(t, err, errs.ErrMissingRequiredFieldInHeaderRow.Message)
|
||||
}
|
||||
|
||||
func TestJDComFinanceCsvFileImporterParseImportedData_NoTransactionData(t *testing.T) {
|
||||
converter := JDComFinanceTransactionDataCsvFileImporter
|
||||
context := core.NewNullContext()
|
||||
|
||||
user := &models.User{
|
||||
Uid: 1234567890,
|
||||
DefaultCurrency: "CNY",
|
||||
}
|
||||
|
||||
data := "导出信息:\n" +
|
||||
"京东账号名:xxxxxx\n" +
|
||||
"日期区间:2025-01-01 至 2025-09-01\n" +
|
||||
"\n" +
|
||||
"交易时间,商户名称,交易说明,金额,收/付款方式,交易状态,收/支\n"
|
||||
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(data), 0, nil, nil, nil, nil, nil)
|
||||
assert.EqualError(t, err, errs.ErrNotFoundTransactionDataInFile.Message)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package jdcom
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/csv"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||
)
|
||||
|
||||
func createNewJDComFinanceTransactionBasicDataTable(ctx core.Context, originalDataTable datatable.BasicDataTable) (datatable.BasicDataTable, error) {
|
||||
iterator := originalDataTable.DataRowIterator()
|
||||
allOriginalLines := make([][]string, 0)
|
||||
hasFileHeader := false
|
||||
foundDataHeaderLine := false
|
||||
|
||||
for iterator.HasNext() {
|
||||
row := iterator.Next()
|
||||
|
||||
if !hasFileHeader {
|
||||
if row.ColumnCount() <= 0 {
|
||||
continue
|
||||
} else if strings.Index(row.GetData(0), jdComFinanceTransactionDataCsvFileHeader) == 0 {
|
||||
hasFileHeader = true
|
||||
continue
|
||||
} else {
|
||||
log.Warnf(ctx, "[jdcom_finance_transaction_data_extrator.createNewJDComFinanceTransactionBasicDataTable] read unexpected line in row \"%s\" before read file header", iterator.CurrentRowId())
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if !foundDataHeaderLine {
|
||||
if row.ColumnCount() <= 0 {
|
||||
continue
|
||||
} else if row.GetData(0) == jdComFinanceTransactionTimeColumnName {
|
||||
foundDataHeaderLine = true
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if foundDataHeaderLine {
|
||||
if row.ColumnCount() <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
items := make([]string, row.ColumnCount())
|
||||
|
||||
for i := 0; i < row.ColumnCount(); i++ {
|
||||
items[i] = strings.TrimRight(strings.Trim(row.GetData(i), " "), "\t")
|
||||
}
|
||||
|
||||
if len(allOriginalLines) > 0 && len(items) < len(allOriginalLines[0]) {
|
||||
log.Errorf(ctx, "[jdcom_finance_transaction_data_extrator.createNewJDComFinanceTransactionBasicDataTable] cannot parse row \"%s\", because may missing some columns (column count %d in data row is less than header column count %d)", iterator.CurrentRowId(), len(items), len(allOriginalLines[0]))
|
||||
return nil, errs.ErrFewerFieldsInDataRowThanInHeaderRow
|
||||
}
|
||||
|
||||
allOriginalLines = append(allOriginalLines, items)
|
||||
}
|
||||
}
|
||||
|
||||
if !hasFileHeader || !foundDataHeaderLine {
|
||||
return nil, errs.ErrInvalidFileHeader
|
||||
}
|
||||
|
||||
if len(allOriginalLines) < 2 {
|
||||
log.Errorf(ctx, "[jdcom_finance_transaction_data_extrator.createNewJDComFinanceTransactionBasicDataTable] cannot parse import data, because data table row count is less 1")
|
||||
return nil, errs.ErrNotFoundTransactionDataInFile
|
||||
}
|
||||
|
||||
return csv.CreateNewCustomCsvBasicDataTable(allOriginalLines, true), nil
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package jdcom
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||
)
|
||||
|
||||
const jdComFinanceTransactionDataCsvFileHeader = "导出信息:"
|
||||
|
||||
const jdComFinanceTransactionTimeColumnName = "交易时间"
|
||||
const jdComFinanceTransactionMerchantNameColumnName = "商户名称"
|
||||
const jdComFinanceTransactionMemoColumnName = "交易说明"
|
||||
const jdComFinanceTransactionAmountColumnName = "金额"
|
||||
const jdComFinanceTransactionRelatedAccountColumnName = "收/付款方式"
|
||||
const jdComFinanceTransactionStatusColumnName = "交易状态"
|
||||
const jdComFinanceTransactionTypeColumnName = "收/支"
|
||||
const jdComFinanceTransactionCategoryColumnName = "交易分类"
|
||||
const jdComFinanceTransactionDescriptionColumnName = "备注"
|
||||
|
||||
const jdComFinanceTransactionAmountRefundAll = "(已全额退款)"
|
||||
|
||||
const jdComFinanceTransactionMemoTransferToWalletPrefix = "充值"
|
||||
const jdComFinanceTransactionMemoTransferFromWalletPrefix = "提现"
|
||||
const jdComFinanceTransactionMemoTransferInText = "转入"
|
||||
const jdComFinanceTransactionMemoTransferOutText = "转出"
|
||||
const jdComFinanceTransactionMemoRepaymentText = "还款"
|
||||
const jdComFinanceTransactionMemoRefundText = "退款"
|
||||
|
||||
const jdComFinanceTransactionDataStatusSuccessName = "交易成功"
|
||||
const jdComFinanceTransactionDataStatusRefundSuccessName = "退款成功"
|
||||
|
||||
var jdComFinanceTransactionSupportedColumns = map[datatable.TransactionDataTableColumn]bool{
|
||||
datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME: true,
|
||||
datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: true,
|
||||
datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY: true,
|
||||
datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME: true,
|
||||
datatable.TRANSACTION_DATA_TABLE_AMOUNT: true,
|
||||
datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME: true,
|
||||
datatable.TRANSACTION_DATA_TABLE_DESCRIPTION: true,
|
||||
}
|
||||
|
||||
var jdComFinanceTransactionTypeNameMapping = map[models.TransactionType]string{
|
||||
models.TRANSACTION_TYPE_INCOME: "收入",
|
||||
models.TRANSACTION_TYPE_EXPENSE: "支出",
|
||||
models.TRANSACTION_TYPE_TRANSFER: "不计收支",
|
||||
}
|
||||
|
||||
// jdComFinanceTransactionDataRowParser defines the structure of jd.com finance transaction data row parser
|
||||
type jdComFinanceTransactionDataRowParser struct {
|
||||
existedOriginalDataColumns map[string]bool
|
||||
}
|
||||
|
||||
// Parse returns the converted transaction data row
|
||||
func (p *jdComFinanceTransactionDataRowParser) Parse(ctx core.Context, user *models.User, dataRow datatable.CommonDataTableRow, rowId string) (rowData map[datatable.TransactionDataTableColumn]string, rowDataValid bool, err error) {
|
||||
if dataRow.GetData(jdComFinanceTransactionTypeColumnName) != jdComFinanceTransactionTypeNameMapping[models.TRANSACTION_TYPE_INCOME] &&
|
||||
dataRow.GetData(jdComFinanceTransactionTypeColumnName) != jdComFinanceTransactionTypeNameMapping[models.TRANSACTION_TYPE_EXPENSE] &&
|
||||
dataRow.GetData(jdComFinanceTransactionTypeColumnName) != jdComFinanceTransactionTypeNameMapping[models.TRANSACTION_TYPE_TRANSFER] {
|
||||
log.Warnf(ctx, "[jdcom_finance_transaction_data_row_parser.Parse] skip parsing transaction in row \"%s\", because type is \"%s\"", rowId, dataRow.GetData(jdComFinanceTransactionTypeColumnName))
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
statusName := dataRow.GetData(jdComFinanceTransactionStatusColumnName)
|
||||
|
||||
if statusName != jdComFinanceTransactionDataStatusSuccessName &&
|
||||
statusName != jdComFinanceTransactionDataStatusRefundSuccessName {
|
||||
log.Warnf(ctx, "[jdcom_finance_transaction_data_row_parser.Parse] skip parsing transaction in row \"%s\", because status is \"%s\"", rowId, statusName)
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
data := make(map[datatable.TransactionDataTableColumn]string, len(jdComFinanceTransactionSupportedColumns))
|
||||
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME] = dataRow.GetData(jdComFinanceTransactionTimeColumnName)
|
||||
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = dataRow.GetData(jdComFinanceTransactionTypeColumnName)
|
||||
data[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] = dataRow.GetData(jdComFinanceTransactionCategoryColumnName)
|
||||
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = dataRow.GetData(jdComFinanceTransactionRelatedAccountColumnName)
|
||||
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = ""
|
||||
|
||||
if strings.Index(dataRow.GetData(jdComFinanceTransactionAmountColumnName), "(") >= 0 {
|
||||
// If a transaction includes a refund, the original transaction amount will like "-xx.xx(已全额退款)" or "-xx.xx(已退款yy.yy)", along with another refund transaction
|
||||
data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = strings.Split(dataRow.GetData(jdComFinanceTransactionAmountColumnName), "(")[0]
|
||||
} else {
|
||||
data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = dataRow.GetData(jdComFinanceTransactionAmountColumnName)
|
||||
}
|
||||
|
||||
if p.hasOriginalColumn(jdComFinanceTransactionDescriptionColumnName) && dataRow.GetData(jdComFinanceTransactionDescriptionColumnName) != "" {
|
||||
data[datatable.TRANSACTION_DATA_TABLE_DESCRIPTION] = dataRow.GetData(jdComFinanceTransactionDescriptionColumnName)
|
||||
} else if p.hasOriginalColumn(jdComFinanceTransactionMemoColumnName) && dataRow.GetData(jdComFinanceTransactionMemoColumnName) != "" {
|
||||
data[datatable.TRANSACTION_DATA_TABLE_DESCRIPTION] = dataRow.GetData(jdComFinanceTransactionMemoColumnName)
|
||||
} else {
|
||||
data[datatable.TRANSACTION_DATA_TABLE_DESCRIPTION] = ""
|
||||
}
|
||||
|
||||
if data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] == jdComFinanceTransactionTypeNameMapping[models.TRANSACTION_TYPE_TRANSFER] {
|
||||
memo := dataRow.GetData(jdComFinanceTransactionMemoColumnName)
|
||||
|
||||
if statusName == jdComFinanceTransactionDataStatusRefundSuccessName || strings.Index(memo, jdComFinanceTransactionMemoRefundText) >= 0 { // refund
|
||||
amount, err := utils.ParseAmount(data[datatable.TRANSACTION_DATA_TABLE_AMOUNT])
|
||||
|
||||
if err == nil {
|
||||
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = jdComFinanceTransactionTypeNameMapping[models.TRANSACTION_TYPE_EXPENSE]
|
||||
data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(-amount)
|
||||
}
|
||||
} else if strings.Index(dataRow.GetData(jdComFinanceTransactionAmountColumnName), jdComFinanceTransactionAmountRefundAll) > 0 { // expense transaction (but include a full refund)
|
||||
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = jdComFinanceTransactionTypeNameMapping[models.TRANSACTION_TYPE_EXPENSE]
|
||||
} else { // transfer
|
||||
if strings.Index(memo, jdComFinanceTransactionMemoTransferToWalletPrefix) >= 0 { // transfer to jd.com finance wallet
|
||||
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = dataRow.GetData(jdComFinanceTransactionMerchantNameColumnName)
|
||||
} else if strings.Index(memo, jdComFinanceTransactionMemoTransferFromWalletPrefix) >= 0 { // transfer from jd.com finance wallet
|
||||
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME]
|
||||
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = dataRow.GetData(jdComFinanceTransactionMerchantNameColumnName)
|
||||
} else if strings.Index(memo, jdComFinanceTransactionMemoTransferInText) >= 0 { // transfer in
|
||||
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = dataRow.GetData(jdComFinanceTransactionMerchantNameColumnName)
|
||||
} else if strings.Index(memo, jdComFinanceTransactionMemoTransferOutText) >= 0 { // transfer out
|
||||
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME]
|
||||
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = dataRow.GetData(jdComFinanceTransactionMerchantNameColumnName)
|
||||
} else if strings.Index(memo, jdComFinanceTransactionMemoRepaymentText) >= 0 { // repayment
|
||||
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = dataRow.GetData(jdComFinanceTransactionMerchantNameColumnName)
|
||||
} else {
|
||||
log.Warnf(ctx, "[jdcom_finance_transaction_data_row_parser.Parse] skip parsing transaction in row \"%s\", because memo (\"%s\") of this transfer transaction is unknown", rowId, memo)
|
||||
return nil, false, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return data, true, nil
|
||||
}
|
||||
|
||||
func (p *jdComFinanceTransactionDataRowParser) hasOriginalColumn(columnName string) bool {
|
||||
_, exists := p.existedOriginalDataColumns[columnName]
|
||||
return exists
|
||||
}
|
||||
|
||||
// createJDComFinanceTransactionDataRowParser returns jd.com finance transaction data row parser
|
||||
func createJDComFinanceTransactionDataRowParser(headerColumnNames []string) datatable.CommonTransactionDataRowParser {
|
||||
existedOriginalDataColumns := make(map[string]bool, len(headerColumnNames))
|
||||
|
||||
for i := 0; i < len(headerColumnNames); i++ {
|
||||
existedOriginalDataColumns[headerColumnNames[i]] = true
|
||||
}
|
||||
|
||||
return &jdComFinanceTransactionDataRowParser{
|
||||
existedOriginalDataColumns: existedOriginalDataColumns,
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"io"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
@@ -269,19 +270,27 @@ func readOFX1FileHeader(ctx core.Context, data []byte) (fileHeader *ofxFileHeade
|
||||
|
||||
func readOFX2FileHeader(ctx core.Context, data []byte) (fileHeader *ofxFileHeader, err error) {
|
||||
reader := bytes.NewReader(data)
|
||||
scanner := bufio.NewScanner(reader)
|
||||
bufReader := bufio.NewReader(reader)
|
||||
fileHeader = &ofxFileHeader{}
|
||||
headerLine := ""
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
|
||||
for {
|
||||
line, err := bufReader.ReadString('\n')
|
||||
ofxHeaderStartIndex := strings.Index(line, "<?OFX ")
|
||||
|
||||
if ofxHeaderStartIndex >= 0 {
|
||||
headerLine = ofx2HeaderPattern.FindString(line)
|
||||
break
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
break
|
||||
} else {
|
||||
log.Errorf(ctx, "[ofx_data_reader.readOFX2FileHeader] cannot read ofx 2.x file, because %s", err.Error())
|
||||
return nil, errs.ErrInvalidOFXFile
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if headerLine == "" {
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/fireflyIII"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/gnucash"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/iif"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/jdcom"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/mt"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/ofx"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/qif"
|
||||
@@ -37,6 +38,8 @@ func GetTransactionDataImporter(fileType string) (converter.TransactionDataImpor
|
||||
return _default.DefaultTransactionDataCSVFileConverter, nil
|
||||
} else if fileType == "ezbookkeeping_tsv" {
|
||||
return _default.DefaultTransactionDataTSVFileConverter, nil
|
||||
} else if fileType == "ezbookkeeping_json" {
|
||||
return _default.DefaultTransactionDataJsonFileImporter, nil
|
||||
} else if fileType == "ofx" {
|
||||
return ofx.OFXTransactionDataImporter, nil
|
||||
} else if fileType == "qfx" {
|
||||
@@ -69,8 +72,12 @@ func GetTransactionDataImporter(fileType string) (converter.TransactionDataImpor
|
||||
return alipay.AlipayAppTransactionDataCsvFileImporter, nil
|
||||
} else if fileType == "alipay_web_csv" {
|
||||
return alipay.AlipayWebTransactionDataCsvFileImporter, nil
|
||||
} else if fileType == "wechat_pay_app_xlsx" {
|
||||
return wechat.WeChatPayTransactionDataXlsxFileImporter, nil
|
||||
} else if fileType == "wechat_pay_app_csv" {
|
||||
return wechat.WeChatPayTransactionDataCsvFileImporter, nil
|
||||
} else if fileType == "jdcom_finance_app_csv" {
|
||||
return jdcom.JDComFinanceTransactionDataCsvFileImporter, nil
|
||||
} else {
|
||||
return nil, errs.ErrImportFileTypeNotSupported
|
||||
}
|
||||
|
||||
@@ -2,14 +2,12 @@ package wechat
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/csv"
|
||||
|
||||
"golang.org/x/text/encoding/unicode"
|
||||
"golang.org/x/text/transform"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/converter"
|
||||
csvdatatable "github.com/mayswind/ezbookkeeping/pkg/converters/csv"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/csv"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
@@ -17,22 +15,6 @@ import (
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
)
|
||||
|
||||
var wechatPayTransactionSupportedColumns = map[datatable.TransactionDataTableColumn]bool{
|
||||
datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME: true,
|
||||
datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE: true,
|
||||
datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY: true,
|
||||
datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME: true,
|
||||
datatable.TRANSACTION_DATA_TABLE_AMOUNT: true,
|
||||
datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME: true,
|
||||
datatable.TRANSACTION_DATA_TABLE_DESCRIPTION: true,
|
||||
}
|
||||
|
||||
var wechatPayTransactionTypeNameMapping = map[models.TransactionType]string{
|
||||
models.TRANSACTION_TYPE_INCOME: "收入",
|
||||
models.TRANSACTION_TYPE_EXPENSE: "支出",
|
||||
models.TRANSACTION_TYPE_TRANSFER: "/",
|
||||
}
|
||||
|
||||
// wechatPayTransactionDataCsvFileImporter defines the structure of wechatPay csv importer for transaction data
|
||||
type wechatPayTransactionDataCsvFileImporter struct {
|
||||
fileHeaderLineBeginning string
|
||||
@@ -49,7 +31,13 @@ func (c *wechatPayTransactionDataCsvFileImporter) ParseImportedData(ctx core.Con
|
||||
fallback := unicode.UTF8.NewDecoder()
|
||||
reader := transform.NewReader(bytes.NewReader(data), unicode.BOMOverride(fallback))
|
||||
|
||||
dataTable, err := c.createNewWeChatPayBasicDataTable(ctx, reader)
|
||||
csvDataTable, err := csv.CreateNewCsvBasicDataTable(ctx, reader, false)
|
||||
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, nil, nil, err
|
||||
}
|
||||
|
||||
dataTable, err := createNewWeChatPayTransactionBasicDataTable(ctx, csvDataTable)
|
||||
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, nil, nil, err
|
||||
@@ -72,78 +60,3 @@ func (c *wechatPayTransactionDataCsvFileImporter) ParseImportedData(ctx core.Con
|
||||
|
||||
return dataTableImporter.ParseImportedData(ctx, user, transactionDataTable, defaultTimezoneOffset, accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap)
|
||||
}
|
||||
|
||||
func (c *wechatPayTransactionDataCsvFileImporter) createNewWeChatPayBasicDataTable(ctx core.Context, reader io.Reader) (datatable.BasicDataTable, error) {
|
||||
csvReader := csv.NewReader(reader)
|
||||
csvReader.FieldsPerRecord = -1
|
||||
|
||||
allOriginalLines := make([][]string, 0)
|
||||
hasFileHeader := false
|
||||
foundContentBeforeDataHeaderLine := false
|
||||
|
||||
for {
|
||||
items, err := csvReader.Read()
|
||||
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(ctx, "[wechat_pay_transaction_data_csv_file_importer.createNewWeChatPayBasicDataTable] cannot parse wechat pay csv data, because %s", err.Error())
|
||||
return nil, errs.ErrInvalidCSVFile
|
||||
}
|
||||
|
||||
if !hasFileHeader {
|
||||
if len(items) <= 0 {
|
||||
continue
|
||||
} else if strings.Index(items[0], wechatPayTransactionDataCsvFileHeader) == 0 {
|
||||
hasFileHeader = true
|
||||
continue
|
||||
} else {
|
||||
log.Warnf(ctx, "[wechat_pay_transaction_data_csv_file_importer.createNewWeChatPayBasicDataTable] read unexpected line before read file header, line content is %s", strings.Join(items, ","))
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if !foundContentBeforeDataHeaderLine {
|
||||
if len(items) <= 0 {
|
||||
continue
|
||||
} else if strings.Index(items[0], wechatPayTransactionDataHeaderStartContentBeginning) == 0 {
|
||||
foundContentBeforeDataHeaderLine = true
|
||||
continue
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if foundContentBeforeDataHeaderLine {
|
||||
if len(items) <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
for i := 0; i < len(items); i++ {
|
||||
items[i] = strings.Trim(items[i], " ")
|
||||
}
|
||||
|
||||
if len(allOriginalLines) > 0 && len(items) < len(allOriginalLines[0]) {
|
||||
log.Errorf(ctx, "[wechat_pay_transaction_data_csv_file_importer.createNewWeChatPayBasicDataTable] cannot parse row \"index:%d\", because may missing some columns (column count %d in data row is less than header column count %d)", len(allOriginalLines), len(items), len(allOriginalLines[0]))
|
||||
return nil, errs.ErrFewerFieldsInDataRowThanInHeaderRow
|
||||
}
|
||||
|
||||
allOriginalLines = append(allOriginalLines, items)
|
||||
}
|
||||
}
|
||||
|
||||
if !hasFileHeader || !foundContentBeforeDataHeaderLine {
|
||||
return nil, errs.ErrInvalidFileHeader
|
||||
}
|
||||
|
||||
if len(allOriginalLines) < 2 {
|
||||
log.Errorf(ctx, "[wechat_pay_transaction_data_csv_file_importer.createNewWeChatPayBasicDataTable] cannot parse import data, because data table row count is less 1")
|
||||
return nil, errs.ErrNotFoundTransactionDataInFile
|
||||
}
|
||||
|
||||
dataTable := csvdatatable.CreateNewCustomCsvBasicDataTable(allOriginalLines)
|
||||
|
||||
return dataTable, nil
|
||||
}
|
||||
|
||||
@@ -261,6 +261,23 @@ func TestWeChatPayCsvFileImporterParseImportedData_ParseAccountName(t *testing.T
|
||||
assert.Equal(t, 1, len(allNewTransactions))
|
||||
assert.Equal(t, "Wallet", allNewTransactions[0].OriginalSourceAccountName)
|
||||
assert.Equal(t, "test", allNewTransactions[0].OriginalDestinationAccountName)
|
||||
|
||||
// transfer from wechat wallet
|
||||
data5 := "微信支付账单明细,,,,\n" +
|
||||
"微信昵称:[xxx],,,,\n" +
|
||||
"起始时间:[2024-01-01 00:00:00] 终止时间:[2024-09-01 23:59:59],,,,\n" +
|
||||
",,,,\n" +
|
||||
"----------------------微信支付账单明细列表--------------------,,,,\n" +
|
||||
"交易时间,交易类型,收/支,金额(元),支付方式,当前状态\n" +
|
||||
"2024-09-03 23:59:59,信用卡还款,/,¥0.01,零钱,支付成功\n"
|
||||
assert.Nil(t, err)
|
||||
|
||||
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(data5), 0, nil, nil, nil, nil, nil)
|
||||
assert.Nil(t, err)
|
||||
|
||||
assert.Equal(t, 1, len(allNewTransactions))
|
||||
assert.Equal(t, "零钱", allNewTransactions[0].OriginalSourceAccountName)
|
||||
assert.Equal(t, "", allNewTransactions[0].OriginalDestinationAccountName)
|
||||
}
|
||||
|
||||
func TestWeChatPayCsvFileImporterParseImportedData_ParseDescription(t *testing.T) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user