Compare commits
224 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -0,0 +1,70 @@
|
||||
name: Bug Report
|
||||
description: Report a bug in ezBookkeeping
|
||||
labels: bug
|
||||
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 @@
|
||||
blank_issues_enabled: false
|
||||
@@ -0,0 +1,26 @@
|
||||
name: Feature Request
|
||||
description: Request a feature or enhancement for ezBookkeeping
|
||||
labels: enhancement
|
||||
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.
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
name: Docker Snapshot
|
||||
name: Build for Non-Main Branches
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -6,7 +6,7 @@ on:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
build:
|
||||
build-linux-docker:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
-
|
||||
@@ -0,0 +1,224 @@
|
||||
name: Build Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
|
||||
jobs:
|
||||
build-linux-docker:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
image-tag: ${{ steps.meta.outputs.version }}
|
||||
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 }}
|
||||
|
||||
upload-linux-artifact:
|
||||
needs: build-linux-docker
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- arch: linux/amd64
|
||||
arch_alias: linux-amd64
|
||||
- arch: linux/arm64/v8
|
||||
arch_alias: linux-arm64
|
||||
- arch: linux/arm/v7
|
||||
arch_alias: linux-armv7
|
||||
- arch: linux/arm/v6
|
||||
arch_alias: linux-armv6
|
||||
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: Pull and save packaged files for ${{ matrix.arch }}
|
||||
run: |
|
||||
VERSION=${{ needs.build-linux-docker.outputs.image-tag }}
|
||||
IMAGE=${{ secrets.DOCKER_USERNAME }}/ezbookkeeping:${VERSION}
|
||||
docker pull --platform ${{ matrix.arch }} ${IMAGE}
|
||||
cid=$(docker create "${IMAGE}")
|
||||
docker cp ${cid}:/ezbookkeeping ./ezbookkeeping
|
||||
docker rm ${cid}
|
||||
cd ezbookkeeping
|
||||
tar -czf ../ezbookkeeping-v${VERSION}-${{ matrix.arch_alias }}.tar.gz *
|
||||
cd ..
|
||||
rm -rf ezbookkeeping
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ezbookkeeping-${{ github.ref_name }}-${{ matrix.arch_alias }}
|
||||
path: ezbookkeeping-${{ github.ref_name }}-${{ matrix.arch_alias }}.tar.gz
|
||||
if-no-files-found: error
|
||||
|
||||
build-and-upload-windows-package:
|
||||
needs: upload-linux-artifact
|
||||
runs-on: windows-latest
|
||||
env:
|
||||
GO_VERSION: "1.25.1"
|
||||
MINGW_VERSION: "14.2.0"
|
||||
MINGW_REVISION: "v12-rev2"
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download linux-amd64 packaged files
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: ezbookkeeping-${{ github.ref_name }}-linux-amd64
|
||||
path: artifacts
|
||||
|
||||
- name: Extract frontend files from linux-amd64 package
|
||||
run: |
|
||||
New-Item -ItemType Directory -Path package
|
||||
tar -xzf (Get-ChildItem artifacts\ezbookkeeping-${{ github.ref_name }}-linux-amd64.tar.gz) -C package
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
|
||||
- name: Install MinGW
|
||||
run: |
|
||||
$mingwVersion = "${{ env.MINGW_VERSION }}"
|
||||
$mingwRevision = "${{ env.MINGW_REVISION }}"
|
||||
$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
|
||||
env:
|
||||
RELEASE_BUILD: "1"
|
||||
BUILD_PIPELINE: "1"
|
||||
CHECK_3RD_API: ${{ vars.CHECK_3RD_API }}
|
||||
SKIP_TESTS: ${{ vars.SKIP_TESTS }}
|
||||
run: |
|
||||
.\build.ps1 backend
|
||||
|
||||
- name: Package Windows build
|
||||
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 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 ..\ezbookkeeping-${{ github.ref_name }}-windows-x64.zip *
|
||||
Pop-Location
|
||||
Remove-Item -Recurse -Force ezbookkeeping
|
||||
|
||||
- name: Upload Windows artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ezbookkeeping-${{ github.ref_name }}-windows-x64
|
||||
path: ezbookkeeping-${{ github.ref_name }}-windows-x64.zip
|
||||
if-no-files-found: error
|
||||
|
||||
publish-release:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- upload-linux-artifact
|
||||
- build-and-upload-windows-package
|
||||
steps:
|
||||
- name: Download linux-amd64 packaged files
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: ezbookkeeping-${{ github.ref_name }}-linux-amd64
|
||||
path: ./release-files
|
||||
|
||||
- name: Download linux-arm64 packaged files
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: ezbookkeeping-${{ github.ref_name }}-linux-arm64
|
||||
path: ./release-files
|
||||
|
||||
- name: Download linux-armv6 packaged files
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: ezbookkeeping-${{ github.ref_name }}-linux-armv6
|
||||
path: ./release-files
|
||||
|
||||
- name: Download linux-armv7 packaged files
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: ezbookkeeping-${{ github.ref_name }}-linux-armv7
|
||||
path: ./release-files
|
||||
|
||||
- name: Download windows-x64 packaged files
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: ezbookkeeping-${{ github.ref_name }}-windows-x64
|
||||
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,178 @@
|
||||
name: Build Snapshot
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
build-linux-docker:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
image-tag: ${{ steps.meta.outputs.version }}
|
||||
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'}}.${{ github.run_id }}
|
||||
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 }}
|
||||
|
||||
upload-linux-artifact:
|
||||
needs: build-linux-docker
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- arch: linux/amd64
|
||||
arch_alias: linux-amd64
|
||||
- arch: linux/arm64/v8
|
||||
arch_alias: linux-arm64
|
||||
- arch: linux/arm/v7
|
||||
arch_alias: linux-armv7
|
||||
- arch: linux/arm/v6
|
||||
arch_alias: linux-armv6
|
||||
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: Pull and save packaged files for ${{ matrix.arch }}
|
||||
run: |
|
||||
TAG=${{ needs.build-linux-docker.outputs.image-tag }}
|
||||
IMAGE=${{ secrets.DOCKER_USERNAME }}/ezbookkeeping:${TAG}
|
||||
docker pull --platform ${{ matrix.arch }} ${IMAGE}
|
||||
cid=$(docker create "${IMAGE}")
|
||||
docker cp ${cid}:/ezbookkeeping ./ezbookkeeping
|
||||
docker rm ${cid}
|
||||
cd ezbookkeeping
|
||||
tar -czf ../ezbookkeeping-dev-${{ github.run_id }}-${{ matrix.arch_alias }}.tar.gz *
|
||||
cd ..
|
||||
rm -rf ezbookkeeping
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ezbookkeeping-dev-${{ github.run_id }}-${{ matrix.arch_alias }}
|
||||
path: ezbookkeeping-dev-${{ github.run_id }}-${{ matrix.arch_alias }}.tar.gz
|
||||
if-no-files-found: error
|
||||
|
||||
build-and-upload-windows-package:
|
||||
needs: upload-linux-artifact
|
||||
runs-on: windows-latest
|
||||
env:
|
||||
GO_VERSION: "1.25.1"
|
||||
MINGW_VERSION: "14.2.0"
|
||||
MINGW_REVISION: "v12-rev2"
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download linux-amd64 packaged files
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: ezbookkeeping-dev-${{ github.run_id }}-linux-amd64
|
||||
path: artifacts
|
||||
|
||||
- name: Extract frontend files from linux-amd64 package
|
||||
run: |
|
||||
New-Item -ItemType Directory -Path package
|
||||
tar -xzf (Get-ChildItem artifacts\ezbookkeeping-dev-${{ github.run_id }}-linux-amd64.tar.gz) -C package
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
|
||||
- name: Install MinGW
|
||||
run: |
|
||||
$mingwVersion = "${{ env.MINGW_VERSION }}"
|
||||
$mingwRevision = "${{ env.MINGW_REVISION }}"
|
||||
$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
|
||||
env:
|
||||
BUILD_PIPELINE: "1"
|
||||
CHECK_3RD_API: ${{ vars.CHECK_3RD_API }}
|
||||
SKIP_TESTS: ${{ vars.SKIP_TESTS }}
|
||||
run: |
|
||||
.\build.ps1 backend
|
||||
|
||||
- name: Package Windows build
|
||||
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 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 ..\ezbookkeeping-dev-${{ github.run_id }}-windows-x64.zip *
|
||||
Pop-Location
|
||||
Remove-Item -Recurse -Force ezbookkeeping
|
||||
|
||||
- name: Upload Windows artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ezbookkeeping-dev-${{ github.run_id }}-windows-x64
|
||||
path: ezbookkeeping-dev-${{ github.run_id }}-windows-x64.zip
|
||||
if-no-files-found: error
|
||||
@@ -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 }}
|
||||
+3
-3
@@ -1,5 +1,5 @@
|
||||
# Build backend binary file
|
||||
FROM golang:1.24.4-alpine3.22 AS be-builder
|
||||
FROM golang:1.25.1-alpine3.22 AS be-builder
|
||||
ARG RELEASE_BUILD
|
||||
ARG BUILD_PIPELINE
|
||||
ARG CHECK_3RD_API
|
||||
@@ -15,7 +15,7 @@ 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.7.0-alpine3.22 AS fe-builder
|
||||
ARG RELEASE_BUILD
|
||||
ARG BUILD_PIPELINE
|
||||
ENV RELEASE_BUILD=$RELEASE_BUILD
|
||||
@@ -27,7 +27,7 @@ RUN apk add git
|
||||
RUN ./build.sh frontend
|
||||
|
||||
# Package docker image
|
||||
FROM alpine:3.22.0
|
||||
FROM alpine:3.22.1
|
||||
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
|
||||
@@ -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,16 +130,19 @@ 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) |
|
||||
| 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)
|
||||
|
||||
@@ -112,7 +112,7 @@ 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"
|
||||
for /f %%x in ('git rev-parse --short^=7 HEAD') do set "COMMIT_HASH=%%x"
|
||||
call :set_unixtime BUILD_UNIXTIME
|
||||
call :set_date BUILD_DATE
|
||||
|
||||
@@ -261,7 +261,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,231 @@
|
||||
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 = ""
|
||||
$script:BuildDate = ""
|
||||
|
||||
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
|
||||
$script:BuildUnixTime = [int][double]::Parse((Get-Date -UFormat %s))
|
||||
$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
|
||||
@@ -117,7 +117,7 @@ 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)"
|
||||
COMMIT_HASH="$(git rev-parse --short=7 HEAD)"
|
||||
BUILD_UNIXTIME="$(date '+%s')"
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
@@ -158,5 +168,15 @@ func getConfigWithoutSensitiveData(config *settings.Config) *settings.Config {
|
||||
clonedConfig.SecretKey = "****"
|
||||
clonedConfig.AmapApplicationSecret = "****"
|
||||
|
||||
if clonedConfig.WebDAVConfig != nil {
|
||||
clonedConfig.WebDAVConfig.Password = "****"
|
||||
}
|
||||
|
||||
if clonedConfig.ReceiptImageRecognitionLLMConfig != nil {
|
||||
clonedConfig.ReceiptImageRecognitionLLMConfig.OpenAIAPIKey = "****"
|
||||
clonedConfig.ReceiptImageRecognitionLLMConfig.OpenAICompatibleAPIKey = "****"
|
||||
clonedConfig.ReceiptImageRecognitionLLMConfig.OpenRouterAPIKey = "****"
|
||||
}
|
||||
|
||||
return clonedConfig
|
||||
}
|
||||
|
||||
+37
-1
@@ -268,6 +268,19 @@ var UserData = &cli.Command{
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
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",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "user-session-clear",
|
||||
Usage: "Clear user all sessions",
|
||||
@@ -732,6 +745,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 +946,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",
|
||||
|
||||
+15
-5
@@ -79,7 +79,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)
|
||||
@@ -323,7 +323,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,6 +346,7 @@ 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/amounts.json", bindApi(api.Transactions.TransactionAmountsHandler))
|
||||
@@ -394,6 +397,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))
|
||||
@@ -521,7 +531,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 +544,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 +557,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+89
-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
|
||||
|
||||
@@ -193,8 +269,15 @@ 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 two-factor authorization
|
||||
enable_two_factor = true
|
||||
|
||||
# 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
|
||||
|
||||
[user]
|
||||
# Set to true to allow users to register account by themselves
|
||||
@@ -206,12 +289,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
|
||||
|
||||
|
||||
+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,35 @@
|
||||
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/extrame/xls v0.0.2-0.20200426124601-4a6cf263071b
|
||||
github.com/gin-contrib/cache v1.4.0
|
||||
github.com/gin-contrib/cache v1.4.1
|
||||
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/go-co-op/gocron/v2 v2.16.5
|
||||
github.com/go-playground/validator/v10 v10.27.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.4.1
|
||||
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
|
||||
golang.org/x/crypto v0.41.0
|
||||
golang.org/x/net v0.43.0
|
||||
golang.org/x/text v0.28.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.10
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -36,7 +37,7 @@ 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 v1.13.3 // indirect
|
||||
github.com/bytedance/sonic/loader v0.2.4 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect
|
||||
@@ -57,22 +58,21 @@ require (
|
||||
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.2.11 // 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/richardlehane/mscfb v1.0.4 // indirect
|
||||
github.com/richardlehane/msoleps v1.0.4 // indirect
|
||||
@@ -85,13 +85,13 @@ require (
|
||||
github.com/tiendc/go-deepcopy v1.6.0 // 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.2.14 // 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
|
||||
golang.org/x/arch v0.18.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
golang.org/x/sys v0.35.0 // indirect
|
||||
google.golang.org/protobuf v1.36.6 // indirect
|
||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
|
||||
@@ -4,16 +4,16 @@ 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/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 v1.13.3 h1:MS8gmaH16Gtirygw7jV91pDCN33NyMrPbN7qiYhEsF0=
|
||||
github.com/bytedance/sonic v1.13.3/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=
|
||||
@@ -42,30 +42,30 @@ github.com/extrame/xls v0.0.2-0.20200426124601-4a6cf263071b/go.mod h1:iACcgahst7
|
||||
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/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.3 h1:dAhT722RuEG330ce2agAs75z7yB+NKvX/ZM1r8w0u2U=
|
||||
github.com/gin-contrib/gzip v1.2.3/go.mod h1:ad72i4Bzmaypk8M762gNXa2wkxxjbz0icRNnuLJ9a/c=
|
||||
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/go-co-op/gocron/v2 v2.16.5 h1:j228Jxk7bb9CF8LKR3gS+bK3rcjRUINjlVI+ZMp26Ss=
|
||||
github.com/go-co-op/gocron/v2 v2.16.5/go.mod h1:zAfC/GFQ668qHxOVl/D68Jh5Ce7sDqX6TJnSQyRkRBc=
|
||||
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-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.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
|
||||
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||
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/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 +87,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.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU=
|
||||
github.com/klauspost/cpuid/v2 v2.2.11/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,16 +101,16 @@ 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=
|
||||
@@ -125,8 +125,8 @@ 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=
|
||||
@@ -153,8 +153,8 @@ 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=
|
||||
@@ -164,10 +164,10 @@ 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.2.14 h1:yOQvXCBc3Ij46LRkRoh4Yd5qK6LVOgi0bYOXfb7ifjw=
|
||||
github.com/ugorji/go/codec v1.2.14/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/urfave/cli/v3 v3.4.1 h1:1M9UOCy5bLmGnuu1yn3t3CB4rG79Rtoxuv1sPhnm6qM=
|
||||
github.com/urfave/cli/v3 v3.4.1/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo=
|
||||
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=
|
||||
@@ -178,23 +178,23 @@ github.com/xuri/excelize/v2 v2.9.0/go.mod h1:uqey4QBZ9gdMeWApPLdhm9x+9o2lq4iVmji
|
||||
github.com/xuri/nfp v0.0.1 h1:MDamSGatIvp8uOmDP8FnmjuQpu90NzdJxo7242ANR9Q=
|
||||
github.com/xuri/nfp v0.0.1/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.18.0 h1:WN9poc33zL4AzGxqf8VtpKUnGvMi8O9lhNyBMF/85qc=
|
||||
golang.org/x/arch v0.18.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
||||
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
|
||||
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
|
||||
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI=
|
||||
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.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
|
||||
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
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.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
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=
|
||||
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
|
||||
@@ -215,5 +215,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.10 h1:yR83hTT4mKIPyA/lvWFTzS35xjLwkiYnwdw0Qupeh0o=
|
||||
xorm.io/xorm v1.3.10/go.mod h1:Lo7hmsFF0F0GbDE7ubX5ZKa+eCf0eCuiJAUG3oI5cxQ=
|
||||
|
||||
Generated
+2780
-1811
File diff suppressed because it is too large
Load Diff
+36
-29
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ezbookkeeping",
|
||||
"version": "0.10.0",
|
||||
"version": "1.1.0",
|
||||
"private": true,
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -21,63 +21,70 @@
|
||||
"dependencies": {
|
||||
"@mdi/js": "^7.4.47",
|
||||
"@vuepic/vue-datepicker": "^11.0.2",
|
||||
"axios": "^1.9.0",
|
||||
"axios": "^1.11.0",
|
||||
"cbor-js": "^0.1.0",
|
||||
"clipboard": "^2.0.11",
|
||||
"crypto-js": "^4.2.0",
|
||||
"dom7": "^4.0.6",
|
||||
"echarts": "^5.6.0",
|
||||
"echarts": "^5.5.1",
|
||||
"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": "^3.5.21",
|
||||
"vue-echarts": "^7.0.3",
|
||||
"vue-i18n": "^11.1.5",
|
||||
"vue-i18n": "^11.1.12",
|
||||
"vue-router": "^4.5.1",
|
||||
"vue3-perfect-scrollbar": "^2.0.0",
|
||||
"vuedraggable": "^4.1.0",
|
||||
"vuetify": "^3.8.7"
|
||||
"vuetify": "^3.9.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@jest/globals": "^29.7.0",
|
||||
"@tsconfig/node22": "^22.0.2",
|
||||
"@jest/globals": "^30.1.2",
|
||||
"@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.3.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.0.0",
|
||||
"eslint": "^9.35.0",
|
||||
"eslint-plugin-vue": "^10.4.0",
|
||||
"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.1.3",
|
||||
"postcss-preset-env": "^10.3.1",
|
||||
"sass": "^1.92.1",
|
||||
"ts-jest": "^29.4.1",
|
||||
"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.2",
|
||||
"vite": "^7.1.4",
|
||||
"vite-plugin-checker": "^0.10.3",
|
||||
"vite-plugin-pwa": "^1.0.3",
|
||||
"vite-plugin-vuetify": "^2.1.2",
|
||||
"vue-tsc": "^3.0.6"
|
||||
},
|
||||
"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"
|
||||
]
|
||||
}
|
||||
|
||||
+10
-10
@@ -23,7 +23,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 +53,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 +72,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 +91,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
|
||||
|
||||
+107
-11
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||
)
|
||||
|
||||
const pageCountForClearTransactions = 1000
|
||||
const pageCountForDataExport = 1000
|
||||
|
||||
// DataManagementsApi represents data management api
|
||||
@@ -124,13 +125,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 +140,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 +157,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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -47,6 +47,12 @@ func (a *ServerSettingsApi) ServerSettingsJavascriptHandler(c *core.WebContext)
|
||||
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)
|
||||
}
|
||||
|
||||
+7
-1
@@ -130,7 +130,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))
|
||||
|
||||
+141
-4
@@ -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.GetAllTransactionsWithAccountBalanceByMaxTime(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
|
||||
@@ -439,6 +549,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 +590,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 +828,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 +976,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 {
|
||||
@@ -1310,7 +1447,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 +1525,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
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
+36
-9
@@ -359,6 +359,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 +422,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 +467,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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
+34
-1
@@ -445,6 +445,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 +957,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
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
@@ -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 strings.Index(row.GetData(0), dataHeaderStartContent) >= 0 {
|
||||
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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package wechat
|
||||
|
||||
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 createNewWeChatPayTransactionBasicDataTable(ctx core.Context, originalDataTable datatable.BasicDataTable) (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), wechatPayTransactionDataCsvFileHeader) == 0 {
|
||||
hasFileHeader = true
|
||||
continue
|
||||
} else {
|
||||
log.Warnf(ctx, "[wechat_pay_transaction_data_extrator.createNewWeChatPayTransactionBasicDataTable] read unexpected line in row \"%s\" before read file header", iterator.CurrentRowId())
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if !foundContentBeforeDataHeaderLine {
|
||||
if row.ColumnCount() <= 0 {
|
||||
continue
|
||||
} else if strings.Index(row.GetData(0), wechatPayTransactionDataHeaderStartContentBeginning) == 0 {
|
||||
foundContentBeforeDataHeaderLine = true
|
||||
continue
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if foundContentBeforeDataHeaderLine {
|
||||
if row.ColumnCount() <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
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, "[wechat_pay_transaction_data_extrator.createNewWeChatPayTransactionBasicDataTable] 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, "[wechat_pay_transaction_data_extrator.createNewWeChatPayTransactionBasicDataTable] cannot parse import data, because data table row count is less 1")
|
||||
return nil, errs.ErrNotFoundTransactionDataInFile
|
||||
}
|
||||
|
||||
return csv.CreateNewCustomCsvBasicDataTable(allOriginalLines, true), nil
|
||||
}
|
||||
@@ -29,6 +29,22 @@ const wechatPayTransactionDataCategoryTransferFromWeChatWallet = "零钱提现"
|
||||
|
||||
const wechatPayTransactionDataStatusRefundName = "退款"
|
||||
|
||||
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: "/",
|
||||
}
|
||||
|
||||
// weChatPayTransactionDataRowParser defines the structure of wechat pay transaction data row parser
|
||||
type weChatPayTransactionDataRowParser struct {
|
||||
existedOriginalDataColumns map[string]bool
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package wechat
|
||||
|
||||
import (
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/converter"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/excel"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
)
|
||||
|
||||
// wechatPayTransactionDataXlsxFileImporter defines the structure of wechatPay xlsx importer for transaction data
|
||||
type wechatPayTransactionDataXlsxFileImporter struct {
|
||||
dataHeaderStartContentBeginning string
|
||||
}
|
||||
|
||||
// Initialize a webchat pay transaction data xlsx file importer singleton instance
|
||||
var (
|
||||
WeChatPayTransactionDataXlsxFileImporter = &wechatPayTransactionDataXlsxFileImporter{}
|
||||
)
|
||||
|
||||
// ParseImportedData returns the imported data by parsing the wechat pay transaction csv data
|
||||
func (c *wechatPayTransactionDataXlsxFileImporter) 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) {
|
||||
xlsxDataTable, err := excel.CreateNewExcelOOXMLFileBasicDataTable(data, false)
|
||||
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, nil, nil, err
|
||||
}
|
||||
|
||||
dataTable, err := createNewWeChatPayTransactionBasicDataTable(ctx, xlsxDataTable)
|
||||
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, nil, nil, err
|
||||
}
|
||||
|
||||
commonDataTable := datatable.CreateNewCommonDataTableFromBasicDataTable(dataTable)
|
||||
|
||||
if !commonDataTable.HasColumn(wechatPayTransactionTimeColumnName) ||
|
||||
!commonDataTable.HasColumn(wechatPayTransactionCategoryColumnName) ||
|
||||
!commonDataTable.HasColumn(wechatPayTransactionTypeColumnName) ||
|
||||
!commonDataTable.HasColumn(wechatPayTransactionAmountColumnName) ||
|
||||
!commonDataTable.HasColumn(wechatPayTransactionStatusColumnName) {
|
||||
log.Errorf(ctx, "[wechat_pay_transaction_data_xlsx_file_importer.ParseImportedData] cannot parse wechat pay xlsx data, because missing essential columns in header row")
|
||||
return nil, nil, nil, nil, nil, nil, errs.ErrMissingRequiredFieldInHeaderRow
|
||||
}
|
||||
|
||||
transactionRowParser := createWeChatPayTransactionDataRowParser(dataTable.HeaderColumnNames())
|
||||
transactionDataTable := datatable.CreateNewTransactionDataTableFromCommonDataTable(commonDataTable, wechatPayTransactionSupportedColumns, transactionRowParser)
|
||||
dataTableImporter := converter.CreateNewSimpleImporterWithTypeNameMapping(wechatPayTransactionTypeNameMapping)
|
||||
|
||||
return dataTableImporter.ParseImportedData(ctx, user, transactionDataTable, defaultTimezoneOffset, accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package core
|
||||
|
||||
import "fmt"
|
||||
|
||||
// CalendarDisplayType represents calendar display type
|
||||
type CalendarDisplayType byte
|
||||
|
||||
// Calendar Display Type
|
||||
const (
|
||||
CALENDAR_DISPLAY_TYPE_DEFAULT CalendarDisplayType = 0
|
||||
CALENDAR_DISPLAY_TYPE_GREGORAIN CalendarDisplayType = 1
|
||||
CALENDAR_DISPLAY_TYPE_BUDDHIST CalendarDisplayType = 2
|
||||
CALENDAR_DISPLAY_TYPE_GREGORAIN_WITH_CHINESE CalendarDisplayType = 3
|
||||
CALENDAR_DISPLAY_TYPE_GREGORAIN_WITH_PERSIAN CalendarDisplayType = 4
|
||||
CALENDAR_DISPLAY_TYPE_INVALID CalendarDisplayType = 255
|
||||
)
|
||||
|
||||
// String returns a textual representation of the calendar display type enum
|
||||
func (f CalendarDisplayType) String() string {
|
||||
switch f {
|
||||
case CALENDAR_DISPLAY_TYPE_DEFAULT:
|
||||
return "Default"
|
||||
case CALENDAR_DISPLAY_TYPE_GREGORAIN:
|
||||
return "Gregorian"
|
||||
case CALENDAR_DISPLAY_TYPE_BUDDHIST:
|
||||
return "Buddhist"
|
||||
case CALENDAR_DISPLAY_TYPE_GREGORAIN_WITH_CHINESE:
|
||||
return "Gregorian with Chinese Calendar"
|
||||
case CALENDAR_DISPLAY_TYPE_GREGORAIN_WITH_PERSIAN:
|
||||
return "Gregorian with Persian Calendar"
|
||||
case CALENDAR_DISPLAY_TYPE_INVALID:
|
||||
return "Invalid"
|
||||
default:
|
||||
return fmt.Sprintf("Invalid(%d)", int(f))
|
||||
}
|
||||
}
|
||||
|
||||
// DateDisplayType represents date display type
|
||||
type DateDisplayType byte
|
||||
|
||||
// Date Display Type
|
||||
const (
|
||||
DATE_DISPLAY_TYPE_DEFAULT DateDisplayType = 0
|
||||
DATE_DISPLAY_TYPE_GREGORAIN DateDisplayType = 1
|
||||
DATE_DISPLAY_TYPE_BUDDHIST DateDisplayType = 2
|
||||
DATE_DISPLAY_TYPE_PERSIAN DateDisplayType = 3
|
||||
DATE_DISPLAY_TYPE_INVALID DateDisplayType = 255
|
||||
)
|
||||
|
||||
// String returns a textual representation of the date display type enum
|
||||
func (f DateDisplayType) String() string {
|
||||
switch f {
|
||||
case DATE_DISPLAY_TYPE_DEFAULT:
|
||||
return "Default"
|
||||
case DATE_DISPLAY_TYPE_GREGORAIN:
|
||||
return "Gregorian"
|
||||
case DATE_DISPLAY_TYPE_BUDDHIST:
|
||||
return "Buddhist"
|
||||
case DATE_DISPLAY_TYPE_PERSIAN:
|
||||
return "Persian"
|
||||
case DATE_DISPLAY_TYPE_INVALID:
|
||||
return "Invalid"
|
||||
default:
|
||||
return fmt.Sprintf("Invalid(%d)", int(f))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package core
|
||||
|
||||
// O is a shortcut for map[string]any
|
||||
type O map[string]any
|
||||
@@ -1,7 +1,10 @@
|
||||
package core
|
||||
|
||||
import "context"
|
||||
|
||||
// Context is the base context of ezBookkeeping
|
||||
type Context interface {
|
||||
context.Context
|
||||
GetContextId() string
|
||||
GetClientLocale() string
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package core
|
||||
import (
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
@@ -23,6 +24,11 @@ const RemoteClientPortHeader = "X-Real-Port"
|
||||
// ClientTimezoneOffsetHeaderName represents the header name of client timezone offset
|
||||
const ClientTimezoneOffsetHeaderName = "X-Timezone-Offset"
|
||||
|
||||
const tokenHeaderName = "Authorization"
|
||||
const tokenHeaderValuePrefix = "bearer "
|
||||
const tokenQueryStringParam = "token"
|
||||
const tokenCookieParam = "ebk_auth_token"
|
||||
|
||||
// WebContext represents the request and response context
|
||||
type WebContext struct {
|
||||
*gin.Context
|
||||
@@ -118,6 +124,41 @@ func (c *WebContext) GetCurrentUid() int64 {
|
||||
return claims.Uid
|
||||
}
|
||||
|
||||
// GetTokenStringFromHeader returns the token string from the request header
|
||||
func (c *WebContext) GetTokenStringFromHeader() string {
|
||||
tokenHeader := c.GetHeader(tokenHeaderName)
|
||||
|
||||
if len(tokenHeader) < 7 || !strings.EqualFold(tokenHeader[:7], tokenHeaderValuePrefix) {
|
||||
return ""
|
||||
}
|
||||
|
||||
return tokenHeader[7:]
|
||||
}
|
||||
|
||||
// GetTokenStringFromQueryString returns the token string from the request query string
|
||||
func (c *WebContext) GetTokenStringFromQueryString() string {
|
||||
return c.Query(tokenQueryStringParam)
|
||||
}
|
||||
|
||||
// GetTokenStringFromCookie returns the token string from the request cookie
|
||||
func (c *WebContext) GetTokenStringFromCookie() string {
|
||||
tokenCookie, err := c.Cookie(tokenCookieParam)
|
||||
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return tokenCookie
|
||||
}
|
||||
|
||||
func (c *WebContext) SetTokenStringToCookie(token string, tokenExpiredTime int, path string) {
|
||||
if token != "" {
|
||||
c.SetCookie(tokenCookieParam, token, tokenExpiredTime, path, "", false, true)
|
||||
} else {
|
||||
c.SetCookie(tokenCookieParam, "", -1, path, "", false, true)
|
||||
}
|
||||
}
|
||||
|
||||
// GetClientLocale returns the client locale name
|
||||
func (c *WebContext) GetClientLocale() string {
|
||||
value := c.GetHeader(AcceptLanguageHeaderName)
|
||||
|
||||
+41
-4
@@ -4,6 +4,40 @@ import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// NumeralSystem represents the type of numeral system
|
||||
type NumeralSystem byte
|
||||
|
||||
// Numeral System
|
||||
const (
|
||||
NUMERAL_SYSTEM_DEFAULT NumeralSystem = 0
|
||||
NUMERAL_SYSTEM_WESTERN_ARABIC_NUMERALS NumeralSystem = 1
|
||||
NUMERAL_SYSTEM_EASTERN_ARABIC_NUMERALS NumeralSystem = 2
|
||||
NUMERAL_SYSTEM_PERSIAN_DIGITS NumeralSystem = 3
|
||||
NUMERAL_SYSTEM_BURMESE_NUMERALS NumeralSystem = 4
|
||||
NUMERAL_SYSTEM_DEVANAGARI_NUMERALS NumeralSystem = 5
|
||||
NUMERAL_SYSTEM_INVALID NumeralSystem = 255
|
||||
)
|
||||
|
||||
// String returns a textual representation of the decimal separator enum
|
||||
func (f NumeralSystem) String() string {
|
||||
switch f {
|
||||
case NUMERAL_SYSTEM_DEFAULT:
|
||||
return "Default"
|
||||
case NUMERAL_SYSTEM_WESTERN_ARABIC_NUMERALS:
|
||||
return "Western Arabic Numerals"
|
||||
case NUMERAL_SYSTEM_EASTERN_ARABIC_NUMERALS:
|
||||
return "Eastern Arabic Numerals"
|
||||
case NUMERAL_SYSTEM_PERSIAN_DIGITS:
|
||||
return "Persian Digits"
|
||||
case NUMERAL_SYSTEM_BURMESE_NUMERALS:
|
||||
return "Burmese Numerals"
|
||||
case NUMERAL_SYSTEM_DEVANAGARI_NUMERALS:
|
||||
return "Devanagari Numerals"
|
||||
default:
|
||||
return fmt.Sprintf("Invalid(%d)", int(f))
|
||||
}
|
||||
}
|
||||
|
||||
// DecimalSeparator represents the type of decimal separator
|
||||
type DecimalSeparator byte
|
||||
|
||||
@@ -69,10 +103,11 @@ type DigitGroupingType byte
|
||||
|
||||
// Digit Grouping Type
|
||||
const (
|
||||
DIGIT_GROUPING_TYPE_DEFAULT DigitGroupingType = 0
|
||||
DIGIT_GROUPING_TYPE_NONE DigitGroupingType = 1
|
||||
DIGIT_GROUPING_TYPE_THOUSANDS_SEPARATOR DigitGroupingType = 2
|
||||
DIGIT_GROUPING_TYPE_INVALID DigitGroupingType = 255
|
||||
DIGIT_GROUPING_TYPE_DEFAULT DigitGroupingType = 0
|
||||
DIGIT_GROUPING_TYPE_NONE DigitGroupingType = 1
|
||||
DIGIT_GROUPING_TYPE_THOUSANDS_SEPARATOR DigitGroupingType = 2
|
||||
DIGIT_GROUPING_TYPE_INDIAN_NUMBER_GROUPING DigitGroupingType = 3
|
||||
DIGIT_GROUPING_TYPE_INVALID DigitGroupingType = 255
|
||||
)
|
||||
|
||||
// String returns a textual representation of the digit grouping type enum
|
||||
@@ -84,6 +119,8 @@ func (d DigitGroupingType) String() string {
|
||||
return "None"
|
||||
case DIGIT_GROUPING_TYPE_THOUSANDS_SEPARATOR:
|
||||
return "Thousands Separator"
|
||||
case DIGIT_GROUPING_TYPE_INDIAN_NUMBER_GROUPING:
|
||||
return "Indian Number Grouping"
|
||||
case DIGIT_GROUPING_TYPE_INVALID:
|
||||
return "Invalid"
|
||||
default:
|
||||
|
||||
@@ -76,23 +76,24 @@ type UserFeatureRestrictionType uint64
|
||||
|
||||
// User Feature Restriction Type
|
||||
const (
|
||||
USER_FEATURE_RESTRICTION_TYPE_UPDATE_PASSWORD UserFeatureRestrictionType = 1
|
||||
USER_FEATURE_RESTRICTION_TYPE_UPDATE_EMAIL UserFeatureRestrictionType = 2
|
||||
USER_FEATURE_RESTRICTION_TYPE_UPDATE_PROFILE_BASIC_INFO UserFeatureRestrictionType = 3
|
||||
USER_FEATURE_RESTRICTION_TYPE_UPDATE_AVATAR UserFeatureRestrictionType = 4
|
||||
USER_FEATURE_RESTRICTION_TYPE_REVOKE_OTHER_SESSION UserFeatureRestrictionType = 5
|
||||
USER_FEATURE_RESTRICTION_TYPE_ENABLE_2FA UserFeatureRestrictionType = 6
|
||||
USER_FEATURE_RESTRICTION_TYPE_DISABLE_2FA UserFeatureRestrictionType = 7
|
||||
USER_FEATURE_RESTRICTION_TYPE_FORGET_PASSWORD UserFeatureRestrictionType = 8
|
||||
USER_FEATURE_RESTRICTION_TYPE_IMPORT_TRANSACTION UserFeatureRestrictionType = 9
|
||||
USER_FEATURE_RESTRICTION_TYPE_EXPORT_TRANSACTION UserFeatureRestrictionType = 10
|
||||
USER_FEATURE_RESTRICTION_TYPE_CLEAR_ALL_DATA UserFeatureRestrictionType = 11
|
||||
USER_FEATURE_RESTRICTION_TYPE_SYNC_APPLICATION_SETTINGS UserFeatureRestrictionType = 12
|
||||
USER_FEATURE_RESTRICTION_TYPE_MCP_ACCESS UserFeatureRestrictionType = 13
|
||||
USER_FEATURE_RESTRICTION_TYPE_UPDATE_PASSWORD UserFeatureRestrictionType = 1
|
||||
USER_FEATURE_RESTRICTION_TYPE_UPDATE_EMAIL UserFeatureRestrictionType = 2
|
||||
USER_FEATURE_RESTRICTION_TYPE_UPDATE_PROFILE_BASIC_INFO UserFeatureRestrictionType = 3
|
||||
USER_FEATURE_RESTRICTION_TYPE_UPDATE_AVATAR UserFeatureRestrictionType = 4
|
||||
USER_FEATURE_RESTRICTION_TYPE_REVOKE_OTHER_SESSION UserFeatureRestrictionType = 5
|
||||
USER_FEATURE_RESTRICTION_TYPE_ENABLE_2FA UserFeatureRestrictionType = 6
|
||||
USER_FEATURE_RESTRICTION_TYPE_DISABLE_2FA UserFeatureRestrictionType = 7
|
||||
USER_FEATURE_RESTRICTION_TYPE_FORGET_PASSWORD UserFeatureRestrictionType = 8
|
||||
USER_FEATURE_RESTRICTION_TYPE_IMPORT_TRANSACTION UserFeatureRestrictionType = 9
|
||||
USER_FEATURE_RESTRICTION_TYPE_EXPORT_TRANSACTION UserFeatureRestrictionType = 10
|
||||
USER_FEATURE_RESTRICTION_TYPE_CLEAR_ALL_DATA UserFeatureRestrictionType = 11
|
||||
USER_FEATURE_RESTRICTION_TYPE_SYNC_APPLICATION_SETTINGS UserFeatureRestrictionType = 12
|
||||
USER_FEATURE_RESTRICTION_TYPE_MCP_ACCESS UserFeatureRestrictionType = 13
|
||||
USER_FEATURE_RESTRICTION_TYPE_CREATE_TRANSACTION_FROM_AI_IMAGE_RECOGNITION UserFeatureRestrictionType = 14
|
||||
)
|
||||
|
||||
const userFeatureRestrictionTypeMinValue UserFeatureRestrictionType = USER_FEATURE_RESTRICTION_TYPE_UPDATE_PASSWORD
|
||||
const userFeatureRestrictionTypeMaxValue UserFeatureRestrictionType = USER_FEATURE_RESTRICTION_TYPE_MCP_ACCESS
|
||||
const userFeatureRestrictionTypeMaxValue UserFeatureRestrictionType = USER_FEATURE_RESTRICTION_TYPE_CREATE_TRANSACTION_FROM_AI_IMAGE_RECOGNITION
|
||||
|
||||
// String returns a textual representation of the restriction type of user features
|
||||
func (t UserFeatureRestrictionType) String() string {
|
||||
|
||||
@@ -95,7 +95,7 @@ func TestCronJobSchedulerContainerRepeatRun(t *testing.T) {
|
||||
InMemoryDuplicateCheckerCleanupIntervalDuration: 60 * time.Second,
|
||||
})
|
||||
|
||||
duplicatechecker.Container.Current = checker
|
||||
duplicatechecker.SetDuplicateChecker(checker)
|
||||
|
||||
container := &CronJobSchedulerContainer{
|
||||
allJobsMap: make(map[string]*CronJob),
|
||||
|
||||
@@ -22,7 +22,7 @@ func (j *CronJob) doRun() {
|
||||
start := time.Now()
|
||||
c := core.NewCronJobContext(j.Name, j.Period.GetInterval())
|
||||
|
||||
if duplicatechecker.Container.Current != nil {
|
||||
if duplicatechecker.Container.IsEnabled() {
|
||||
localAddr, err := utils.GetLocalIPAddressesString()
|
||||
|
||||
if err != nil {
|
||||
|
||||
@@ -128,16 +128,16 @@ func getMysqlConnectionString(dbConfig *settings.DatabaseConfig) (string, error)
|
||||
}
|
||||
|
||||
func getPostgresConnectionString(dbConfig *settings.DatabaseConfig) (string, error) {
|
||||
host, port, err := net.SplitHostPort(dbConfig.DatabaseHost)
|
||||
|
||||
if err != nil {
|
||||
return "", errs.ErrDatabaseHostInvalid
|
||||
}
|
||||
|
||||
if strings.HasPrefix(dbConfig.DatabaseHost, "/") { // unix socket path
|
||||
return fmt.Sprintf("postgres://%s:%s@:%s/%s?sslmode=%s&host=%s",
|
||||
url.QueryEscape(dbConfig.DatabaseUser), url.QueryEscape(dbConfig.DatabasePassword), port, dbConfig.DatabaseName, dbConfig.DatabaseSSLMode, host), nil
|
||||
return fmt.Sprintf("postgres:///%s?sslmode=%s&host=%s&user=%s&password=%s",
|
||||
dbConfig.DatabaseName, dbConfig.DatabaseSSLMode, dbConfig.DatabaseHost, url.QueryEscape(dbConfig.DatabaseUser), url.QueryEscape(dbConfig.DatabasePassword)), nil
|
||||
} else {
|
||||
host, port, err := net.SplitHostPort(dbConfig.DatabaseHost)
|
||||
|
||||
if err != nil {
|
||||
return "", errs.ErrDatabaseHostInvalid
|
||||
}
|
||||
|
||||
return fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=%s",
|
||||
url.QueryEscape(dbConfig.DatabaseUser), url.QueryEscape(dbConfig.DatabasePassword), host, port, dbConfig.DatabaseName, dbConfig.DatabaseSSLMode), nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package datastore
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||
)
|
||||
|
||||
func TestGetMysqlConnectionString_TCP(t *testing.T) {
|
||||
expectedValue := "username:password@tcp(1.2.3.4:3306)/dbname?charset=utf8mb4&parseTime=true"
|
||||
actualValue, err := getMysqlConnectionString(&settings.DatabaseConfig{
|
||||
DatabaseType: "mysql",
|
||||
DatabaseHost: "1.2.3.4:3306",
|
||||
DatabaseName: "dbname",
|
||||
DatabaseUser: "username",
|
||||
DatabasePassword: "password",
|
||||
})
|
||||
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, expectedValue, actualValue)
|
||||
}
|
||||
|
||||
func TestGetMysqlConnectionString_UnixSocket(t *testing.T) {
|
||||
expectedValue := "username:password@unix(/path/to/mysql.sock)/dbname?charset=utf8mb4&parseTime=true"
|
||||
actualValue, err := getMysqlConnectionString(&settings.DatabaseConfig{
|
||||
DatabaseType: "mysql",
|
||||
DatabaseHost: "/path/to/mysql.sock",
|
||||
DatabaseName: "dbname",
|
||||
DatabaseUser: "username",
|
||||
DatabasePassword: "password",
|
||||
})
|
||||
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, expectedValue, actualValue)
|
||||
}
|
||||
|
||||
func TestGetPostgreSQLConnectionString_TCP(t *testing.T) {
|
||||
expectedValue := "postgres://username:password@1.2.3.4:5432/dbname?sslmode=disable"
|
||||
actualValue, err := getPostgresConnectionString(&settings.DatabaseConfig{
|
||||
DatabaseType: "postgres",
|
||||
DatabaseHost: "1.2.3.4:5432",
|
||||
DatabaseName: "dbname",
|
||||
DatabaseUser: "username",
|
||||
DatabasePassword: "password",
|
||||
DatabaseSSLMode: "disable",
|
||||
})
|
||||
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, expectedValue, actualValue)
|
||||
}
|
||||
|
||||
func TestGetPostgreSQLConnectionString_UnixSocket(t *testing.T) {
|
||||
expectedValue := "postgres:///dbname?sslmode=disable&host=/path/to/postgres.sock&user=username&password=password"
|
||||
actualValue, err := getPostgresConnectionString(&settings.DatabaseConfig{
|
||||
DatabaseType: "postgres",
|
||||
DatabaseHost: "/path/to/postgres.sock",
|
||||
DatabaseName: "dbname",
|
||||
DatabaseUser: "username",
|
||||
DatabasePassword: "password",
|
||||
DatabaseSSLMode: "disable",
|
||||
})
|
||||
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, expectedValue, actualValue)
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
|
||||
// DuplicateCheckerContainer contains the current duplicate checker
|
||||
type DuplicateCheckerContainer struct {
|
||||
Current DuplicateChecker
|
||||
current DuplicateChecker
|
||||
}
|
||||
|
||||
// Initialize a duplicate checker container singleton instance
|
||||
@@ -21,7 +21,7 @@ var (
|
||||
func InitializeDuplicateChecker(config *settings.Config) error {
|
||||
if config.DuplicateCheckerType == settings.InMemoryDuplicateCheckerType {
|
||||
checker, err := NewInMemoryDuplicateChecker(config)
|
||||
Container.Current = checker
|
||||
Container.current = checker
|
||||
|
||||
return err
|
||||
}
|
||||
@@ -29,37 +29,75 @@ func InitializeDuplicateChecker(config *settings.Config) error {
|
||||
return errs.ErrInvalidDuplicateCheckerType
|
||||
}
|
||||
|
||||
// SetDuplicateChecker sets the current duplicate checker
|
||||
func SetDuplicateChecker(checker DuplicateChecker) {
|
||||
Container.current = checker
|
||||
}
|
||||
|
||||
// IsEnabled returns whether the duplicate checker is enabled
|
||||
func (c *DuplicateCheckerContainer) IsEnabled() bool {
|
||||
return c.current != nil
|
||||
}
|
||||
|
||||
// GetSubmissionRemark returns whether the same submission has been processed and related remark by the current duplicate checker
|
||||
func (c *DuplicateCheckerContainer) GetSubmissionRemark(checkerType DuplicateCheckerType, uid int64, identification string) (bool, string) {
|
||||
return c.Current.GetSubmissionRemark(checkerType, uid, identification)
|
||||
if c.current == nil {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
return c.current.GetSubmissionRemark(checkerType, uid, identification)
|
||||
}
|
||||
|
||||
// SetSubmissionRemark saves the identification and remark by the current duplicate checker
|
||||
func (c *DuplicateCheckerContainer) SetSubmissionRemark(checkerType DuplicateCheckerType, uid int64, identification string, remark string) {
|
||||
c.Current.SetSubmissionRemark(checkerType, uid, identification, remark)
|
||||
if c.current == nil {
|
||||
return
|
||||
}
|
||||
|
||||
c.current.SetSubmissionRemark(checkerType, uid, identification, remark)
|
||||
}
|
||||
|
||||
// RemoveSubmissionRemark removes the identification and remark by the current duplicate checker
|
||||
func (c *DuplicateCheckerContainer) RemoveSubmissionRemark(checkerType DuplicateCheckerType, uid int64, identification string) {
|
||||
c.Current.RemoveSubmissionRemark(checkerType, uid, identification)
|
||||
if c.current == nil {
|
||||
return
|
||||
}
|
||||
|
||||
c.current.RemoveSubmissionRemark(checkerType, uid, identification)
|
||||
}
|
||||
|
||||
// GetOrSetCronJobRunningInfo returns the running info when the cron job is running or saves the running info by the current duplicate checker
|
||||
func (c *DuplicateCheckerContainer) GetOrSetCronJobRunningInfo(jobName string, runningInfo string, runningInterval time.Duration) (bool, string) {
|
||||
return c.Current.GetOrSetCronJobRunningInfo(jobName, runningInfo, runningInterval)
|
||||
if c.current == nil {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
return c.current.GetOrSetCronJobRunningInfo(jobName, runningInfo, runningInterval)
|
||||
}
|
||||
|
||||
// RemoveCronJobRunningInfo removes the running info of the cron job by the current duplicate checker
|
||||
func (c *DuplicateCheckerContainer) RemoveCronJobRunningInfo(jobName string) {
|
||||
c.Current.RemoveCronJobRunningInfo(jobName)
|
||||
if c.current == nil {
|
||||
return
|
||||
}
|
||||
|
||||
c.current.RemoveCronJobRunningInfo(jobName)
|
||||
}
|
||||
|
||||
// GetFailureCount returns the failure count of the specified failure key
|
||||
func (c *DuplicateCheckerContainer) GetFailureCount(failureKey string) uint32 {
|
||||
return c.Current.GetFailureCount(failureKey)
|
||||
if c.current == nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
return c.current.GetFailureCount(failureKey)
|
||||
}
|
||||
|
||||
// IncreaseFailureCount increases the failure count of the specified failure key
|
||||
func (c *DuplicateCheckerContainer) IncreaseFailureCount(failureKey string) uint32 {
|
||||
return c.Current.IncreaseFailureCount(failureKey)
|
||||
if c.current == nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
return c.current.IncreaseFailureCount(failureKey)
|
||||
}
|
||||
|
||||
@@ -30,4 +30,5 @@ var (
|
||||
ErrInvalidAmountExpression = NewNormalError(NormalSubcategoryConverter, 23, http.StatusBadRequest, "invalid amount expression")
|
||||
ErrInvalidXmlFile = NewNormalError(NormalSubcategoryConverter, 24, http.StatusBadRequest, "invalid xml file")
|
||||
ErrInvalidMT940File = NewNormalError(NormalSubcategoryConverter, 25, http.StatusBadRequest, "invalid mt940 file")
|
||||
ErrInvalidJSONFile = NewNormalError(NormalSubcategoryConverter, 26, http.StatusBadRequest, "invalid json file")
|
||||
)
|
||||
|
||||
@@ -40,6 +40,7 @@ const (
|
||||
NormalSubcategoryConverter = 12
|
||||
NormalSubcategoryUserCustomExchangeRate = 13
|
||||
NormalSubcategoryModelContextProtocol = 14
|
||||
NormalSubcategoryLargeLanguageModel = 15
|
||||
)
|
||||
|
||||
// Error represents the specific error returned to user
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package errs
|
||||
|
||||
import "net/http"
|
||||
|
||||
// Error codes related to large language model features
|
||||
var (
|
||||
ErrLargeLanguageModelProviderNotEnabled = NewNormalError(NormalSubcategoryLargeLanguageModel, 0, http.StatusBadRequest, "llm provider is not enabled")
|
||||
ErrNoAIRecognitionImage = NewNormalError(NormalSubcategoryLargeLanguageModel, 1, http.StatusBadRequest, "no image for AI recognition")
|
||||
ErrAIRecognitionImageIsEmpty = NewNormalError(NormalSubcategoryLargeLanguageModel, 2, http.StatusBadRequest, "image for AI recognition is empty")
|
||||
ErrExceedMaxAIRecognitionImageFileSize = NewNormalError(NormalSubcategoryLargeLanguageModel, 3, http.StatusBadRequest, "exceed the maximum size of image file for AI recognition")
|
||||
ErrNoTransactionInformationInImage = NewNormalError(NormalSubcategoryLargeLanguageModel, 4, http.StatusBadRequest, "no transaction information detected")
|
||||
)
|
||||
@@ -24,4 +24,6 @@ var (
|
||||
ErrInvalidPasswordResetTokenExpiredTime = NewSystemError(SystemSubcategorySetting, 17, http.StatusInternalServerError, "invalid password reset token expired time")
|
||||
ErrInvalidExchangeRatesDataSource = NewSystemError(SystemSubcategorySetting, 18, http.StatusInternalServerError, "invalid exchange rates data source")
|
||||
ErrInvalidIpAddressPattern = NewSystemError(SystemSubcategorySetting, 19, http.StatusInternalServerError, "invalid ip address pattern")
|
||||
ErrInvalidLLMProvider = NewSystemError(SystemSubcategorySetting, 20, http.StatusInternalServerError, "invalid llm provider")
|
||||
ErrInvalidLLMModelId = NewSystemError(SystemSubcategorySetting, 21, http.StatusInternalServerError, "invalid llm model id")
|
||||
)
|
||||
|
||||
+16
-17
@@ -2,7 +2,6 @@ package exchangerates
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sort"
|
||||
@@ -25,13 +24,13 @@ type HttpExchangeRatesDataSource interface {
|
||||
Parse(c core.Context, content []byte) (*models.LatestExchangeRateResponse, error)
|
||||
}
|
||||
|
||||
// CommonHttpExchangeRatesDataSource defines the structure of common http exchange rates data source
|
||||
type CommonHttpExchangeRatesDataSource struct {
|
||||
ExchangeRatesDataSource
|
||||
// CommonHttpExchangeRatesDataProvider defines the structure of common http exchange rates data provider
|
||||
type CommonHttpExchangeRatesDataProvider struct {
|
||||
ExchangeRatesDataProvider
|
||||
dataSource HttpExchangeRatesDataSource
|
||||
}
|
||||
|
||||
func (e *CommonHttpExchangeRatesDataSource) GetLatestExchangeRates(c core.Context, uid int64, currentConfig *settings.Config) (*models.LatestExchangeRateResponse, error) {
|
||||
func (e *CommonHttpExchangeRatesDataProvider) GetLatestExchangeRates(c core.Context, uid int64, currentConfig *settings.Config) (*models.LatestExchangeRateResponse, error) {
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
utils.SetProxyUrl(transport, currentConfig.ExchangeRatesProxy)
|
||||
|
||||
@@ -49,7 +48,7 @@ func (e *CommonHttpExchangeRatesDataSource) GetLatestExchangeRates(c core.Contex
|
||||
requests, err := e.dataSource.BuildRequests()
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[http_exchange_rates_datasource.GetLatestExchangeRates] failed to build requests for user \"uid:%d\", because %s", uid, err.Error())
|
||||
log.Errorf(c, "[common_http_exchange_rates_data_provider.GetLatestExchangeRates] failed to build requests for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
@@ -59,7 +58,7 @@ func (e *CommonHttpExchangeRatesDataSource) GetLatestExchangeRates(c core.Contex
|
||||
req := requests[i]
|
||||
|
||||
if len(req.Header.Values("User-Agent")) < 1 {
|
||||
req.Header.Set("User-Agent", fmt.Sprintf("ezBookkeeping/%s", settings.Version))
|
||||
req.Header.Set("User-Agent", settings.GetUserAgent())
|
||||
} else if req.Header.Get("User-Agent") == "" {
|
||||
req.Header.Del("User-Agent")
|
||||
}
|
||||
@@ -67,24 +66,24 @@ func (e *CommonHttpExchangeRatesDataSource) GetLatestExchangeRates(c core.Contex
|
||||
resp, err := client.Do(req)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[http_exchange_rates_datasource.GetLatestExchangeRates] failed to request latest exchange rate data for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
log.Errorf(c, "[http_exchange_rates_datasource.GetLatestExchangeRates] failed to get latest exchange rate data response for user \"uid:%d\", because response code is not 200", uid)
|
||||
log.Errorf(c, "[common_http_exchange_rates_data_provider.GetLatestExchangeRates] failed to request latest exchange rate data for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
|
||||
log.Debugf(c, "[http_exchange_rates_datasource.GetLatestExchangeRates] response#%d is %s", i, body)
|
||||
log.Debugf(c, "[common_http_exchange_rates_data_provider.GetLatestExchangeRates] response#%d is %s", i, body)
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
log.Errorf(c, "[common_http_exchange_rates_data_provider.GetLatestExchangeRates] failed to get latest exchange rate data response for user \"uid:%d\", because response code is not %d", uid, resp.StatusCode)
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
exchangeRateResp, err := e.dataSource.Parse(c, body)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[http_exchange_rates_datasource.GetLatestExchangeRates] failed to parse response for user \"uid:%d\", because %s", uid, err.Error())
|
||||
log.Errorf(c, "[common_http_exchange_rates_data_provider.GetLatestExchangeRates] failed to parse response for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrFailedToRequestRemoteApi)
|
||||
}
|
||||
|
||||
@@ -126,8 +125,8 @@ func (e *CommonHttpExchangeRatesDataSource) GetLatestExchangeRates(c core.Contex
|
||||
return finalExchangeRateResponse, nil
|
||||
}
|
||||
|
||||
func newCommonHttpExchangeRatesDataSource(dataSource HttpExchangeRatesDataSource) *CommonHttpExchangeRatesDataSource {
|
||||
return &CommonHttpExchangeRatesDataSource{
|
||||
func newCommonHttpExchangeRatesDataProvider(dataSource HttpExchangeRatesDataSource) *CommonHttpExchangeRatesDataProvider {
|
||||
return &CommonHttpExchangeRatesDataProvider{
|
||||
dataSource: dataSource,
|
||||
}
|
||||
}
|
||||
+1
-4
@@ -326,15 +326,12 @@ func executeLatestExchangeRateHandler(t *testing.T, dataSourceType string) *mode
|
||||
err := InitializeExchangeRatesDataSource(config)
|
||||
assert.Nil(t, err)
|
||||
|
||||
dataSource := Container.Current
|
||||
assert.NotNil(t, dataSource)
|
||||
|
||||
ginContext, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
context := &core.WebContext{
|
||||
Context: ginContext,
|
||||
}
|
||||
|
||||
exchangeRateResponse, err := dataSource.GetLatestExchangeRates(context, context.GetCurrentUid(), config)
|
||||
exchangeRateResponse, err := Container.GetLatestExchangeRates(context, context.GetCurrentUid(), config)
|
||||
assert.Nil(t, err)
|
||||
assert.NotNil(t, exchangeRateResponse)
|
||||
|
||||
+2
-2
@@ -6,8 +6,8 @@ import (
|
||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||
)
|
||||
|
||||
// ExchangeRatesDataSource defines the structure of exchange rates data source
|
||||
type ExchangeRatesDataSource interface {
|
||||
// ExchangeRatesDataProvider defines the structure of exchange rates data provider
|
||||
type ExchangeRatesDataProvider interface {
|
||||
// GetLatestExchangeRates returns the common response entities
|
||||
GetLatestExchangeRates(c core.Context, uid int64, currentConfig *settings.Config) (*models.LatestExchangeRateResponse, error)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package exchangerates
|
||||
|
||||
import (
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||
)
|
||||
|
||||
// ExchangeRatesDataProviderContainer contains the current exchange rates data provider
|
||||
type ExchangeRatesDataProviderContainer struct {
|
||||
current ExchangeRatesDataProvider
|
||||
}
|
||||
|
||||
// Initialize a exchange rates data provider container singleton instance
|
||||
var (
|
||||
Container = &ExchangeRatesDataProviderContainer{}
|
||||
)
|
||||
|
||||
// InitializeExchangeRatesDataSource initializes the current exchange rates data source according to the config
|
||||
func InitializeExchangeRatesDataSource(config *settings.Config) error {
|
||||
if config.ExchangeRatesDataSource == settings.ReserveBankOfAustraliaDataSource {
|
||||
Container.current = newCommonHttpExchangeRatesDataProvider(&ReserveBankOfAustraliaDataSource{})
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.BankOfCanadaDataSource {
|
||||
Container.current = newCommonHttpExchangeRatesDataProvider(&BankOfCanadaDataSource{})
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.CzechNationalBankDataSource {
|
||||
Container.current = newCommonHttpExchangeRatesDataProvider(&CzechNationalBankDataSource{})
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.DanmarksNationalbankDataSource {
|
||||
Container.current = newCommonHttpExchangeRatesDataProvider(&DanmarksNationalbankDataSource{})
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.EuroCentralBankDataSource {
|
||||
Container.current = newCommonHttpExchangeRatesDataProvider(&EuroCentralBankDataSource{})
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.NationalBankOfGeorgiaDataSource {
|
||||
Container.current = newCommonHttpExchangeRatesDataProvider(&NationalBankOfGeorgiaDataSource{})
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.CentralBankOfHungaryDataSource {
|
||||
Container.current = newCommonHttpExchangeRatesDataProvider(&CentralBankOfHungaryDataSource{})
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.BankOfIsraelDataSource {
|
||||
Container.current = newCommonHttpExchangeRatesDataProvider(&BankOfIsraelDataSource{})
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.CentralBankOfMyanmarDataSource {
|
||||
Container.current = newCommonHttpExchangeRatesDataProvider(&CentralBankOfMyanmarDataSource{})
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.NorgesBankDataSource {
|
||||
Container.current = newCommonHttpExchangeRatesDataProvider(&NorgesBankDataSource{})
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.NationalBankOfPolandDataSource {
|
||||
Container.current = newCommonHttpExchangeRatesDataProvider(&NationalBankOfPolandDataSource{})
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.NationalBankOfRomaniaDataSource {
|
||||
Container.current = newCommonHttpExchangeRatesDataProvider(&NationalBankOfRomaniaDataSource{})
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.BankOfRussiaDataSource {
|
||||
Container.current = newCommonHttpExchangeRatesDataProvider(&BankOfRussiaDataSource{})
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.SwissNationalBankDataSource {
|
||||
Container.current = newCommonHttpExchangeRatesDataProvider(&SwissNationalBankDataSource{})
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.NationalBankOfUkraineDataSource {
|
||||
Container.current = newCommonHttpExchangeRatesDataProvider(&NationalBankOfUkraineDataSource{})
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.CentralBankOfUzbekistanDataSource {
|
||||
Container.current = newCommonHttpExchangeRatesDataProvider(&CentralBankOfUzbekistanDataSource{})
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.InternationalMonetaryFundDataSource {
|
||||
Container.current = newCommonHttpExchangeRatesDataProvider(&InternationalMonetaryFundDataSource{})
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.UserCustomExchangeRatesDataSource {
|
||||
Container.current = newUserCustomExchangeRatesDataProvider()
|
||||
return nil
|
||||
}
|
||||
|
||||
return errs.ErrInvalidExchangeRatesDataSource
|
||||
}
|
||||
|
||||
// GetLatestExchangeRates returns the latest exchange rates data from the current exchange rates data source
|
||||
func (e *ExchangeRatesDataProviderContainer) GetLatestExchangeRates(c core.Context, uid int64, currentConfig *settings.Config) (*models.LatestExchangeRateResponse, error) {
|
||||
if Container.current == nil {
|
||||
return nil, errs.ErrInvalidExchangeRatesDataSource
|
||||
}
|
||||
|
||||
return e.current.GetLatestExchangeRates(c, uid, currentConfig)
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
package exchangerates
|
||||
|
||||
import (
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||
)
|
||||
|
||||
// ExchangeRatesDataSourceContainer contains the current exchange rates data source
|
||||
type ExchangeRatesDataSourceContainer struct {
|
||||
Current ExchangeRatesDataSource
|
||||
}
|
||||
|
||||
// Initialize a exchange rates data source container singleton instance
|
||||
var (
|
||||
Container = &ExchangeRatesDataSourceContainer{}
|
||||
)
|
||||
|
||||
// InitializeExchangeRatesDataSource initializes the current exchange rates data source according to the config
|
||||
func InitializeExchangeRatesDataSource(config *settings.Config) error {
|
||||
if config.ExchangeRatesDataSource == settings.ReserveBankOfAustraliaDataSource {
|
||||
Container.Current = newCommonHttpExchangeRatesDataSource(&ReserveBankOfAustraliaDataSource{})
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.BankOfCanadaDataSource {
|
||||
Container.Current = newCommonHttpExchangeRatesDataSource(&BankOfCanadaDataSource{})
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.CzechNationalBankDataSource {
|
||||
Container.Current = newCommonHttpExchangeRatesDataSource(&CzechNationalBankDataSource{})
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.DanmarksNationalbankDataSource {
|
||||
Container.Current = newCommonHttpExchangeRatesDataSource(&DanmarksNationalbankDataSource{})
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.EuroCentralBankDataSource {
|
||||
Container.Current = newCommonHttpExchangeRatesDataSource(&EuroCentralBankDataSource{})
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.NationalBankOfGeorgiaDataSource {
|
||||
Container.Current = newCommonHttpExchangeRatesDataSource(&NationalBankOfGeorgiaDataSource{})
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.CentralBankOfHungaryDataSource {
|
||||
Container.Current = newCommonHttpExchangeRatesDataSource(&CentralBankOfHungaryDataSource{})
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.BankOfIsraelDataSource {
|
||||
Container.Current = newCommonHttpExchangeRatesDataSource(&BankOfIsraelDataSource{})
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.CentralBankOfMyanmarDataSource {
|
||||
Container.Current = newCommonHttpExchangeRatesDataSource(&CentralBankOfMyanmarDataSource{})
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.NorgesBankDataSource {
|
||||
Container.Current = newCommonHttpExchangeRatesDataSource(&NorgesBankDataSource{})
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.NationalBankOfPolandDataSource {
|
||||
Container.Current = newCommonHttpExchangeRatesDataSource(&NationalBankOfPolandDataSource{})
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.NationalBankOfRomaniaDataSource {
|
||||
Container.Current = newCommonHttpExchangeRatesDataSource(&NationalBankOfRomaniaDataSource{})
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.BankOfRussiaDataSource {
|
||||
Container.Current = newCommonHttpExchangeRatesDataSource(&BankOfRussiaDataSource{})
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.SwissNationalBankDataSource {
|
||||
Container.Current = newCommonHttpExchangeRatesDataSource(&SwissNationalBankDataSource{})
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.NationalBankOfUkraineDataSource {
|
||||
Container.Current = newCommonHttpExchangeRatesDataSource(&NationalBankOfUkraineDataSource{})
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.CentralBankOfUzbekistanDataSource {
|
||||
Container.Current = newCommonHttpExchangeRatesDataSource(&CentralBankOfUzbekistanDataSource{})
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.InternationalMonetaryFundDataSource {
|
||||
Container.Current = newCommonHttpExchangeRatesDataSource(&InternationalMonetaryFundDataSource{})
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.UserCustomExchangeRatesDataSource {
|
||||
Container.Current = newUserCustomExchangeRatesDataSource()
|
||||
return nil
|
||||
}
|
||||
|
||||
return errs.ErrInvalidExchangeRatesDataSource
|
||||
}
|
||||
+8
-8
@@ -15,25 +15,25 @@ import (
|
||||
|
||||
const userDataSourceType = "user_custom"
|
||||
|
||||
// UserCustomExchangeRatesDataSource defines the structure of user custom exchange rates data source
|
||||
type UserCustomExchangeRatesDataSource struct {
|
||||
ExchangeRatesDataSource
|
||||
// UserCustomExchangeRatesDataProvider defines the structure of user custom exchange rates data provider
|
||||
type UserCustomExchangeRatesDataProvider struct {
|
||||
ExchangeRatesDataProvider
|
||||
users *services.UserService
|
||||
userCustomExchangeRates *services.UserCustomExchangeRatesService
|
||||
}
|
||||
|
||||
func (e *UserCustomExchangeRatesDataSource) GetLatestExchangeRates(c core.Context, uid int64, currentConfig *settings.Config) (*models.LatestExchangeRateResponse, error) {
|
||||
func (e *UserCustomExchangeRatesDataProvider) GetLatestExchangeRates(c core.Context, uid int64, currentConfig *settings.Config) (*models.LatestExchangeRateResponse, error) {
|
||||
user, err := e.users.GetUserById(c, uid)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[user_custom_datasource.GetLatestExchangeRates] failed to get user \"uid:%d\", because %s", uid, err.Error())
|
||||
log.Errorf(c, "[user_custom_data_provider.GetLatestExchangeRates] failed to get user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
customExchangeRates, err := e.userCustomExchangeRates.GetAllCustomExchangeRatesByUid(c, uid)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[user_custom_datasource.GetLatestExchangeRates] failed to get user custom exchange rates for user \"uid:%d\", because %s", uid, err.Error())
|
||||
log.Errorf(c, "[user_custom_data_provider.GetLatestExchangeRates] failed to get user custom exchange rates for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
@@ -93,8 +93,8 @@ func (e *UserCustomExchangeRatesDataSource) GetLatestExchangeRates(c core.Contex
|
||||
return finalExchangeRateResponse, nil
|
||||
}
|
||||
|
||||
func newUserCustomExchangeRatesDataSource() *UserCustomExchangeRatesDataSource {
|
||||
return &UserCustomExchangeRatesDataSource{
|
||||
func newUserCustomExchangeRatesDataProvider() *UserCustomExchangeRatesDataProvider {
|
||||
return &UserCustomExchangeRatesDataProvider{
|
||||
users: services.Users,
|
||||
userCustomExchangeRates: services.UserCustomExchangeRates,
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package data
|
||||
|
||||
import "reflect"
|
||||
|
||||
type LargeLanguageModelRequestPromptType byte
|
||||
|
||||
// Large Language Model Request Prompt Type
|
||||
const (
|
||||
LARGE_LANGUAGE_MODEL_REQUEST_PROMPT_TYPE_TEXT LargeLanguageModelRequestPromptType = 0
|
||||
LARGE_LANGUAGE_MODEL_REQUEST_PROMPT_TYPE_IMAGE_URL LargeLanguageModelRequestPromptType = 1
|
||||
)
|
||||
|
||||
type LargeLanguageModelResponseFormat byte
|
||||
|
||||
// Large Language Model Response Format
|
||||
const (
|
||||
LARGE_LANGUAGE_MODEL_RESPONSE_FORMAT_TEXT LargeLanguageModelResponseFormat = 0
|
||||
LARGE_LANGUAGE_MODEL_RESPONSE_FORMAT_JSON LargeLanguageModelResponseFormat = 1
|
||||
)
|
||||
|
||||
// LargeLanguageModelRequest represents a request to a large language model
|
||||
type LargeLanguageModelRequest struct {
|
||||
Stream bool
|
||||
SystemPrompt string
|
||||
UserPrompt []byte
|
||||
UserPromptType LargeLanguageModelRequestPromptType
|
||||
UserPromptContentType string
|
||||
ResponseJsonObjectType reflect.Type
|
||||
}
|
||||
|
||||
// LargeLanguageModelTextualResponse represents a textual response from a large language model
|
||||
type LargeLanguageModelTextualResponse struct {
|
||||
Content string
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/llm/data"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/llm/provider"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/llm/provider/googleai"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/llm/provider/ollama"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/llm/provider/openai"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||
)
|
||||
|
||||
// LargeLanguageModelProviderContainer contains the current large language model provider
|
||||
type LargeLanguageModelProviderContainer struct {
|
||||
receiptImageRecognitionCurrentProvider provider.LargeLanguageModelProvider
|
||||
}
|
||||
|
||||
// Initialize a large language model provider container singleton instance
|
||||
var (
|
||||
Container = &LargeLanguageModelProviderContainer{}
|
||||
)
|
||||
|
||||
// InitializeLargeLanguageModelProvider initializes the current large language model provider according to the config
|
||||
func InitializeLargeLanguageModelProvider(config *settings.Config) error {
|
||||
var err error = nil
|
||||
|
||||
if config.ReceiptImageRecognitionLLMConfig != nil {
|
||||
Container.receiptImageRecognitionCurrentProvider, err = initializeLargeLanguageModelProvider(config.ReceiptImageRecognitionLLMConfig)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func initializeLargeLanguageModelProvider(llmConfig *settings.LLMConfig) (provider.LargeLanguageModelProvider, error) {
|
||||
if llmConfig.LLMProvider == settings.OpenAILLMProvider {
|
||||
return openai.NewOpenAILargeLanguageModelProvider(llmConfig), nil
|
||||
} else if llmConfig.LLMProvider == settings.OpenAICompatibleLLMProvider {
|
||||
return openai.NewOpenAICompatibleLargeLanguageModelProvider(llmConfig), nil
|
||||
} else if llmConfig.LLMProvider == settings.OpenRouterLLMProvider {
|
||||
return openai.NewOpenRouterLargeLanguageModelProvider(llmConfig), nil
|
||||
} else if llmConfig.LLMProvider == settings.OllamaLLMProvider {
|
||||
return ollama.NewOllamaLargeLanguageModelProvider(llmConfig), nil
|
||||
} else if llmConfig.LLMProvider == settings.GoogleAILLMProvider {
|
||||
return googleai.NewGoogleAILargeLanguageModelProvider(llmConfig), nil
|
||||
} else if llmConfig.LLMProvider == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return nil, errs.ErrInvalidLLMProvider
|
||||
}
|
||||
|
||||
// GetJsonResponseByReceiptImageRecognitionModel returns the json response from the current large language model provider by receipt image recognition model
|
||||
func (l *LargeLanguageModelProviderContainer) GetJsonResponseByReceiptImageRecognitionModel(c core.Context, uid int64, currentConfig *settings.Config, request *data.LargeLanguageModelRequest) (*data.LargeLanguageModelTextualResponse, error) {
|
||||
if currentConfig.ReceiptImageRecognitionLLMConfig == nil || Container.receiptImageRecognitionCurrentProvider == nil {
|
||||
return nil, errs.ErrInvalidLLMProvider
|
||||
}
|
||||
|
||||
return l.receiptImageRecognitionCurrentProvider.GetJsonResponse(c, uid, currentConfig.ReceiptImageRecognitionLLMConfig, request)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/llm/data"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/llm/provider"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||
)
|
||||
|
||||
// HttpLargeLanguageModelAdapter defines the structure of http large language model adapter
|
||||
type HttpLargeLanguageModelAdapter interface {
|
||||
// BuildTextualRequest returns the http request by the provider api definition
|
||||
BuildTextualRequest(c core.Context, uid int64, request *data.LargeLanguageModelRequest, responseType data.LargeLanguageModelResponseFormat) (*http.Request, error)
|
||||
|
||||
// ParseTextualResponse returns the textual response entity by the provider api definition
|
||||
ParseTextualResponse(c core.Context, uid int64, body []byte, responseType data.LargeLanguageModelResponseFormat) (*data.LargeLanguageModelTextualResponse, error)
|
||||
}
|
||||
|
||||
// CommonHttpLargeLanguageModelProvider defines the structure of common http large language model provider
|
||||
type CommonHttpLargeLanguageModelProvider struct {
|
||||
provider.LargeLanguageModelProvider
|
||||
adapter HttpLargeLanguageModelAdapter
|
||||
}
|
||||
|
||||
// GetJsonResponse returns the json response from common http large language model provider
|
||||
func (p *CommonHttpLargeLanguageModelProvider) GetJsonResponse(c core.Context, uid int64, currentLLMConfig *settings.LLMConfig, request *data.LargeLanguageModelRequest) (*data.LargeLanguageModelTextualResponse, error) {
|
||||
response, err := p.getTextualResponse(c, uid, currentLLMConfig, request, data.LARGE_LANGUAGE_MODEL_RESPONSE_FORMAT_JSON)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if strings.HasPrefix(response.Content, "```json") && strings.HasSuffix(response.Content, "```") {
|
||||
response.Content = strings.TrimPrefix(response.Content, "```json")
|
||||
response.Content = strings.TrimSuffix(response.Content, "```")
|
||||
} else if strings.HasPrefix(response.Content, "```") && strings.HasSuffix(response.Content, "```") {
|
||||
response.Content = strings.TrimPrefix(response.Content, "```")
|
||||
response.Content = strings.TrimSuffix(response.Content, "```")
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (p *CommonHttpLargeLanguageModelProvider) getTextualResponse(c core.Context, uid int64, currentLLMConfig *settings.LLMConfig, request *data.LargeLanguageModelRequest, responseType data.LargeLanguageModelResponseFormat) (*data.LargeLanguageModelTextualResponse, error) {
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
utils.SetProxyUrl(transport, currentLLMConfig.LargeLanguageModelAPIProxy)
|
||||
|
||||
if currentLLMConfig.LargeLanguageModelAPISkipTLSVerify {
|
||||
transport.TLSClientConfig = &tls.Config{
|
||||
InsecureSkipVerify: true,
|
||||
}
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Transport: transport,
|
||||
Timeout: time.Duration(currentLLMConfig.LargeLanguageModelAPIRequestTimeout) * time.Millisecond,
|
||||
}
|
||||
|
||||
httpRequest, err := p.adapter.BuildTextualRequest(c, uid, request, responseType)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[common_http_large_language_model_provider.getTextualResponse] failed to build requests for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
httpRequest.Header.Set("User-Agent", settings.GetUserAgent())
|
||||
|
||||
resp, err := client.Do(httpRequest)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[common_http_large_language_model_provider.getTextualResponse] failed to request large language model api for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
|
||||
log.Debugf(c, "[common_http_large_language_model_provider.getTextualResponse] response is %s", body)
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
log.Errorf(c, "[common_http_large_language_model_provider.getTextualResponse] failed to get large language model api response for user \"uid:%d\", because response code is %d", uid, resp.StatusCode)
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
return p.adapter.ParseTextualResponse(c, uid, body, responseType)
|
||||
}
|
||||
|
||||
// NewCommonHttpLargeLanguageModelProvider creates a http adapter based large language model provider instance
|
||||
func NewCommonHttpLargeLanguageModelProvider(adapter HttpLargeLanguageModelAdapter) *CommonHttpLargeLanguageModelProvider {
|
||||
return &CommonHttpLargeLanguageModelProvider{
|
||||
adapter: adapter,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package googleai
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/llm/data"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/llm/provider"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/llm/provider/common"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||
)
|
||||
|
||||
const googleAIGenerateContentAPIFormat = "https://generativelanguage.googleapis.com/v1beta/models/%s:generateContent"
|
||||
|
||||
// GoogleAILargeLanguageModelAdapter defines the structure of Google AI large language model adapter
|
||||
type GoogleAILargeLanguageModelAdapter struct {
|
||||
common.HttpLargeLanguageModelAdapter
|
||||
GoogleAIAPIKey string
|
||||
GoogleAIModelID string
|
||||
}
|
||||
|
||||
// GoogleAIGenerateContentRequest defines the structure of Google AI generate content request
|
||||
type GoogleAIGenerateContentRequest struct {
|
||||
Contents []*GoogleAIGenerateContentRequestContent `json:"contents"`
|
||||
}
|
||||
|
||||
// GoogleAIGenerateContentRequestContent defines the structure of Google AI generate content request content
|
||||
type GoogleAIGenerateContentRequestContent struct {
|
||||
Parts []*GoogleAIGenerateContentRequestContentPart `json:"parts"`
|
||||
}
|
||||
|
||||
// GoogleAIGenerateContentRequestContentPart defines the structure of Google AI generate content request content part
|
||||
type GoogleAIGenerateContentRequestContentPart struct {
|
||||
Text string `json:"text,omitempty"`
|
||||
InlineData *GoogleAIGenerateContentRequestInlineData `json:"inlineData,omitempty"`
|
||||
}
|
||||
|
||||
// GoogleAIGenerateContentRequestInlineData defines the structure of Google AI generate content request inline data
|
||||
type GoogleAIGenerateContentRequestInlineData struct {
|
||||
MimeType string `json:"mimeType"`
|
||||
Data string `json:"data"`
|
||||
}
|
||||
|
||||
// GoogleAIGenerateContentResponse defines the structure of Google AI generate content response
|
||||
type GoogleAIGenerateContentResponse struct {
|
||||
Candidates []*GoogleAIGenerateContentResponseCandidate `json:"candidates"`
|
||||
}
|
||||
|
||||
// GoogleAIGenerateContentResponseCandidate defines the structure of Google AI generate content response candidate
|
||||
type GoogleAIGenerateContentResponseCandidate struct {
|
||||
Content *GoogleAIGenerateContentResponseContent `json:"content"`
|
||||
}
|
||||
|
||||
// GoogleAIGenerateContentResponseContent defines the structure of Google AI generate content response content
|
||||
type GoogleAIGenerateContentResponseContent struct {
|
||||
Part []*GoogleAIGenerateContentResponseContentPart `json:"parts"`
|
||||
}
|
||||
|
||||
// GoogleAIGenerateContentResponseContentPart defines the structure of Google AI generate content response content part
|
||||
type GoogleAIGenerateContentResponseContentPart struct {
|
||||
Text *string `json:"text"`
|
||||
}
|
||||
|
||||
// BuildTextualRequest returns the http request by Google AI large language model adapter
|
||||
func (p *GoogleAILargeLanguageModelAdapter) BuildTextualRequest(c core.Context, uid int64, request *data.LargeLanguageModelRequest, responseType data.LargeLanguageModelResponseFormat) (*http.Request, error) {
|
||||
requestBody, err := p.buildJsonRequestBody(c, uid, request, responseType)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
requestUrl := fmt.Sprintf(googleAIGenerateContentAPIFormat, p.GoogleAIModelID)
|
||||
httpRequest, err := http.NewRequest("POST", requestUrl, bytes.NewReader(requestBody))
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
httpRequest.Header.Set("Content-Type", "application/json")
|
||||
httpRequest.Header.Set("X-goog-api-key", p.GoogleAIAPIKey)
|
||||
|
||||
return httpRequest, nil
|
||||
}
|
||||
|
||||
// ParseTextualResponse returns the textual response by Google AI large language model adapter
|
||||
func (p *GoogleAILargeLanguageModelAdapter) ParseTextualResponse(c core.Context, uid int64, body []byte, responseType data.LargeLanguageModelResponseFormat) (*data.LargeLanguageModelTextualResponse, error) {
|
||||
generateContentResponse := &GoogleAIGenerateContentResponse{}
|
||||
err := json.Unmarshal(body, &generateContentResponse)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[google_ai_large_language_model_adapter.ParseTextualResponse] failed to parse generate content response for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
if generateContentResponse == nil || generateContentResponse.Candidates == nil || len(generateContentResponse.Candidates) < 1 ||
|
||||
generateContentResponse.Candidates[0].Content == nil || len(generateContentResponse.Candidates[0].Content.Part) < 1 ||
|
||||
generateContentResponse.Candidates[0].Content.Part[0].Text == nil {
|
||||
log.Errorf(c, "[google_ai_large_language_model_adapter.ParseTextualResponse] generate content response is invalid for user \"uid:%d\"", uid)
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
textualResponse := &data.LargeLanguageModelTextualResponse{
|
||||
Content: *generateContentResponse.Candidates[0].Content.Part[0].Text,
|
||||
}
|
||||
|
||||
return textualResponse, nil
|
||||
}
|
||||
|
||||
func (p *GoogleAILargeLanguageModelAdapter) buildJsonRequestBody(c core.Context, uid int64, request *data.LargeLanguageModelRequest, responseType data.LargeLanguageModelResponseFormat) ([]byte, error) {
|
||||
if p.GoogleAIModelID == "" {
|
||||
return nil, errs.ErrInvalidLLMModelId
|
||||
}
|
||||
|
||||
generateContentRequest := &GoogleAIGenerateContentRequest{
|
||||
Contents: []*GoogleAIGenerateContentRequestContent{
|
||||
{
|
||||
Parts: make([]*GoogleAIGenerateContentRequestContentPart, 0, 2),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if request.SystemPrompt != "" {
|
||||
generateContentRequest.Contents[0].Parts = append(generateContentRequest.Contents[0].Parts, &GoogleAIGenerateContentRequestContentPart{
|
||||
Text: request.SystemPrompt,
|
||||
})
|
||||
}
|
||||
|
||||
if len(request.UserPrompt) > 0 {
|
||||
if request.UserPromptType == data.LARGE_LANGUAGE_MODEL_REQUEST_PROMPT_TYPE_IMAGE_URL {
|
||||
imageBase64Data := base64.StdEncoding.EncodeToString(request.UserPrompt)
|
||||
generateContentRequest.Contents[0].Parts = append(generateContentRequest.Contents[0].Parts, &GoogleAIGenerateContentRequestContentPart{
|
||||
InlineData: &GoogleAIGenerateContentRequestInlineData{
|
||||
MimeType: request.UserPromptContentType,
|
||||
Data: imageBase64Data,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
generateContentRequest.Contents[0].Parts = append(generateContentRequest.Contents[0].Parts, &GoogleAIGenerateContentRequestContentPart{
|
||||
Text: string(request.UserPrompt),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
requestBodyBytes, err := json.Marshal(generateContentRequest)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[google_ai_large_language_model_adapter.buildJsonRequestBody] failed to marshal request body for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.ErrOperationFailed
|
||||
}
|
||||
|
||||
log.Debugf(c, "[google_ai_large_language_model_adapter.buildJsonRequestBody] request body is %s", requestBodyBytes)
|
||||
return requestBodyBytes, nil
|
||||
}
|
||||
|
||||
// NewGoogleAILargeLanguageModelProvider creates a new Google AI large language model provider instance
|
||||
func NewGoogleAILargeLanguageModelProvider(llmConfig *settings.LLMConfig) provider.LargeLanguageModelProvider {
|
||||
return common.NewCommonHttpLargeLanguageModelProvider(&GoogleAILargeLanguageModelAdapter{
|
||||
GoogleAIAPIKey: llmConfig.GoogleAIAPIKey,
|
||||
GoogleAIModelID: llmConfig.GoogleAIModelID,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package googleai
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/llm/data"
|
||||
)
|
||||
|
||||
func TestGoogleAILargeLanguageModelAdapter_buildJsonRequestBody_TextualUserPrompt(t *testing.T) {
|
||||
adapter := &GoogleAILargeLanguageModelAdapter{
|
||||
GoogleAIModelID: "test",
|
||||
}
|
||||
|
||||
request := &data.LargeLanguageModelRequest{
|
||||
SystemPrompt: "You are a helpful assistant.",
|
||||
UserPrompt: []byte("Hello, how are you?"),
|
||||
}
|
||||
|
||||
bodyBytes, err := adapter.buildJsonRequestBody(core.NewNullContext(), 0, request, data.LARGE_LANGUAGE_MODEL_RESPONSE_FORMAT_JSON)
|
||||
assert.Nil(t, err)
|
||||
|
||||
var body map[string]interface{}
|
||||
err = json.Unmarshal(bodyBytes, &body)
|
||||
assert.Nil(t, err)
|
||||
|
||||
assert.Equal(t, "{\"contents\":[{\"parts\":[{\"text\":\"You are a helpful assistant.\"},{\"text\":\"Hello, how are you?\"}]}]}", string(bodyBytes))
|
||||
}
|
||||
|
||||
func TestGoogleAILargeLanguageModelAdapter_buildJsonRequestBody_ImageUserPrompt(t *testing.T) {
|
||||
adapter := &GoogleAILargeLanguageModelAdapter{
|
||||
GoogleAIModelID: "test",
|
||||
}
|
||||
|
||||
request := &data.LargeLanguageModelRequest{
|
||||
SystemPrompt: "What's in this image?",
|
||||
UserPrompt: []byte("fakedata"),
|
||||
UserPromptType: data.LARGE_LANGUAGE_MODEL_REQUEST_PROMPT_TYPE_IMAGE_URL,
|
||||
UserPromptContentType: "image/png",
|
||||
}
|
||||
|
||||
bodyBytes, err := adapter.buildJsonRequestBody(core.NewNullContext(), 0, request, data.LARGE_LANGUAGE_MODEL_RESPONSE_FORMAT_JSON)
|
||||
assert.Nil(t, err)
|
||||
|
||||
var body map[string]interface{}
|
||||
err = json.Unmarshal(bodyBytes, &body)
|
||||
assert.Nil(t, err)
|
||||
|
||||
assert.Equal(t, "{\"contents\":[{\"parts\":[{\"text\":\"What's in this image?\"},{\"inlineData\":{\"mimeType\":\"image/png\",\"data\":\"ZmFrZWRhdGE=\"}}]}]}", string(bodyBytes))
|
||||
}
|
||||
|
||||
func TestGoogleAILargeLanguageModelAdapter_ParseTextualResponse_ValidJsonResponse(t *testing.T) {
|
||||
adapter := &GoogleAILargeLanguageModelAdapter{
|
||||
GoogleAIModelID: "test",
|
||||
}
|
||||
|
||||
response := `{
|
||||
"responseId": "test-123",
|
||||
"modelVersion": "test",
|
||||
"usageMetadata": {
|
||||
"promptTokenCount": 13,
|
||||
"candidatesTokenCount": 7,
|
||||
"totalTokenCount": 20
|
||||
},
|
||||
"candidates": [
|
||||
{
|
||||
"content": {
|
||||
"parts": [
|
||||
{
|
||||
"text": "This is a test response"
|
||||
}
|
||||
]
|
||||
},
|
||||
"finish_reason": "stop",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
result, err := adapter.ParseTextualResponse(core.NewNullContext(), 0, []byte(response), data.LARGE_LANGUAGE_MODEL_RESPONSE_FORMAT_JSON)
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, "This is a test response", result.Content)
|
||||
}
|
||||
|
||||
func TestGoogleAILargeLanguageModelAdapter_ParseTextualResponse_EmptyResponse(t *testing.T) {
|
||||
adapter := &GoogleAILargeLanguageModelAdapter{
|
||||
GoogleAIModelID: "test",
|
||||
}
|
||||
|
||||
response := `{
|
||||
"responseId": "test-123",
|
||||
"modelVersion": "test",
|
||||
"usageMetadata": {
|
||||
"promptTokenCount": 13,
|
||||
"candidatesTokenCount": 7,
|
||||
"totalTokenCount": 20
|
||||
},
|
||||
"candidates": [
|
||||
{
|
||||
"content": {
|
||||
"parts": [
|
||||
{
|
||||
"text": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"finish_reason": "stop",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
result, err := adapter.ParseTextualResponse(core.NewNullContext(), 0, []byte(response), data.LARGE_LANGUAGE_MODEL_RESPONSE_FORMAT_JSON)
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, "", result.Content)
|
||||
}
|
||||
|
||||
func TestGoogleAILargeLanguageModelAdapter_ParseTextualResponse_EmptyCandidates(t *testing.T) {
|
||||
adapter := &GoogleAILargeLanguageModelAdapter{
|
||||
GoogleAIModelID: "test",
|
||||
}
|
||||
|
||||
response := `{
|
||||
"responseId": "test-123",
|
||||
"modelVersion": "test",
|
||||
"usageMetadata": {
|
||||
"promptTokenCount": 13,
|
||||
"candidatesTokenCount": 7,
|
||||
"totalTokenCount": 20
|
||||
},
|
||||
"candidates": []
|
||||
}`
|
||||
|
||||
_, err := adapter.ParseTextualResponse(core.NewNullContext(), 0, []byte(response), data.LARGE_LANGUAGE_MODEL_RESPONSE_FORMAT_JSON)
|
||||
assert.EqualError(t, err, "failed to request third party api")
|
||||
}
|
||||
|
||||
func TestGoogleAILargeLanguageModelAdapter_ParseTextualResponse_NoPartText(t *testing.T) {
|
||||
adapter := &GoogleAILargeLanguageModelAdapter{
|
||||
GoogleAIModelID: "test",
|
||||
}
|
||||
|
||||
response := `{
|
||||
"responseId": "test-123",
|
||||
"modelVersion": "test",
|
||||
"usageMetadata": {
|
||||
"promptTokenCount": 13,
|
||||
"candidatesTokenCount": 7,
|
||||
"totalTokenCount": 20
|
||||
},
|
||||
"candidates": [
|
||||
{
|
||||
"content": {
|
||||
"parts": [
|
||||
{
|
||||
}
|
||||
]
|
||||
},
|
||||
"finish_reason": "stop",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
_, err := adapter.ParseTextualResponse(core.NewNullContext(), 0, []byte(response), data.LARGE_LANGUAGE_MODEL_RESPONSE_FORMAT_JSON)
|
||||
assert.EqualError(t, err, "failed to request third party api")
|
||||
}
|
||||
|
||||
func TestGoogleAILargeLanguageModelAdapter_ParseTextualResponse_InvalidJson(t *testing.T) {
|
||||
adapter := &GoogleAILargeLanguageModelAdapter{
|
||||
GoogleAIModelID: "test",
|
||||
}
|
||||
|
||||
response := "error"
|
||||
|
||||
_, err := adapter.ParseTextualResponse(core.NewNullContext(), 0, []byte(response), data.LARGE_LANGUAGE_MODEL_RESPONSE_FORMAT_JSON)
|
||||
assert.EqualError(t, err, "failed to request third party api")
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/llm/data"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||
)
|
||||
|
||||
// LargeLanguageModelProvider defines the structure of large language model provider
|
||||
type LargeLanguageModelProvider interface {
|
||||
// GetJsonResponse returns the json response from the large language model provider
|
||||
GetJsonResponse(c core.Context, uid int64, currentLLMConfig *settings.LLMConfig, request *data.LargeLanguageModelRequest) (*data.LargeLanguageModelTextualResponse, error)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user