Compare commits
150 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1655f11514 | |||
| 0fb2dfdc63 | |||
| d629dfe18c | |||
| 76a274e1cc | |||
| 291bd86c94 | |||
| 8658e849e7 | |||
| 6d0329210f | |||
| c57c1e8490 | |||
| 2425c358e3 | |||
| 373ccba9d6 | |||
| ce345f79ab | |||
| 6baf668696 | |||
| 9ef0e62b05 | |||
| 7df2d49c56 | |||
| 65d52571de | |||
| 4bdd2c7195 | |||
| 9da91ad54f | |||
| 55c175acca | |||
| 3ed37e7719 | |||
| 32a49be913 | |||
| ebcc03d3d0 | |||
| 0be04287c8 | |||
| dfbc2b1440 | |||
| 47b5641597 | |||
| 11da502f75 | |||
| 29c164439c | |||
| 989ffef156 | |||
| c929e950e1 | |||
| 6b8d9fcb13 | |||
| fe265259d7 | |||
| 69d66c8634 | |||
| 729c04880f | |||
| 7cfb5c7457 | |||
| 93630a821d | |||
| 501765d669 | |||
| 91fa3b65f3 | |||
| b82533233e | |||
| 9c4a0493ee | |||
| 9aa6c4102e | |||
| f058fa53eb | |||
| 4ff73b475a | |||
| ba85852543 | |||
| c7c84c74d3 | |||
| 5fbff39c4f | |||
| 285fef6eba | |||
| 97fb73ad43 | |||
| ce0c9ec65e | |||
| ed084e1ce0 | |||
| ec84065f73 | |||
| 2e8aedcfa6 | |||
| 422f18443a | |||
| 0fbf185223 | |||
| 91cdffa9a6 | |||
| 89199eed8b | |||
| 1a65bb9db6 | |||
| 9772d9ca62 | |||
| 5ee93a5db1 | |||
| 85c4f686da | |||
| 1f066b0d1e | |||
| 38ddb7aaa3 | |||
| a22931f96b | |||
| dcee067aea | |||
| 302d118ae0 | |||
| 09eea96cdc | |||
| 205dea9e58 | |||
| 089eabb806 | |||
| dd63500202 | |||
| 13488efdaf | |||
| edcf33f49c | |||
| d601e01029 | |||
| 4d7c3650b5 | |||
| a0fd468309 | |||
| 0b7471879d | |||
| 282b74c95e | |||
| 5ce1dc973c | |||
| 7ac1e0b69f | |||
| 127bed1026 | |||
| d517a1862b | |||
| 8e5202b375 | |||
| 301fb58917 | |||
| aedebb1461 | |||
| 1336377598 | |||
| 3b58dcbc4d | |||
| 23a5f0a96f | |||
| b81d2ec63c | |||
| cabe365907 | |||
| 54f61ecb18 | |||
| 404cd62d7b | |||
| f0f3143605 | |||
| b729fdedca | |||
| 973cec2c6a | |||
| 6e61aba050 | |||
| 40a8deba12 | |||
| 0ba762ba6e | |||
| 732c256db2 | |||
| d2ce801277 | |||
| 4845fdedfd | |||
| f5a7e2e2d6 | |||
| a84f48ae8a | |||
| c4c9503e31 | |||
| 8c1f499ed8 | |||
| c6eb3cfb74 | |||
| d7a0d253c4 | |||
| 9d275a3051 | |||
| 8192a48bc5 | |||
| 247181830c | |||
| d5dfdc8c05 | |||
| d95fcd8b00 | |||
| 40a366e68d | |||
| 593ae10783 | |||
| 75d9e11bab | |||
| 6d37d42e50 | |||
| f9e9c9285f | |||
| 314bf876f2 | |||
| 61c52cc888 | |||
| b42f226aba | |||
| 767b841866 | |||
| fd08666f49 | |||
| eb662681a1 | |||
| ef15eccc33 | |||
| e0286ff133 | |||
| 2baffe3f11 | |||
| 196657ee86 | |||
| b4c4aafc99 | |||
| b907a79223 | |||
| 0d213de580 | |||
| 2e97d699e7 | |||
| 22e4738b7a | |||
| 4b68641043 | |||
| 3a66a3d655 | |||
| 76d1d3aef3 | |||
| fe2aa5d28b | |||
| f474bbf09a | |||
| c4d02db879 | |||
| 75b36ec547 | |||
| 43b7aea76e | |||
| 13a4a47d40 | |||
| fd9f380922 | |||
| a5fdb9d6b7 | |||
| 983c65e4f8 | |||
| fa568056d3 | |||
| ea8b2812d4 | |||
| b6a2aea8fd | |||
| fa047bf303 | |||
| 4177ac3d46 | |||
| 7647f4f5b9 | |||
| bab03dbde1 | |||
| 85db6e96af | |||
| 548461ade0 | |||
| ecbf182173 |
@@ -0,0 +1,167 @@
|
|||||||
|
name: Build Docker Image
|
||||||
|
|
||||||
|
on:
|
||||||
|
# 自动触发:push 到 custom 分支时跑(force-push 后的 rebase 也会触发,可接受)
|
||||||
|
# paths-ignore:纯文档/配置改动跳过,避免浪费 ~10 分钟构建
|
||||||
|
# ⚠️ 已知 quirk(2026-05-02 验证):empty commit(git commit --allow-empty)
|
||||||
|
# 不会触发 paths-ignore 过滤的 workflow,Gitea 把 zero-paths-changed 当作
|
||||||
|
# "vacuously matches ignore list" 跳过。要强制触发必须至少改一个非 ignore 路径
|
||||||
|
# 的真实文件(改这个 yml 自己最稳)。
|
||||||
|
push:
|
||||||
|
branches: [custom]
|
||||||
|
paths-ignore:
|
||||||
|
- '**.md'
|
||||||
|
- '.gitignore'
|
||||||
|
- 'LICENSE'
|
||||||
|
- 'screenshot/**'
|
||||||
|
# sync-upstream.yml 改的是 main reset 逻辑,跟 build 无关
|
||||||
|
# build-image.yml 自己留着会触发,作为 workflow 改动的 self-test
|
||||||
|
- '.gitea/workflows/sync-upstream.yml'
|
||||||
|
# 手动触发:保留作为应急通道(重新打包旧 commit、用自定义 tag 等等)
|
||||||
|
# 注意:手动触发也会跑 deploy job —— 如果只想 build 不部署,临时把 deploy
|
||||||
|
# job 注释掉或在 deploy 里加 if 条件
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
branch:
|
||||||
|
description: '要打包的分支(仅手动触发生效)'
|
||||||
|
required: true
|
||||||
|
default: 'custom'
|
||||||
|
tag:
|
||||||
|
description: '镜像 tag(留空则用 commit short hash)'
|
||||||
|
required: false
|
||||||
|
default: ''
|
||||||
|
|
||||||
|
# 并发控制:同一分支的连续 push 只跑最新的,旧 in-progress run 会被取消
|
||||||
|
# 例:连续 3 次 push,第 1 次 build 跑了 30s,第 2 次开始 → 取消第 1,第 2 跑;
|
||||||
|
# 期间第 3 次又来 → 取消第 2,第 3 跑。最后只构建+部署最新代码,省 CI 时间。
|
||||||
|
# group 包含 ref 是为了不同分支的 build 互不干扰(虽然当前只有 custom 用)
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout target branch
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
# workflow_dispatch 时用用户填的 branch;push 触发时 inputs.branch 为空,
|
||||||
|
# fallback 到 github.ref_name(即触发的分支名,push 到 custom 时就是 custom)
|
||||||
|
ref: ${{ inputs.branch || github.ref_name }}
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
with:
|
||||||
|
# 钉到 v0.13.2(自带 runc 1.1.x),避免 runc 1.2+ 的 procfs 安全检查
|
||||||
|
# 在 DSM 老内核(4.4.x)上撞 openat2/fsmount 不存在导致 build 失败
|
||||||
|
driver-opts: |
|
||||||
|
image=moby/buildkit:v0.13.2
|
||||||
|
|
||||||
|
- name: Login to Gitea Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: git.zhengchentao.win
|
||||||
|
username: ${{ gitea.actor }}
|
||||||
|
password: ${{ secrets.PACKAGES_TOKEN }}
|
||||||
|
|
||||||
|
- name: Determine image tag and revision
|
||||||
|
id: meta
|
||||||
|
run: |
|
||||||
|
if [ -n "${{ inputs.tag }}" ]; then
|
||||||
|
IMAGE_TAG="${{ inputs.tag }}"
|
||||||
|
else
|
||||||
|
IMAGE_TAG="$(git rev-parse --short HEAD)"
|
||||||
|
fi
|
||||||
|
echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT
|
||||||
|
echo "full_sha=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
|
||||||
|
echo "==> Image tag: $IMAGE_TAG"
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
# 上游 Dockerfile 用 BUILD_PIPELINE 作为 CI 跳过开关:
|
||||||
|
# 设为 "1" 时 pkg/exchangerates 跳过依赖第三方 API 的活测试
|
||||||
|
# (加拿大银行/乌兹别克央行 API 国内不稳,跑就超时)
|
||||||
|
# CHECK_3RD_API 留空 → 三方 API 测试不跑;想跑设 "1"
|
||||||
|
build-args: |
|
||||||
|
BUILD_PIPELINE=1
|
||||||
|
# OCI 标签:
|
||||||
|
# - source 让 Gitea 收包时自动把镜像关联到对应 repo(不再需要手动去
|
||||||
|
# "包设置 → 链接到仓库")
|
||||||
|
# - revision 把构建时的 commit full SHA 烙进镜像 manifest,
|
||||||
|
# docker inspect 能反推回源码版本
|
||||||
|
labels: |
|
||||||
|
org.opencontainers.image.source=https://git.zhengchentao.win/zhengchen.tao/ezbookkeeping
|
||||||
|
org.opencontainers.image.revision=${{ steps.meta.outputs.full_sha }}
|
||||||
|
tags: |
|
||||||
|
git.zhengchentao.win/zhengchen.tao/ezbookkeeping:${{ steps.meta.outputs.image_tag }}
|
||||||
|
git.zhengchentao.win/zhengchen.tao/ezbookkeeping:latest
|
||||||
|
|
||||||
|
- name: Build summary
|
||||||
|
# 把构建出的镜像 tag 与源 commit 显式列在 Action run summary 区,
|
||||||
|
# 方便从 UI 一眼看到本次 build 产出。always() 保证 build 失败也输出。
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
{
|
||||||
|
echo "## Build Summary"
|
||||||
|
echo ""
|
||||||
|
echo "| 项 | 值 |"
|
||||||
|
echo "|---|---|"
|
||||||
|
echo "| 触发方式 | \`${{ github.event_name }}\` |"
|
||||||
|
echo "| 源分支 | \`${{ inputs.branch || github.ref_name }}\` |"
|
||||||
|
echo "| 源 commit (full) | \`${{ steps.meta.outputs.full_sha }}\` |"
|
||||||
|
echo "| 源 commit (short) | \`${{ steps.meta.outputs.image_tag }}\` |"
|
||||||
|
echo "| 镜像 tag | \`git.zhengchentao.win/zhengchen.tao/ezbookkeeping:${{ steps.meta.outputs.image_tag }}\` + \`:latest\` |"
|
||||||
|
} >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
# needs: build 串起来 —— build 失败 deploy 自动跳过,无需 if 条件
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
# 登录 Gitea Container Registry,否则 docker compose pull 私有镜像 401。
|
||||||
|
# 跟 build job 那步是同一个 PACKAGES_TOKEN,但每个 job 跑在独立 runner 上,
|
||||||
|
# 凭据不会从 build job 继承,必须在这里再登一次。
|
||||||
|
- name: Login to Gitea Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: git.zhengchentao.win
|
||||||
|
username: ${{ gitea.actor }}
|
||||||
|
password: ${{ secrets.PACKAGES_TOKEN }}
|
||||||
|
|
||||||
|
- name: Pull and restart ezbookkeeping
|
||||||
|
# 部署逻辑直接内联在这。runner 容器挂了 host docker.sock,
|
||||||
|
# 所以这里 docker 命令直接操作的是宿主机 docker daemon,
|
||||||
|
# 容器层面相当于 "ssh 到 NAS 跑 docker compose"。
|
||||||
|
#
|
||||||
|
# NAS_INFRA_TOKEN secret 仅在 nas-infra 是私有仓库时需要;
|
||||||
|
# 公开仓库不设这个 secret 也能拉。
|
||||||
|
env:
|
||||||
|
NAS_INFRA_TOKEN: ${{ secrets.NAS_INFRA_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
|
||||||
|
TMPDIR=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$TMPDIR"' EXIT
|
||||||
|
|
||||||
|
# 决定 clone URL:有 token 用 token(私有),没有用裸 URL(公开)
|
||||||
|
if [ -n "$NAS_INFRA_TOKEN" ]; then
|
||||||
|
CLONE_URL="https://x-access-token:${NAS_INFRA_TOKEN}@git.zhengchentao.win/dev/nas-infra.git"
|
||||||
|
else
|
||||||
|
CLONE_URL="https://git.zhengchentao.win/dev/nas-infra.git"
|
||||||
|
fi
|
||||||
|
|
||||||
|
git clone --depth 1 "$CLONE_URL" "$TMPDIR/nas-infra"
|
||||||
|
cd "$TMPDIR/nas-infra/ezbookkeeping"
|
||||||
|
|
||||||
|
docker compose pull
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# 简单 health:列容器状态 + 输出最近日志
|
||||||
|
sleep 3
|
||||||
|
docker compose ps
|
||||||
|
docker compose logs --tail=30 ezbookkeeping
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
name: Deploy Docker Image
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Execute custom script
|
|
||||||
run: |
|
|
||||||
cat >> deploy.sh <<EOF
|
|
||||||
#!/bin/sh
|
|
||||||
${{ vars.CUSTOM_DEPLOY_SCRIPTS }}
|
|
||||||
EOF
|
|
||||||
chmod +x deploy.sh
|
|
||||||
./deploy.sh
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
name: Docker Release
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- v*
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v5
|
|
||||||
|
|
||||||
- name: Docker meta
|
|
||||||
id: meta
|
|
||||||
uses: docker/metadata-action@v5
|
|
||||||
with:
|
|
||||||
images: |
|
|
||||||
${{ secrets.DOCKER_REPO }}/mayswind/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
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Set up the environment
|
|
||||||
id: setup
|
|
||||||
run: |
|
|
||||||
echo "build_unix_time=$(date '+%s')" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "build_date=$(date '+%Y%m%d')" >> "$GITHUB_OUTPUT"
|
|
||||||
sed -r -i 's#FROM( --.*)? (.*:.*)?#FROM\1 ${{ secrets.DOCKER_REPO }}/mirrors/\2#g' Dockerfile
|
|
||||||
cat >> docker/custom-backend-pre-setup.sh <<EOF
|
|
||||||
#!/bin/sh
|
|
||||||
${{ vars.CUSTOM_BACKEND_PRE_SETUP }}
|
|
||||||
EOF
|
|
||||||
cat >> docker/custom-frontend-pre-setup.sh <<EOF
|
|
||||||
#!/bin/sh
|
|
||||||
${{ vars.CUSTOM_FRONTEND_PRE_SETUP }}
|
|
||||||
EOF
|
|
||||||
chmod +x docker/custom-backend-pre-setup.sh
|
|
||||||
chmod +x docker/custom-frontend-pre-setup.sh
|
|
||||||
|
|
||||||
- name: Build and push
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
file: Dockerfile
|
|
||||||
context: .
|
|
||||||
platforms: ${{ vars.BUILD_RELEASE_PLATFORMS }}
|
|
||||||
push: true
|
|
||||||
build-args: |
|
|
||||||
RELEASE_BUILD=1
|
|
||||||
BUILD_PIPELINE=1
|
|
||||||
BUILD_UNIXTIME=${{ steps.setup.outputs.build_unix_time }}
|
|
||||||
BUILD_DATE=${{ steps.setup.outputs.build_date }}
|
|
||||||
CHECK_3RD_API=${{ vars.CHECK_3RD_API }}
|
|
||||||
SKIP_TESTS=${{ vars.SKIP_TESTS }}
|
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
name: Docker Snapshot
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v5
|
|
||||||
|
|
||||||
- name: Docker meta
|
|
||||||
id: meta
|
|
||||||
uses: docker/metadata-action@v5
|
|
||||||
with:
|
|
||||||
images: |
|
|
||||||
${{ secrets.DOCKER_REPO }}/mayswind/ezbookkeeping
|
|
||||||
tags: |
|
|
||||||
type=raw,value=SNAPSHOT-{{date 'YYYYMMDD'}}
|
|
||||||
type=raw,value=latest-snapshot
|
|
||||||
type=sha,format=short,prefix=SNAPSHOT-
|
|
||||||
|
|
||||||
- name: Set up QEMU
|
|
||||||
uses: docker/setup-qemu-action@v3
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Set up the environment
|
|
||||||
id: setup
|
|
||||||
run: |
|
|
||||||
echo "build_unix_time=$(date '+%s')" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "build_date=$(date '+%Y%m%d')" >> "$GITHUB_OUTPUT"
|
|
||||||
sed -r -i 's#FROM( --.*)? (.*:.*)?#FROM\1 ${{ secrets.DOCKER_REPO }}/mirrors/\2#g' Dockerfile
|
|
||||||
cat >> docker/custom-backend-pre-setup.sh <<EOF
|
|
||||||
#!/bin/sh
|
|
||||||
${{ vars.CUSTOM_BACKEND_PRE_SETUP }}
|
|
||||||
EOF
|
|
||||||
cat >> docker/custom-frontend-pre-setup.sh <<EOF
|
|
||||||
#!/bin/sh
|
|
||||||
${{ vars.CUSTOM_FRONTEND_PRE_SETUP }}
|
|
||||||
EOF
|
|
||||||
chmod +x docker/custom-backend-pre-setup.sh
|
|
||||||
chmod +x docker/custom-frontend-pre-setup.sh
|
|
||||||
|
|
||||||
- name: Build and push
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
file: Dockerfile
|
|
||||||
context: .
|
|
||||||
platforms: ${{ vars.BUILD_SNAPSHOT_PLATFORMS }}
|
|
||||||
push: true
|
|
||||||
build-args: |
|
|
||||||
BUILD_PIPELINE=1
|
|
||||||
BUILD_UNIXTIME=${{ steps.setup.outputs.build_unix_time }}
|
|
||||||
BUILD_DATE=${{ steps.setup.outputs.build_date }}
|
|
||||||
CHECK_3RD_API=${{ vars.CHECK_3RD_API }}
|
|
||||||
SKIP_TESTS=${{ vars.SKIP_TESTS }}
|
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
name: Sync from upstream
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: '要同步的 release tag(留空则同步到 upstream/main 的最新 tag)'
|
||||||
|
required: false
|
||||||
|
default: ''
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
sync:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
token: ${{ secrets.SYNC_TOKEN }}
|
||||||
|
|
||||||
|
- name: Sync main to release tag
|
||||||
|
run: |
|
||||||
|
git config user.name "gitea-actions"
|
||||||
|
git config user.email "actions@gitea.local"
|
||||||
|
git remote add upstream https://git.zhengchentao.win/mirror/ezbookkeeping.git
|
||||||
|
git fetch upstream --tags
|
||||||
|
|
||||||
|
if [ -n "${{ inputs.tag }}" ]; then
|
||||||
|
TARGET="${{ inputs.tag }}"
|
||||||
|
else
|
||||||
|
TARGET=$(git tag -l --sort=-v:refname | head -n 1)
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Syncing main to $TARGET"
|
||||||
|
git rev-parse "$TARGET" || { echo "❌ Tag $TARGET not found"; exit 1; }
|
||||||
|
|
||||||
|
git checkout -B main origin/main
|
||||||
|
git reset --hard "$TARGET"
|
||||||
|
git push origin main --force-with-lease
|
||||||
|
git push origin --tags
|
||||||
@@ -3,13 +3,13 @@ name: Build backend file for windows
|
|||||||
inputs:
|
inputs:
|
||||||
go-version:
|
go-version:
|
||||||
required: false
|
required: false
|
||||||
default: "1.25.5"
|
default: "1.25.7"
|
||||||
mingw-version:
|
mingw-version:
|
||||||
required: false
|
required: false
|
||||||
default: "15.2.0"
|
default: "15.2.0"
|
||||||
mingw-revison:
|
mingw-revison:
|
||||||
required: false
|
required: false
|
||||||
default: "v13-rev0"
|
default: "v13-rev1"
|
||||||
release-build:
|
release-build:
|
||||||
required: false
|
required: false
|
||||||
type: string
|
type: string
|
||||||
|
|||||||
@@ -0,0 +1,352 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const FRONTEND_LOCALES_DIR = path.join(__dirname, '..', '..', 'src', 'locales');
|
||||||
|
const BACKEND_LOCALES_DIR = path.join(__dirname, '..', '..', 'pkg', 'locales');
|
||||||
|
const OUTPUT_DIR = process.argv[2] || path.join(__dirname, '..', '..', 'i18n-badge');
|
||||||
|
|
||||||
|
const DEFAULT_LANGUAGE_TAG = 'en';
|
||||||
|
|
||||||
|
const BACKEND_SKIP_STRUCTS = new Set([
|
||||||
|
'GlobalTextItems',
|
||||||
|
'DefaultTypes',
|
||||||
|
'DataConverterTextItems',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function discoverFrontendLanguages() {
|
||||||
|
const indexPath = path.join(FRONTEND_LOCALES_DIR, 'index.ts');
|
||||||
|
const content = fs.readFileSync(indexPath, 'utf-8');
|
||||||
|
|
||||||
|
const importMap = {};
|
||||||
|
const importRegex = /import\s+(\w+)\s+from\s+['"]\.\/([\w_]+\.json)['"]/g;
|
||||||
|
let match;
|
||||||
|
|
||||||
|
while ((match = importRegex.exec(content)) !== null) {
|
||||||
|
importMap[match[1]] = match[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = {};
|
||||||
|
const langRegex = /['"]([^'"]+)['"]\s*:\s*\{[^}]*content\s*:\s*(\w+)/g;
|
||||||
|
|
||||||
|
while ((match = langRegex.exec(content)) !== null) {
|
||||||
|
const tag = match[1];
|
||||||
|
const varName = match[2];
|
||||||
|
|
||||||
|
if (importMap[varName]) {
|
||||||
|
result[tag] = importMap[varName];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function discoverBackendLanguages() {
|
||||||
|
const allLocalesPath = path.join(BACKEND_LOCALES_DIR, 'all_locales.go');
|
||||||
|
const content = fs.readFileSync(allLocalesPath, 'utf-8');
|
||||||
|
|
||||||
|
const result = {};
|
||||||
|
const entryRegex = /"([^"]+)"\s*:\s*\{[^}]*Content\s*:\s*(\w+)/g;
|
||||||
|
let match;
|
||||||
|
|
||||||
|
while ((match = entryRegex.exec(content)) !== null) {
|
||||||
|
const tag = match[1];
|
||||||
|
const fileName = tag.toLowerCase().replace(/-/g, '_') + '.go';
|
||||||
|
const filePath = path.join(BACKEND_LOCALES_DIR, fileName);
|
||||||
|
|
||||||
|
if (fs.existsSync(filePath)) {
|
||||||
|
result[tag] = fileName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenJSON(obj, prefix) {
|
||||||
|
const result = {};
|
||||||
|
|
||||||
|
for (const key of Object.keys(obj)) {
|
||||||
|
const fullKey = prefix ? prefix + '.' + key : key;
|
||||||
|
|
||||||
|
if (typeof obj[key] === 'object' && obj[key] !== null) {
|
||||||
|
Object.assign(result, flattenJSON(obj[key], fullKey));
|
||||||
|
} else {
|
||||||
|
result[fullKey] = obj[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldSkipFrontendKey(key) {
|
||||||
|
if (key.startsWith('global.')) {
|
||||||
|
return true;
|
||||||
|
} else if (key.startsWith('default.')) {
|
||||||
|
return true;
|
||||||
|
} else if (key.startsWith('currency.')) {
|
||||||
|
if (key.startsWith('currency.unit.')) {
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else if (key.startsWith('mapprovider.')) {
|
||||||
|
return true;
|
||||||
|
} else if (key.startsWith('encoding.')) {
|
||||||
|
return true;
|
||||||
|
} else if (key.startsWith('document.')) {
|
||||||
|
if (key.startsWith('document.anchor.')) {
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFrontendAlwaysTranslatedKey(key) {
|
||||||
|
if (key.startsWith('language.')) {
|
||||||
|
return true;
|
||||||
|
} else if (key.startsWith('format.')) {
|
||||||
|
if (key.startsWith('format.misc.')) {
|
||||||
|
if (key === 'format.misc.multiTextJoinSeparator') {
|
||||||
|
return true;
|
||||||
|
} else if (key === 'format.misc.eachMonthDayInMonthDays') {
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} else if (key.startsWith('datetime.')) {
|
||||||
|
return true;
|
||||||
|
} else if (key.startsWith('timezone.')) {
|
||||||
|
return true;
|
||||||
|
} else if (key.startsWith('currency.')) {
|
||||||
|
if (key === 'currency.name.EUR') {
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else if (key.startsWith('parameter.')) {
|
||||||
|
if (key === 'parameter.id') {
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (key === 'OK') {
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractGoStringFields(content) {
|
||||||
|
const fields = [];
|
||||||
|
const structBlockRegex = /(\w+):\s*&\w+\{([^}]*)\}/gs;
|
||||||
|
let blockMatch;
|
||||||
|
|
||||||
|
while ((blockMatch = structBlockRegex.exec(content)) !== null) {
|
||||||
|
const structName = blockMatch[1];
|
||||||
|
const blockBody = blockMatch[2];
|
||||||
|
const fieldRegex = /(\w+):\s+"((?:[^"\\]|\\.)*)"/g;
|
||||||
|
let fieldMatch;
|
||||||
|
|
||||||
|
while ((fieldMatch = fieldRegex.exec(blockBody)) !== null) {
|
||||||
|
fields.push({
|
||||||
|
struct: structName,
|
||||||
|
name: fieldMatch[1],
|
||||||
|
value: fieldMatch[2],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProgressColor(progress) {
|
||||||
|
if (progress >= 95) {
|
||||||
|
return 'brightgreen';
|
||||||
|
} else if (progress >= 90) {
|
||||||
|
return 'green';
|
||||||
|
} else if (progress >= 70) {
|
||||||
|
return 'yellowgreen';
|
||||||
|
} else if (progress >= 50) {
|
||||||
|
return 'yellow';
|
||||||
|
} else if (progress >= 20) {
|
||||||
|
return 'orange';
|
||||||
|
} else {
|
||||||
|
return 'red';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const frontendLangs = discoverFrontendLanguages();
|
||||||
|
const backendLangs = discoverBackendLanguages();
|
||||||
|
const allTags = new Set([...Object.keys(frontendLangs), ...Object.keys(backendLangs)]);
|
||||||
|
|
||||||
|
console.log('Discovered ' + allTags.size + ' languages: ' + [...allTags].sort().join(', '));
|
||||||
|
|
||||||
|
const defaultFrontendJSON = JSON.parse(fs.readFileSync(path.join(FRONTEND_LOCALES_DIR, `${DEFAULT_LANGUAGE_TAG}.json`), 'utf-8'));
|
||||||
|
const defaultFrontendItemsMap = flattenJSON(defaultFrontendJSON, '');
|
||||||
|
const defaultFrontendKeys = Object.keys(defaultFrontendItemsMap);
|
||||||
|
const frontendTranslatableKeys = defaultFrontendKeys.filter(function (k) {
|
||||||
|
return !shouldSkipFrontendKey(k);
|
||||||
|
});
|
||||||
|
const frontendSkippedCount = defaultFrontendKeys.length - frontendTranslatableKeys.length;
|
||||||
|
const frontendTotal = frontendTranslatableKeys.length;
|
||||||
|
|
||||||
|
const defaultBackendContent = fs.readFileSync(path.join(BACKEND_LOCALES_DIR, `${DEFAULT_LANGUAGE_TAG}.go`), 'utf-8');
|
||||||
|
const defaultBackendItems = extractGoStringFields(defaultBackendContent);
|
||||||
|
const defaultBackendTranslatableItems = defaultBackendItems.filter(function (f) {
|
||||||
|
return !BACKEND_SKIP_STRUCTS.has(f.struct);
|
||||||
|
});
|
||||||
|
const backendSkippedCount = defaultBackendItems.length - defaultBackendTranslatableItems.length;
|
||||||
|
const backendTotal = defaultBackendTranslatableItems.length;
|
||||||
|
|
||||||
|
console.log('Frontend: ' + frontendTotal + ' translatable keys (' + frontendSkippedCount + ' excluded)');
|
||||||
|
console.log('Backend: ' + backendTotal + ' translatable fields (' + backendSkippedCount + ' excluded)');
|
||||||
|
|
||||||
|
const results = {};
|
||||||
|
const untranslatedKeys = {};
|
||||||
|
|
||||||
|
for (const tag of allTags) {
|
||||||
|
results[tag] = {
|
||||||
|
languageTag: tag,
|
||||||
|
frontendTranslated: 0,
|
||||||
|
frontendTotal: frontendTotal,
|
||||||
|
backendTranslated: 0,
|
||||||
|
backendTotal: backendTotal
|
||||||
|
};
|
||||||
|
untranslatedKeys[tag] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const tag of Object.keys(frontendLangs)) {
|
||||||
|
if (tag === DEFAULT_LANGUAGE_TAG) {
|
||||||
|
results[tag].frontendTranslated = frontendTotal;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = frontendLangs[tag];
|
||||||
|
const filePath = path.join(FRONTEND_LOCALES_DIR, file);
|
||||||
|
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||||
|
const kv = flattenJSON(json, '');
|
||||||
|
let translated = 0;
|
||||||
|
|
||||||
|
for (const key of frontendTranslatableKeys) {
|
||||||
|
if (kv[key] !== undefined && kv[key] !== '' && (kv[key] !== defaultFrontendItemsMap[key] || isFrontendAlwaysTranslatedKey(key))) {
|
||||||
|
translated++;
|
||||||
|
} else {
|
||||||
|
untranslatedKeys[tag].push({ source: path.join('src', 'locales', file), key: key, defaultValue: defaultFrontendItemsMap[key], value: kv[key] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
results[tag].frontendTranslated = translated;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const tag of Object.keys(backendLangs)) {
|
||||||
|
if (tag === DEFAULT_LANGUAGE_TAG) {
|
||||||
|
results[tag].backendTranslated = backendTotal;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = backendLangs[tag];
|
||||||
|
const filePath = path.join(BACKEND_LOCALES_DIR, file);
|
||||||
|
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = fs.readFileSync(filePath, 'utf-8');
|
||||||
|
const fields = extractGoStringFields(content).filter(function (f) {
|
||||||
|
return !BACKEND_SKIP_STRUCTS.has(f.struct);
|
||||||
|
});
|
||||||
|
let translated = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < defaultBackendTranslatableItems.length; i++) {
|
||||||
|
if (i < fields.length && fields[i].value !== defaultBackendTranslatableItems[i].value) {
|
||||||
|
translated++;
|
||||||
|
} else {
|
||||||
|
untranslatedKeys[tag].push({ source: path.join('pkg', 'locales', file), key: defaultBackendTranslatableItems[i].struct + '.' + defaultBackendTranslatableItems[i].name, defaultValue: defaultBackendTranslatableItems[i].value, value: (i < fields.length) ? fields[i].value : null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
results[tag].backendTranslated = translated;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const tag of Object.keys(results)) {
|
||||||
|
const r = results[tag];
|
||||||
|
const totalTranslated = r.frontendTranslated + r.backendTranslated;
|
||||||
|
const totalItems = r.frontendTotal + r.backendTotal;
|
||||||
|
r.totalProgress = Math.round((totalTranslated / totalItems) * 10000) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedResults = {};
|
||||||
|
var sortedTags = Object.keys(results).sort();
|
||||||
|
|
||||||
|
for (const tag of sortedTags) {
|
||||||
|
sortedResults[tag] = results[tag];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(OUTPUT_DIR)) {
|
||||||
|
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
var badgesDir = path.join(OUTPUT_DIR, 'badges');
|
||||||
|
|
||||||
|
if (!fs.existsSync(badgesDir)) {
|
||||||
|
fs.mkdirSync(badgesDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(OUTPUT_DIR, 'i18n-progress.json'),
|
||||||
|
JSON.stringify(sortedResults, null, 4) + '\n'
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const tag of sortedTags) {
|
||||||
|
const data = sortedResults[tag];
|
||||||
|
const badge = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
label: 'translation',
|
||||||
|
message: data.totalProgress + '%',
|
||||||
|
color: getProgressColor(data.totalProgress)
|
||||||
|
};
|
||||||
|
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(badgesDir, tag + '.json'),
|
||||||
|
JSON.stringify(badge, null, 4) + '\n'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
var untranslatedDir = path.join(OUTPUT_DIR, 'untranslated');
|
||||||
|
|
||||||
|
if (!fs.existsSync(untranslatedDir)) {
|
||||||
|
fs.mkdirSync(untranslatedDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const tag of sortedTags) {
|
||||||
|
const items = untranslatedKeys[tag] || [];
|
||||||
|
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(untranslatedDir, tag + '.json'),
|
||||||
|
JSON.stringify(items, null, 4) + '\n'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const tag of sortedTags) {
|
||||||
|
const data = sortedResults[tag];
|
||||||
|
const missingCount = (untranslatedKeys[tag] || []).length;
|
||||||
|
console.log(tag + ': ' + data.totalProgress + '% (frontend: ' + data.frontendTranslated + '/' + data.frontendTotal + ', backend: ' + data.backendTranslated + '/' + data.backendTotal + ', untranslated: ' + missingCount + ')');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\nResults written to ' + OUTPUT_DIR);
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
name: Build and Push Docker Image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- myrequirement
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
|
- name: Log in to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Set lowercase image name
|
||||||
|
run: echo "IMAGE_NAME=$(echo ${{ github.repository }} | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
tags: ghcr.io/${{ env.IMAGE_NAME }}:latest
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
@@ -4,6 +4,7 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches-ignore:
|
branches-ignore:
|
||||||
- main
|
- main
|
||||||
|
- myrequirement
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
setup:
|
setup:
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
name: Update i18n Translation Progress Badges
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths:
|
||||||
|
- 'src/locales/**'
|
||||||
|
- 'pkg/locales/**'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
update-i18n-progress:
|
||||||
|
if: vars.UPDATE_I18N_BADGE_REPO == 'true'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: '24'
|
||||||
|
|
||||||
|
- name: Update translation progress data
|
||||||
|
run: |
|
||||||
|
node .github/scripts/update-i18n-progress.js ${{ runner.temp }}/i18n-badge
|
||||||
|
|
||||||
|
- name: Checkout badge repository
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
repository: mayswind/ezbookkeeping-i18n-badge
|
||||||
|
token: ${{ secrets.I18N_BADGE_REPO_TOKEN }}
|
||||||
|
path: ezbookkeeping-i18n-badge
|
||||||
|
|
||||||
|
- name: Update badge data
|
||||||
|
run: |
|
||||||
|
rm -rf ezbookkeeping-i18n-badge/i18n-progress.json
|
||||||
|
cp ${{ runner.temp }}/i18n-badge/i18n-progress.json ezbookkeeping-i18n-badge/
|
||||||
|
mkdir -p ezbookkeeping-i18n-badge/badges
|
||||||
|
rm -rf ezbookkeeping-i18n-badge/badges/*
|
||||||
|
cp ${{ runner.temp }}/i18n-badge/badges/*.json ezbookkeeping-i18n-badge/badges/
|
||||||
|
mkdir -p ezbookkeeping-i18n-badge/untranslated
|
||||||
|
rm -rf ezbookkeeping-i18n-badge/untranslated/*
|
||||||
|
cp ${{ runner.temp }}/i18n-badge/untranslated/*.json ezbookkeeping-i18n-badge/untranslated/
|
||||||
|
|
||||||
|
- name: Commit and push
|
||||||
|
run: |
|
||||||
|
cd ezbookkeeping-i18n-badge
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
git add -A
|
||||||
|
if git diff --cached --quiet; then
|
||||||
|
echo "No changes to commit"
|
||||||
|
else
|
||||||
|
git commit -m "Update i18n progress data (${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }})"
|
||||||
|
git push
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Purge GitHub camo image cache
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
CAMO_URLS=$(curl -s -H "Accept: application/vnd.github.html+json" -H "Authorization: token $GITHUB_TOKEN" "https://api.github.com/repos/${{ github.repository }}/readme" | grep -oP 'https://camo\.githubusercontent\.com/[^"]+' | sort -u)
|
||||||
|
|
||||||
|
if [ -z "$CAMO_URLS" ]; then
|
||||||
|
echo "No camo URLs found, skipping cache purge"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
for url in $CAMO_URLS; do
|
||||||
|
echo "Purging: $url"
|
||||||
|
curl -s -X PURGE "$url" > /dev/null
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Purged $(echo "$CAMO_URLS" | wc -l) camo URLs"
|
||||||
+16
@@ -147,3 +147,19 @@ dist/
|
|||||||
|
|
||||||
# Roo Code
|
# Roo Code
|
||||||
.roo/
|
.roo/
|
||||||
|
|
||||||
|
# Binary and build files
|
||||||
|
ezbookkeeping
|
||||||
|
!**/ezbookkeeping/
|
||||||
|
package/
|
||||||
|
|
||||||
|
# Environment variable files
|
||||||
|
.env
|
||||||
|
**/.env
|
||||||
|
|
||||||
|
# Other directories
|
||||||
|
data/
|
||||||
|
storage/
|
||||||
|
log/
|
||||||
|
|
||||||
|
.claude/
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
本项目是 [mayswind/ezbookkeeping](https://github.com/mayswind/ezbookkeeping) 的个人 fork。
|
||||||
|
|
||||||
|
> **本文件**:仓库分支模型、上游同步流程、CI 故障排查 —— meta 层
|
||||||
|
> **[`FORK.md`](FORK.md)**:fork 相对上游的具体改动清单(feature 维度 + 进度状态)
|
||||||
|
> **个人笔记**:通用 fork 工作流决策框架在 `fork-工作流决策框架.md`(不入库)
|
||||||
|
|
||||||
|
本文件只记录**这个仓库的具体事实**,避免 Claude 会话误判。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 仓库拓扑
|
||||||
|
|
||||||
|
```
|
||||||
|
github.com/mayswind/ezbookkeeping (上游)
|
||||||
|
│ Gitea pull mirror(后台异步)
|
||||||
|
▼
|
||||||
|
git.zhengchentao.win/mirror/ezbookkeeping (只读镜像)
|
||||||
|
│ CI workflow 拉过来
|
||||||
|
▼
|
||||||
|
git.zhengchentao.win/dev/ezbookkeeping (origin,本地唯一 remote)
|
||||||
|
```
|
||||||
|
|
||||||
|
本地 `git remote -v` 只有 origin 一项,**没有手工配 upstream**。上游同步通过 custom 分支上的 workflow 在服务端完成,不是本地操作。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 两个分支的职责(必须先理解,否则会改错地方)
|
||||||
|
|
||||||
|
| 分支 | 职责 | force push? |
|
||||||
|
|---|---|---|
|
||||||
|
| `main` | **锚定上游 release tag**(当前 v1.4.0)。被 `.gitea/workflows/sync-upstream.yml` `git reset --hard <tag>` 覆写。**别在 main 上做任何改动** | 是(由 CI 做) |
|
||||||
|
| `custom` | **所有个人改动 + workflow 文件都在这**:信用额度功能、UI 调整、个人需求清单、`.gitea/workflows/*.yml` 等。具体改动清单见 [`FORK.md`](FORK.md)。日常开发分支,**default branch** | 是(rebase 后人工做) |
|
||||||
|
|
||||||
|
⚠️ **default branch 是 `custom`**。`git clone` 默认 checkout custom,直接是开发分支。
|
||||||
|
|
||||||
|
### 历史:曾经存在过的 ci 分支(已退役)
|
||||||
|
|
||||||
|
2026-05-02 之前曾经有第三个分支 `ci`,最初设计是把 `.gitea/workflows/*.yml` 单独放它上面以"meta/code 分离"。两周后发现 Gitea Actions runs 列表显示的 commit 是 workflow 文件所在 commit(即 ci 的 HEAD),不是被构建的代码 commit,UX 误导性强。
|
||||||
|
|
||||||
|
把 workflow 挪回 custom 之后:
|
||||||
|
- runs 列表 commit = 真实代码 commit ✅
|
||||||
|
- `git clone` 默认落 custom 直接是开发分支 ✅
|
||||||
|
- rebase 上游时 workflow 跟 custom 一起平移 ✅
|
||||||
|
- 代价:失去"workflow 与代码完全独立"的设计美感 —— 这个分离原本就是过度设计
|
||||||
|
|
||||||
|
**ci 分支于 2026-05-02 删除**,仅保留这段说明给后续 Claude 会话理解 git log 里"workflow 文件迁到 custom"这条提交(commit `555ecc1a`)的来龙去脉。**workflow 改动直接在 custom 上做**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## custom 分支 workflow 清单
|
||||||
|
|
||||||
|
`.gitea/workflows/` 当前有 2 个 workflow(2026-05-04 起 build+deploy 合并为单 workflow 双 job):
|
||||||
|
|
||||||
|
| 文件 | 触发 | 干什么 | 状态 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `sync-upstream.yml` | 手动(`workflow_dispatch`,可填 tag) | 服务端把 `dev/main` 强制 reset 到 mirror 上的指定 release tag(默认最新),然后 `push --force-with-lease` + 推 tags | ✅ 在用 |
|
||||||
|
| `build-image.yml` | **自动**(push 到 custom 触发,`paths-ignore` 屏蔽 `**.md` / `.gitignore` / `LICENSE` / `screenshot/**` / `sync-upstream.yml`)+ 手动备选 | **两个 job 串联在同一 run 里**:①`build` job 装 buildkit v0.13.2 → 登录 Gitea registry → 构建镜像(带 OCI 标签 source/revision,Gitea 自动关联包到 repo)→ push 到 `git.zhengchentao.win/dev/ezbookkeeping:<hash>` 与 `:latest`,`build-args: BUILD_PIPELINE=1` 跳过活 API 测试。②`deploy` job (`needs: build`) 登录 registry → clone nas-infra → `docker compose pull && up -d` 重启 ezbookkeeping。私有 nas-infra 需要 `secrets.NAS_INFRA_TOKEN`,公开仓库不需要。UI 上 Actions 列表显示一条 run,run 详情里 dependency graph 显示 build → deploy | ✅ 日常发布通道 + 自动 CD |
|
||||||
|
|
||||||
|
**已删**:
|
||||||
|
- `docker-snapshot.yml` / `docker-release.yml`(2026-05-02,依赖未配的 `secrets.DOCKER_REPO`,永远失败)
|
||||||
|
- `deploy.yml`(2026-05-04,合并进 `build-image.yml` 作为第二个 job,理由:原先 `workflow_run` 链触发会在 Actions 列表产生两条独立 run,UX 割裂;合并后单 run + dependency graph 看 build/deploy 状态一目了然)
|
||||||
|
|
||||||
|
需要时再从 git 历史 cherry-pick 回来。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 同步发布流程(rebase 模型)
|
||||||
|
|
||||||
|
1. 上游出新 release(如 v1.4.0)→ Gitea pull mirror 自动把 tag 同步到 mirror
|
||||||
|
2. 人工触发 `Sync from upstream` workflow → 服务端把 dev/main reset 到该 tag
|
||||||
|
3. 本地 `git fetch && git checkout custom && git rebase origin/main`
|
||||||
|
4. 解冲突(如有)→ 验证 → `git push --force-with-lease origin custom`
|
||||||
|
5. **build-image workflow 自动触发**(force-push 也算 push 事件),构建新镜像;不需要手动点
|
||||||
|
|
||||||
|
日常 feature commit 流程(全自动 CD):
|
||||||
|
|
||||||
|
1. 在 custom 上改代码 → commit → push
|
||||||
|
2. **自动触发 build job**(除非只改了 `**.md` / `.gitignore` / `LICENSE` / `screenshot/**` / `sync-upstream.yml`)
|
||||||
|
3. build 成功 → **同 run 内 deploy job 接力跑**(`needs: build` 串联):clone nas-infra → docker compose pull → up -d
|
||||||
|
4. 整条 push → build → deploy 链路无人工介入,UI 上是单条 run
|
||||||
|
|
||||||
|
**并发取消策略**:`build-image.yml` 设了 `concurrency.cancel-in-progress: true`,连续多次 push 时**只构建+部署最新那一次**(同 run 里 build/deploy 是原子单元,一起取消)。例:连续 3 次 push 间隔 30 秒,第 1 次 build 跑到 30%、第 2 次到来取消它、第 3 次又取消第 2,最终只 build + deploy 第 3 次的代码。省 CI 时间又保证最终一致性。
|
||||||
|
|
||||||
|
如果想跳过 build/deploy(例如手动多次 push 调试),commit 时只改文档相关文件即可(落在 paths-ignore 范围内)。如果想强制重打某个旧 commit,去 Actions UI 手动触发 `Build Docker Image`,填要打包的 branch / tag —— 注意手动触发也会跑 deploy job,**没有"只重新部署不重新 build"的单点入口了**(合并的代价,原 `deploy.yml` 那条路径已废)。临时只想重启容器:直接到 NAS 上 `docker compose up -d` 或在 Actions UI 临时禁用 deploy job。
|
||||||
|
|
||||||
|
**为什么 rebase 不 merge**:个人项目,无团队协作语义要保留,线性历史更清爽。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 给后续 Claude 会话的明确提示
|
||||||
|
|
||||||
|
- 用户说"我的分支" / "切换到我的分支" → 指 `custom`
|
||||||
|
- 用户说"rebase main" → 指 `git rebase origin/main`,目标是把 custom 的改动叠到最新上游 tag 之上
|
||||||
|
- **不要在 `main` 分支上提交任何东西**(会被 CI 覆写)
|
||||||
|
- **workflow 文件改动直接在 custom 上做**(2026-05-02 起,不再是 ci 分支)
|
||||||
|
- force-push custom 是常规操作,但每次用 `--force-with-lease`,不直接 `--force`
|
||||||
|
- 如果发现本地配了 upstream remote,那是历史遗留,不要依赖;以 origin/main 为准
|
||||||
|
- `.claude/` 在 `.gitignore` 里(个人本地配置不入库),但 `CLAUDE.md` 本身入库
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 同步历史
|
||||||
|
|
||||||
|
- **2026-05-01**:rebase custom → origin/main (v1.4.0)。22 个 custom-only 提交(含一个旧的 `Merge branch 'main' into myrequirement` commit)压平为 21 个线性提交。已 force-push origin/custom(`08c69042` → `fe265259`)。
|
||||||
|
- **2026-05-02**:修 Gitea Actions `Build Docker Image` 工作流。三层故障,全部不在本仓库代码里:
|
||||||
|
- **TLS 雷**:`docker login` 走 host 进程不命中 PREROUTING REDIRECT,且 v6 撞 DSM nginx 的 CF Origin Cert。NAS 侧修:iptables 补 OUTPUT 对称规则 + `/etc/hosts` 显式 v4 兜底。详见 obsidian vault [[NAS/notes/内网证书路径]] §三.5/§三.6
|
||||||
|
- **buildkit 内核兼容**:runc 1.2+ 撞 DSM 4.4 内核。`.gitea/workflows/build-image.yml` 钉 `moby/buildkit:v0.13.2`(commit `acdbb5bf`)
|
||||||
|
- **backend 单元测试撞活 API**:`pkg/exchangerates/` 的 `TestExchangeRatesApiLatestExchangeRateHandler_*` 跑活 API(加拿大银行 / 乌兹别克央行),国内访问超时。upstream Dockerfile 已设 `ARG BUILD_PIPELINE`,测试代码看到 `BUILD_PIPELINE=1 && CHECK_3RD_API!=1` 时早退。修:workflow 加 `build-args: BUILD_PIPELINE=1`(commit `2dd8f099`),对齐上游 GH Actions
|
||||||
|
- **2026-05-02 (后续)**:workflow 文件从 ci 分支迁到 custom,default branch 切到 custom(commit `555ecc1a`),随后**删掉 ci 分支**。原因:Gitea Actions runs 列表的 commit 字段一直显示 ci 的 workflow commit,不是被构建的代码 commit,UX 误导性强。挪到 custom 后列表直接显示真实代码 commit。同时清理上游残留的 `docker-release.yml` / `docker-snapshot.yml`(依赖未配的 `secrets.DOCKER_REPO`,永远失败)。仓库回到朴素的 main + custom 双分支模型
|
||||||
|
- **2026-05-02 (numpad fix)**:FORK.md #11 定位 + 修复。小键盘点击卡顿真因是 `.numpad-button` 的 `touch-action: none`(上游 e178a079 引入)与 F7 tap 处理叠加,改为 `touch-action: manipulation`(commit `75b4d78d`)
|
||||||
|
- **2026-05-04**:把 `deploy.yml` 合并进 `build-image.yml` 作为第二个 job(`needs: build`),删除 `deploy.yml`。原先 `workflow_run` 链路会在 Actions 列表产生两条独立 run(build 完一条、deploy 又一条),用户视角割裂;合并后 UI 列表单条 run,run 详情里 dependency graph 显示 build → deploy 串联。代价:失去"不 rebuild 只 redeploy"的 UI 单点触发,临时只想重启容器需直接 ssh NAS 跑 compose。`paths-ignore` 移除已不存在的 `deploy.yml` 项
|
||||||
|
|
||||||
|
## 给后续 Claude 会话:CI 故障排查路径
|
||||||
|
|
||||||
|
如果 Gitea Actions build 又炸,按 NAS 域问题 vs 仓库代码问题分别排查:
|
||||||
|
|
||||||
|
| 现象 | 大概率位置 | 文档 |
|
||||||
|
|---|---|---|
|
||||||
|
| `Login to Gitea Container Registry` 步骤报 `x509: certificate signed by unknown authority` | NAS 网络层(iptables / dnsmasq / DSM nginx 占 443) | obsidian vault `NAS/notes/内网证书路径.md` + `NAS/notes/IPv6 设计.md` |
|
||||||
|
| `Build and push` 步骤里 `RUN ...` 在第二条之内就炸 `unsafe procfs detected` 之类 | buildkit/runc 与 DSM 内核版本 | `.gitea/workflows/build-image.yml` 的 `driver-opts` |
|
||||||
|
| `Failed to pass unit testing` / `Failed to pass lint checking`(build.sh 报) | **先看 Dockerfile 顶部 `ARG`**,多半是 CI 跳过开关没传(如 `BUILD_PIPELINE` / `CHECK_3RD_API` / `SKIP_TESTS`)。**不要先去改测试代码** | `Dockerfile` 顶部 ARG + `.gitea/workflows/build-image.yml` 的 `build-args` |
|
||||||
|
| `actions/checkout` 报 fetch 失败 | Gitea SSH/HTTPS 路径或 token 权限 | gitea-runner 的 `GITEA_RUNNER_REGISTRATION_TOKEN` + NPM `git.zhengchentao.win` 的 Advanced 配置 |
|
||||||
|
| Dockerfile 里某条指令业务逻辑报错 | 真正的代码问题 | 本仓库 `Dockerfile` |
|
||||||
|
|
||||||
|
**通用排查原则**:build.sh 报的"测试失败 / lint 失败"先看是不是上游已经设计了 CI 跳过路径。Dockerfile 的 `ARG` + `build.sh` 内的 `os.Getenv()` 检查通常成对出现(如 `BUILD_PIPELINE=1` → 跳过 3rd API 测试,`SKIP_TESTS=...` → 跳过指定测试名)。对齐上游 `.github/actions/` 下的传参,绝大多数情况能直接对齐。
|
||||||
+3
-3
@@ -1,5 +1,5 @@
|
|||||||
# Build backend binary file
|
# Build backend binary file
|
||||||
FROM golang:1.25.5-alpine3.23 AS be-builder
|
FROM golang:1.25.7-alpine3.23 AS be-builder
|
||||||
ARG RELEASE_BUILD
|
ARG RELEASE_BUILD
|
||||||
ARG BUILD_PIPELINE
|
ARG BUILD_PIPELINE
|
||||||
ARG BUILD_UNIXTIME
|
ARG BUILD_UNIXTIME
|
||||||
@@ -19,7 +19,7 @@ RUN apk add git gcc g++ libc-dev
|
|||||||
RUN ./build.sh backend
|
RUN ./build.sh backend
|
||||||
|
|
||||||
# Build frontend files
|
# Build frontend files
|
||||||
FROM --platform=$BUILDPLATFORM node:24.12.0-alpine3.23 AS fe-builder
|
FROM --platform=$BUILDPLATFORM node:24.14.0-alpine3.23 AS fe-builder
|
||||||
ARG RELEASE_BUILD
|
ARG RELEASE_BUILD
|
||||||
ARG BUILD_PIPELINE
|
ARG BUILD_PIPELINE
|
||||||
ARG BUILD_UNIXTIME
|
ARG BUILD_UNIXTIME
|
||||||
@@ -35,7 +35,7 @@ RUN apk add git
|
|||||||
RUN ./build.sh frontend
|
RUN ./build.sh frontend
|
||||||
|
|
||||||
# Package docker image
|
# Package docker image
|
||||||
FROM alpine:3.23.2
|
FROM alpine:3.23.3
|
||||||
LABEL maintainer="MaysWind <i@mayswind.net>"
|
LABEL maintainer="MaysWind <i@mayswind.net>"
|
||||||
RUN addgroup -S -g 1000 ezbookkeeping && adduser -S -G ezbookkeeping -u 1000 ezbookkeeping
|
RUN addgroup -S -g 1000 ezbookkeeping && adduser -S -G ezbookkeeping -u 1000 ezbookkeeping
|
||||||
RUN apk --no-cache add tzdata
|
RUN apk --no-cache add tzdata
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
# ezBookkeeping 个人 fork 改动清单
|
||||||
|
|
||||||
|
> 本文件记录这个 fork 相对上游 [mayswind/ezbookkeeping](https://github.com/mayswind/ezbookkeeping) 的所有定制改动 + 进度状态。
|
||||||
|
|
||||||
|
> 关联文档:
|
||||||
|
> - [`CLAUDE.md`](CLAUDE.md) —— 仓库分支模型 / 上游同步流程 / CI 排查路径(meta 层)
|
||||||
|
> - 部署:见自家 NAS infra repo `git.zhengchentao.win/dev/nas-infra` 的 README(compose-level)
|
||||||
|
>
|
||||||
|
> 标注:❌ 难/暂缓 | ❓ 待定 | 🔍 调查中 | 🟢 已完成
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、账户功能
|
||||||
|
|
||||||
|
### 1. 🟢 信用卡账户:额度与可用额度
|
||||||
|
**描述:** 为信用卡类型账户新增「信用额度」字段,在账户列表显示可用额度。
|
||||||
|
|
||||||
|
**已完成:**
|
||||||
|
- 后端:`AccountExtend` JSON blob 新增 `CreditLimit` 字段(无需数据库迁移)
|
||||||
|
- API:`AccountCreateRequest` / `AccountModifyRequest` / `AccountInfoResponse` 增加 `creditLimit`
|
||||||
|
- 前端 model:`Account` 类增加 `creditLimit` 字段,同步序列化/反序列化
|
||||||
|
- 移动端 EditPage:CreditCard 分类时显示信用额度输入项(数字键盘)
|
||||||
|
- 桌面端 EditDialog:CreditCard 分类时显示信用额度输入框(`amount-input`)
|
||||||
|
- 移动端 ListPage:账户名下方显示「可用额度: ¥xxx」(= `creditLimit + balance`)
|
||||||
|
- 桌面端 ListPage:账户卡片余额旁显示「Available: ¥xxx」
|
||||||
|
- 语言包:中英繁均已添加 `"Credit Limit"` / `"Available"`
|
||||||
|
|
||||||
|
### 2. 🟢 按账户筛选交易时顶部显示账户信息卡
|
||||||
|
**描述:** 在按单个账户筛选的交易列表顶部,显示账户图标、名称和余额/可用额度。
|
||||||
|
|
||||||
|
**已完成:**
|
||||||
|
- 仅单账户筛选时(`queryAllFilterAccountIdsCount === 1`)显示,多账户/全量时隐藏
|
||||||
|
- 信用卡账户显示「欠款 · 可用 ¥xxx」,普通账户显示余额
|
||||||
|
- 移动端:toolbar 下方插入账户信息卡片;桌面端:日期范围行下方插入 tonal 样式账户卡片
|
||||||
|
- 多子账户(`MultiSubAccounts`)一级账户:使用 `getAccountSubAccountBalance` 获取汇总余额及正确币种,修复 `currency = '---'` 导致货币符号不显示的问题
|
||||||
|
- 涉及文件:`src/views/mobile/transactions/ListPage.vue`、`src/views/desktop/transactions/ListPage.vue`
|
||||||
|
|
||||||
|
### 3. 🟢 账户编辑页直接修改余额(自动插入调整记录)
|
||||||
|
**描述:** 在账户编辑页修改余额字段,保存时自动计算差值并插入一条「余额调整」类型交易。
|
||||||
|
|
||||||
|
**已完成:**
|
||||||
|
- 后端:移除「账户已有交易时不允许添加 ModifyBalance」的限制
|
||||||
|
- 后端:`Amount` 与 `RelatedAccountAmount` 均存储 delta;创建时 `balance += delta`,删除时 `balance -= delta`,修改时 `balance = balance - oldDelta + newDelta`
|
||||||
|
- 后端响应:`ToTransactionInfoResponse` 对 ModifyBalance 类型返回 `RelatedAccountAmount` 作为 `sourceAmount`
|
||||||
|
- 前端 store:新增 `adjustAccountBalance({ accountId, targetBalance, currentBalance })` 函数,发送 `sourceAmount = delta`
|
||||||
|
- 移动端 EditPage:余额字段对已有账户解除只读;保存时若余额变化先调 `adjustAccountBalance`;捕获 `NothingWillBeUpdated (200004)` 视为成功
|
||||||
|
- 桌面端 EditDialog:同上逻辑,支持多子账户逐一调整
|
||||||
|
- 「调整余额」入口仅在账户编辑页,已从账户列表「更多」菜单移除
|
||||||
|
- 涉及文件:`pkg/services/transactions.go`、`pkg/models/transaction.go`、`src/stores/transaction.ts`、`src/views/mobile/accounts/EditPage.vue`、`src/views/desktop/accounts/list/dialogs/EditDialog.vue`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、记账页面
|
||||||
|
|
||||||
|
### 4. 🟢 记账页选择账户后显示余额/可用额度
|
||||||
|
**描述:** 在记账页面选择账户后,在账户行显示该账户的当前余额或信用卡可用额度。
|
||||||
|
|
||||||
|
**已完成:**
|
||||||
|
- 信用卡账户(有 `creditLimit`):显示「欠款金额 · 可用 ¥xxx」
|
||||||
|
- 普通负债账户:显示欠款正数;普通资产账户:显示余额
|
||||||
|
- 转账类型时源账户和目标账户均显示
|
||||||
|
- 移动端:账户列表项 `footer` 字段;桌面端:`two-column-select` 的 `custom-selection-secondary-text`
|
||||||
|
- 涉及文件:`src/views/mobile/transactions/EditPage.vue`、`src/views/desktop/transactions/list/dialogs/EditDialog.vue`
|
||||||
|
|
||||||
|
### 5. ❓ 记录上次选择的账户(待定)
|
||||||
|
**描述:** 新建交易时,默认选中上次使用的账户。
|
||||||
|
|
||||||
|
**实现思路:**
|
||||||
|
- 保存账户 ID 到 `localStorage`,打开记账页时读取并预选
|
||||||
|
- 涉及文件:`src/views/mobile/transactions/EditPage.vue`、`src/lib/settings.ts`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、小键盘
|
||||||
|
|
||||||
|
### 6. 🟢 小键盘布局调整
|
||||||
|
**描述:** 调整数字键盘布局。
|
||||||
|
|
||||||
|
**已完成:**
|
||||||
|
```
|
||||||
|
[数值显示区 ] [ ⌫ ]
|
||||||
|
7 8 9 ×
|
||||||
|
4 5 6 −
|
||||||
|
1 2 3 +
|
||||||
|
C 0 . OK
|
||||||
|
```
|
||||||
|
- ⌫ 单击退格;按住不放先删一位、约 500ms 后清空全部(长按响应细节见 #11 第二阶段)
|
||||||
|
- C 一键清除全部
|
||||||
|
- 涉及文件:`src/components/mobile/NumberPadSheet.vue`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、交易详情
|
||||||
|
|
||||||
|
### 7. 🟢 交易详情页增加「编辑」和「删除」入口(仅移动端)
|
||||||
|
**描述:** 移动端交易详情页三点菜单中增加编辑和删除操作。PC 端详情直接在编辑弹窗中展示,无需额外入口。
|
||||||
|
|
||||||
|
**已完成:**
|
||||||
|
- 三点菜单:第一项「Edit」跳转编辑页,最后一项红色「Delete」确认后删除并返回
|
||||||
|
- 从详情返回编辑页后自动刷新数据
|
||||||
|
- 涉及文件:`src/views/mobile/transactions/EditPage.vue`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、交易时间选择
|
||||||
|
|
||||||
|
### 8. ❌ 点击交易时间标题默认打开日期选择(已回滚)
|
||||||
|
**描述:** 原想让点击「Transaction Time」标题行时默认弹日期选择器。
|
||||||
|
|
||||||
|
**为何回滚:** 改动改的是 `template #header` 那行 label 的点击 handler(`'time'` → `'date'`),实际操作中用户点的是 `template #title` 里的日期/时间文本。上游早在 commit `368322f9` 已实现"点哪走哪"的智能路由——点日期开日期选择器、点时间开时间选择器。所以这条改动**用户视角无可见差异**,纯空改,回滚到上游行为。
|
||||||
|
|
||||||
|
**留档教训:** 改 UI 行为前先把"用户实际点哪个元素"摸清楚,别只看着 DOM 结构想当然。`#header` slot 只是上方的 label 行,正常用户极少触发。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、分类选择
|
||||||
|
|
||||||
|
### 9. 🟢 分类选择默认全部展开(仅移动端)
|
||||||
|
**描述:** 在移动端记账页选择分类时,默认展开所有一级分类。PC 端使用不同的分类选择组件,无需此设置。
|
||||||
|
|
||||||
|
**已完成:**
|
||||||
|
- 设置存 localStorage(字段 `expandCategoryTreeByDefault`,默认 `false`),即时生效无需 reload;已加入云同步白名单(`ALL_ALLOWED_CLOUD_SYNC_APP_SETTING_KEY_TYPES`),可跨设备同步
|
||||||
|
- `TreeViewSelectionSheet` 新增可选 prop `defaultExpanded`,`:opened` 改为 `props.defaultExpanded || isPrimaryItemHasSecondaryValue(item)`
|
||||||
|
- 移动端 EditPage 三个分类 sheet(支出/收入/转账)均传入 `:default-expanded`
|
||||||
|
- 设置仅在移动端设置页显示,PC 端无对应入口
|
||||||
|
- 涉及文件:`src/components/mobile/TreeViewSelectionSheet.vue`、`src/views/mobile/transactions/EditPage.vue`、`src/views/mobile/SettingsPage.vue`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、性能与动画
|
||||||
|
|
||||||
|
### 10. 🟢 全局动画加速(仅移动端)
|
||||||
|
**描述:** 移动端全局页面跳转及各类弹层动画加速。
|
||||||
|
|
||||||
|
**已完成:**
|
||||||
|
- 页面跳转、Sheet、ActionSheet、Popup、Dialog、Popover 动画时长从 300ms → 150ms
|
||||||
|
- Tab 切换动画保持原样(设置中已有开关可控制)
|
||||||
|
- 涉及文件:`src/styles/mobile/global.scss`
|
||||||
|
|
||||||
|
### 11. 🟢 小键盘点击卡顿(三次修正)
|
||||||
|
**描述:** 移动端小键盘点击有延迟感。
|
||||||
|
|
||||||
|
**第一阶段(2026-05-02)`touch-action: none` 引发的 300ms 双击延迟:** 上游在 `.numpad-button` 上设了 `touch-action: none`(commit `e178a079` "code refactor" by MaysWind),与浏览器双击缩放检测叠加后保留了老式 300ms 点击延迟。
|
||||||
|
- 修复:`.numpad-button` 的 `touch-action: none` 改为 `touch-action: manipulation`(W3C 标准"快速点击"值,禁双击缩放)
|
||||||
|
|
||||||
|
**第二阶段(2026-05-08)退格键 `@taphold` 等待 750ms:** backspace 单点仍可感知延迟。根因是 `@click` + `@taphold` 让 F7 必须等 ~750ms 判别 tap vs hold,期间 click 被抑制。
|
||||||
|
- 修复:弃用 `@click="backspace" @taphold="clear()"`,改为原生 `pointerdown`/`pointerup`/`pointercancel`/`pointerleave` + 自管定时器
|
||||||
|
- 行为:单击立即删一位;按住不放先删一位、约 500ms 后清空全部
|
||||||
|
|
||||||
|
**第三阶段(2026-05-08)所有数字/运算键也延迟:** 第一阶段修完后用户反馈数字键仍有"等一拍"感。怀疑 F7 整套 tap 处理(含 active-state 检测、`fastClicks` 兼容代码、tap-hold 全局监听)即便不显式声明 `@taphold` 也会给 `@click` 加上判别期。
|
||||||
|
- 修复:把所有按键(数字 0-9、运算 ×−+、C 清空、小数点/双零、OK 确认)的 `@click` 全部换成 `@pointerdown.left`
|
||||||
|
- 原理:`pointerdown` 在按下瞬间触发,绕开 F7 的 tap 合成路径。`.left` 修饰符限制只响应主键(触屏 button=0 始终满足,桌面右键不会误触发)
|
||||||
|
- F7 的 `.active-state` 视觉反馈基于独立的 touchstart/touchend 监听,不依赖 `@click`,按下视觉效果保留
|
||||||
|
|
||||||
|
涉及文件:`src/components/mobile/NumberPadSheet.vue`
|
||||||
|
|
||||||
|
**附带认知:** 原 #11 假设是"全局点击响应慢"或"接口慢",与 #12 离线缓存挂钩调研。实际诊断后跟那两条都无关——纯 F7 框架 tap 合成 + 双击缩放 + taphold 检测三者叠加。最终通过完全弃用 `@click` 改 pointer 事件解决。该认知值得记录避免后续误诊路径。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、离线 / 缓存
|
||||||
|
|
||||||
|
### 12. ❌ 本地优先 / 离线数据缓存(暂缓)
|
||||||
|
**描述:** 交易数据本地缓存,优先展示缓存数据,后台静默拉取更新。
|
||||||
|
|
||||||
|
**现状:** Service Worker 已实现静态资源缓存,但交易业务数据目前不做本地缓存。
|
||||||
|
|
||||||
|
**为何难:**
|
||||||
|
- 需引入 IndexedDB 存储交易/账户/分类数据
|
||||||
|
- 需处理本地与服务端的数据同步、冲突解决
|
||||||
|
- 属于架构级改动,工作量较大
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 进度总览
|
||||||
|
|
||||||
|
| # | 需求 | 状态 |
|
||||||
|
|---|------|------|
|
||||||
|
| 1 | 信用卡额度 | 🟢 已完成 |
|
||||||
|
| 2 | 账户信息卡片 | 🟢 已完成 |
|
||||||
|
| 3 | 调整余额入口 | 🟢 已完成 |
|
||||||
|
| 4 | 记账页显示余额 | 🟢 已完成 |
|
||||||
|
| 5 | 记住上次账户 | ❓ 待定 |
|
||||||
|
| 6 | 小键盘布局 | 🟢 已完成 |
|
||||||
|
| 7 | 详情编辑/删除 | 🟢 已完成 |
|
||||||
|
| 8 | 点击时间默认日期 | ❌ 已回滚(无效改动) |
|
||||||
|
| 9 | 分类默认展开 | 🟢 已完成 |
|
||||||
|
| 10 | 全局动画加速 | 🟢 已完成 |
|
||||||
|
| 11 | 小键盘点击卡顿(touch-action 修复) | 🟢 已完成 |
|
||||||
|
| 12 | 离线缓存 | ❌ 暂缓 |
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2020-2026 MaysWind (i@mayswind.net)
|
Copyright (c) 2020-2026 MaysWind (i@mayswind.net)
|
||||||
|
Copyright (c) 2026 Zhengchen Tao (fork modifications)
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|||||||
@@ -1,4 +1,29 @@
|
|||||||
# ezBookkeeping
|
# ezBookkeeping
|
||||||
|
|
||||||
|
> ## Personal fork notice
|
||||||
|
>
|
||||||
|
> This repository is a personal fork of [mayswind/ezbookkeeping](https://github.com/mayswind/ezbookkeeping) (MIT) with the following custom additions on top of upstream releases:
|
||||||
|
>
|
||||||
|
> - **Credit card accounts**: credit-limit field; account list shows available credit
|
||||||
|
> - **Account-filtered transactions**: when filtering by a single account, show an account info card on top (icon / name / balance / available credit)
|
||||||
|
> - **Account editing**: edit the balance field directly; a "balance adjustment" transaction is generated automatically
|
||||||
|
> - **Add-transaction page**: live display of balance or available credit after selecting an account
|
||||||
|
> - **Numpad**: custom layout (4-column calculator style) + `touch-action` fix for tap latency
|
||||||
|
> - **Mobile animations**: generic transitions 300ms → 150ms
|
||||||
|
> - **Transaction detail**: edit / delete entries added to the mobile three-dot menu
|
||||||
|
> - **Category picker**: optional "expand all by default" on mobile (cloud-sync allowlisted)
|
||||||
|
>
|
||||||
|
> Full list with implementation details: [`FORK.md`](FORK.md)
|
||||||
|
> Branch model / upstream sync / CI troubleshooting: [`CLAUDE.md`](CLAUDE.md)
|
||||||
|
>
|
||||||
|
> All modifications are released under the same MIT License as upstream — see [`LICENSE`](LICENSE).
|
||||||
|
>
|
||||||
|
> ---
|
||||||
|
>
|
||||||
|
> Upstream README content follows below.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
[](https://github.com/mayswind/ezbookkeeping/blob/master/LICENSE)
|
[](https://github.com/mayswind/ezbookkeeping/blob/master/LICENSE)
|
||||||
[](https://goreportcard.com/report/github.com/mayswind/ezbookkeeping)
|
[](https://goreportcard.com/report/github.com/mayswind/ezbookkeeping)
|
||||||
[](https://github.com/mayswind/ezbookkeeping/releases)
|
[](https://github.com/mayswind/ezbookkeeping/releases)
|
||||||
@@ -11,7 +36,7 @@
|
|||||||
[](https://trendshift.io/repositories/12917)
|
[](https://trendshift.io/repositories/12917)
|
||||||
|
|
||||||
## Introduction
|
## Introduction
|
||||||
ezBookkeeping is a lightweight, self-hosted personal finance app with a user-friendly interface and powerful bookkeeping features. It's easy to deploy, and you can start it with just one single Docker command. Designed to be resource-efficient and highly scalable, it can run smoothly on devices as small as a Raspberry Pi, or scale up to NAS, MicroServers, and even large cluster environments.
|
ezBookkeeping is a lightweight, self-hosted personal finance app with a user-friendly interface and powerful bookkeeping features. It helps you record daily transactions, import data from various sources, and quickly search and filter your bills. You can analyze historical data using built-in charts or perform custom queries with your own chart dimensions to better understand spending patterns and financial trends. ezBookkeeping is easy to deploy, and you can start it with just one single Docker command. Designed to be resource-efficient, it runs smoothly on devices such as Raspberry Pi, NAS, and MicroServers.
|
||||||
|
|
||||||
ezBookkeeping offers tailored interfaces for both mobile and desktop devices. With support for PWA (Progressive Web Apps), you can even [add it to your mobile home screen](https://raw.githubusercontent.com/wiki/mayswind/ezbookkeeping/img/mobile/add_to_home_screen.gif) and use it like a native app.
|
ezBookkeeping offers tailored interfaces for both mobile and desktop devices. With support for PWA (Progressive Web Apps), you can even [add it to your mobile home screen](https://raw.githubusercontent.com/wiki/mayswind/ezbookkeeping/img/mobile/add_to_home_screen.gif) and use it like a native app.
|
||||||
|
|
||||||
@@ -21,9 +46,9 @@ Live Demo: [https://ezbookkeeping-demo.mayswind.net](https://ezbookkeeping-demo.
|
|||||||
- **Open Source & Self-Hosted**
|
- **Open Source & Self-Hosted**
|
||||||
- Built for privacy and control
|
- Built for privacy and control
|
||||||
- **Lightweight & Fast**
|
- **Lightweight & Fast**
|
||||||
- Optimized for performance, runs smoothly even on low-resource environments
|
- Minimal resource usage, runs smoothly even on low-resource devices
|
||||||
- **Easy Installation**
|
- **Easy Installation**
|
||||||
- Docker-ready
|
- Docker support
|
||||||
- Supports SQLite, MySQL, PostgreSQL
|
- Supports SQLite, MySQL, PostgreSQL
|
||||||
- Cross-platform (Windows, macOS, Linux)
|
- Cross-platform (Windows, macOS, Linux)
|
||||||
- Works on x86, amd64, ARM architectures
|
- Works on x86, amd64, ARM architectures
|
||||||
@@ -33,24 +58,28 @@ Live Demo: [https://ezbookkeeping-demo.mayswind.net](https://ezbookkeeping-demo.
|
|||||||
- Dark mode
|
- Dark mode
|
||||||
- **AI-Powered Features**
|
- **AI-Powered Features**
|
||||||
- Receipt image recognition
|
- Receipt image recognition
|
||||||
- Supports MCP (Model Context Protocol) for AI integration
|
- MCP (Model Context Protocol) support for AI integration
|
||||||
|
- Agent Skill and API command-line script tools support for AI integration
|
||||||
- **Powerful Bookkeeping**
|
- **Powerful Bookkeeping**
|
||||||
- Two-level accounts and categories
|
- Two-level accounts and categories
|
||||||
- Attach images to transactions
|
- Image attachments for transactions
|
||||||
- Location tracking with maps
|
- Location tracking with maps
|
||||||
- Recurring transactions
|
- Scheduled transactions
|
||||||
- Advanced filtering, search, visualization, and analysis
|
- Advanced filtering, search, visualization and analysis
|
||||||
- **Localization & Globalization**
|
- **Localization & Internationalization**
|
||||||
- Multi-language and multi-currency support
|
- Multi-language and multi-currency support
|
||||||
- Automatic exchange rates
|
- Multiple exchange rate sources with automatic updates
|
||||||
- Multi-timezone awareness
|
- Multi-timezone support
|
||||||
- Custom formats for dates, numbers, and currencies
|
- Custom formats for dates, numbers and currencies
|
||||||
- **Security**
|
- **Security**
|
||||||
- Two-factor authentication (2FA)
|
- Two-factor authentication (2FA)
|
||||||
|
- OIDC external authentication
|
||||||
- Login rate limiting
|
- Login rate limiting
|
||||||
- Application lock (PIN code / WebAuthn)
|
- Application lock (PIN code / WebAuthn)
|
||||||
- **Data Import/Export**
|
- **Data Import & Export**
|
||||||
- Supports CSV, OFX, QFX, QIF, IIF, Camt.053, MT940, GnuCash, Firefly III, Beancount, and more
|
- Supports CSV, OFX, QFX, QIF, IIF, Camt.052, Camt.053, MT940, GnuCash, Firefly III, Beancount and more
|
||||||
|
|
||||||
|
For a full list of features, visit the [Full Feature List](https://ezbookkeeping.mayswind.net/comparison/).
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
### Desktop Version
|
### Desktop Version
|
||||||
@@ -112,42 +141,40 @@ You can also build a Docker image. Make sure you have [Docker](https://www.docke
|
|||||||
## Contributing
|
## 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)
|
If you find a bug, please [submit an issue](https://github.com/mayswind/ezbookkeeping/issues) on GitHub.
|
||||||
|
|
||||||
Want to contribute code? Feel free to fork and send a pull request.
|
If you would like to contribute code, you can fork the repository and open a pull request.
|
||||||
|
|
||||||
Contributions of all kinds — bug reports, feature suggestions, documentation improvements, or code — are highly appreciated.
|
Improvements to documentation, feature suggestions, and other forms of feedback are also appreciated.
|
||||||
|
|
||||||
Check out our [Contributor Graph](https://github.com/mayswind/ezbookkeeping/graphs/contributors) to see the amazing people who've already helped.
|
You can view existing contributors on the [Contributor Graph](https://github.com/mayswind/ezbookkeeping/graphs/contributors).
|
||||||
|
|
||||||
## Translating
|
## Translating
|
||||||
Help make ezBookkeeping accessible to users around the world. If you want to contribute a translation, please refer to our [translation guide](https://ezbookkeeping.mayswind.net/translating).
|
Help make ezBookkeeping accessible to users around the world. We welcome help to improve existing translations or add new ones. If you would like to contribute a translation, please refer to the [translation guide](https://ezbookkeeping.mayswind.net/translating).
|
||||||
|
|
||||||
Currently available translations:
|
Currently available translations:
|
||||||
|
|
||||||
| Tag | Language | Contributors |
|
| Tag | Language | Progress | Contributors |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| de | Deutsch | [@chrgm](https://github.com/chrgm) |
|
| de | Deutsch |  | [@chrgm](https://github.com/chrgm), [@1270o1](https://github.com/1270o1) |
|
||||||
| en | English | / |
|
| en | English |  | / |
|
||||||
| es | Español | [@Miguelonlonlon](https://github.com/Miguelonlonlon), [@abrugues](https://github.com/abrugues), [@AndresTeller](https://github.com/AndresTeller), [@diegofercri](https://github.com/diegofercri) |
|
| es | Español |  | [@Miguelonlonlon](https://github.com/Miguelonlonlon), [@abrugues](https://github.com/abrugues), [@AndresTeller](https://github.com/AndresTeller), [@diegofercri](https://github.com/diegofercri) |
|
||||||
| fr | Français | [@brieucdlf](https://github.com/brieucdlf) |
|
| fr | Français |  | [@brieucdlf](https://github.com/brieucdlf) |
|
||||||
| it | Italiano | [@waron97](https://github.com/waron97) |
|
| it | Italiano |  | [@waron97](https://github.com/waron97) |
|
||||||
| ja | 日本語 | [@tkymmm](https://github.com/tkymmm) |
|
| ja | 日本語 |  | [@tkymmm](https://github.com/tkymmm) |
|
||||||
| kn | ಕನ್ನಡ | [@Darshanbm05](https://github.com/Darshanbm05) |
|
| kn | ಕನ್ನಡ |  | [@Darshanbm05](https://github.com/Darshanbm05) |
|
||||||
| ko | 한국어 | [@overworks](https://github.com/overworks) |
|
| ko | 한국어 |  | [@overworks](https://github.com/overworks) |
|
||||||
| nl | Nederlands | [@automagics](https://github.com/automagics) |
|
| nl | Nederlands |  | [@automagics](https://github.com/automagics) |
|
||||||
| pt-BR | Português (Brasil) | [@thecodergus](https://github.com/thecodergus) |
|
| pt-BR | Português (Brasil) |  | [@thecodergus](https://github.com/thecodergus), [@balaios](https://github.com/balaios) |
|
||||||
| ru | Русский | [@artegoser](https://github.com/artegoser) |
|
| ru | Русский |  | [@artegoser](https://github.com/artegoser), [@dshemin](https://github.com/dshemin) |
|
||||||
| sl | Slovenščina | [@thehijacker](https://github.com/thehijacker) |
|
| sl | Slovenščina |  | [@thehijacker](https://github.com/thehijacker) |
|
||||||
| ta | தமிழ் | [@hhharsha36](https://github.com/hhharsha36) |
|
| ta | தமிழ் |  | [@hhharsha36](https://github.com/hhharsha36) |
|
||||||
| th | ไทย | [@natthavat28](https://github.com/natthavat28) |
|
| th | ไทย |  | [@natthavat28](https://github.com/natthavat28) |
|
||||||
| tr | Türkçe | [@aydnykn](https://github.com/aydnykn) |
|
| tr | Türkçe |  | [@aydnykn](https://github.com/aydnykn) |
|
||||||
| uk | Українська | [@nktlitvinenko](https://github.com/nktlitvinenko) |
|
| uk | Українська |  | [@nktlitvinenko](https://github.com/nktlitvinenko) |
|
||||||
| vi | Tiếng Việt | [@f97](https://github.com/f97) |
|
| vi | Tiếng Việt |  | [@f97](https://github.com/f97) |
|
||||||
| zh-Hans | 中文 (简体) | / |
|
| zh-Hans | 中文 (简体) |  | / |
|
||||||
| zh-Hant | 中文 (繁體) | / |
|
| zh-Hant | 中文 (繁體) |  | / |
|
||||||
|
|
||||||
Don't see your language? Help us add it.
|
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
1. [English](https://ezbookkeeping.mayswind.net)
|
1. [English](https://ezbookkeeping.mayswind.net)
|
||||||
|
|||||||
@@ -195,9 +195,25 @@ func getConfigWithoutSensitiveData(config *settings.Config) *settings.Config {
|
|||||||
clonedConfig.ReceiptImageRecognitionLLMConfig.OpenAICompatibleAPIKey = "****"
|
clonedConfig.ReceiptImageRecognitionLLMConfig.OpenAICompatibleAPIKey = "****"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if clonedConfig.ReceiptImageRecognitionLLMConfig.AnthropicCompatibleAPIKey != "" {
|
||||||
|
clonedConfig.ReceiptImageRecognitionLLMConfig.AnthropicCompatibleAPIKey = "****"
|
||||||
|
}
|
||||||
|
|
||||||
|
if clonedConfig.ReceiptImageRecognitionLLMConfig.AnthropicAPIKey != "" {
|
||||||
|
clonedConfig.ReceiptImageRecognitionLLMConfig.AnthropicAPIKey = "****"
|
||||||
|
}
|
||||||
|
|
||||||
if clonedConfig.ReceiptImageRecognitionLLMConfig.OpenRouterAPIKey != "" {
|
if clonedConfig.ReceiptImageRecognitionLLMConfig.OpenRouterAPIKey != "" {
|
||||||
clonedConfig.ReceiptImageRecognitionLLMConfig.OpenRouterAPIKey = "****"
|
clonedConfig.ReceiptImageRecognitionLLMConfig.OpenRouterAPIKey = "****"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if clonedConfig.ReceiptImageRecognitionLLMConfig.LMStudioToken != "" {
|
||||||
|
clonedConfig.ReceiptImageRecognitionLLMConfig.LMStudioToken = "****"
|
||||||
|
}
|
||||||
|
|
||||||
|
if clonedConfig.ReceiptImageRecognitionLLMConfig.GoogleAIAPIKey != "" {
|
||||||
|
clonedConfig.ReceiptImageRecognitionLLMConfig.GoogleAIAPIKey = "****"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if clonedConfig.OAuth2ClientSecret != "" {
|
if clonedConfig.OAuth2ClientSecret != "" {
|
||||||
|
|||||||
+2
-1
@@ -316,6 +316,7 @@ func startWebServer(c *core.CliContext) error {
|
|||||||
|
|
||||||
apiV1Route := apiRoute.Group("/v1")
|
apiV1Route := apiRoute.Group("/v1")
|
||||||
apiV1Route.Use(bindMiddleware(middlewares.JWTAuthorization(config)))
|
apiV1Route.Use(bindMiddleware(middlewares.JWTAuthorization(config)))
|
||||||
|
apiV1Route.Use(bindMiddleware(middlewares.APITokenIpLimit(config)))
|
||||||
{
|
{
|
||||||
// Tokens
|
// Tokens
|
||||||
apiV1Route.GET("/tokens/list.json", bindApi(api.Tokens.TokenListHandler))
|
apiV1Route.GET("/tokens/list.json", bindApi(api.Tokens.TokenListHandler))
|
||||||
@@ -396,7 +397,7 @@ func startWebServer(c *core.CliContext) error {
|
|||||||
apiV1Route.POST("/transactions/delete.json", bindApi(api.Transactions.TransactionDeleteHandler))
|
apiV1Route.POST("/transactions/delete.json", bindApi(api.Transactions.TransactionDeleteHandler))
|
||||||
|
|
||||||
if config.EnableDataImport {
|
if config.EnableDataImport {
|
||||||
apiV1Route.POST("/transactions/parse_dsv_file.json", bindApi(api.Transactions.TransactionParseImportDsvFileDataHandler))
|
apiV1Route.POST("/transactions/parse_custom_file.json", bindApi(api.Transactions.TransactionParseImportCustomFileDataHandler))
|
||||||
apiV1Route.POST("/transactions/parse_import.json", bindApi(api.Transactions.TransactionParseImportFileHandler))
|
apiV1Route.POST("/transactions/parse_import.json", bindApi(api.Transactions.TransactionParseImportFileHandler))
|
||||||
apiV1Route.POST("/transactions/import.json", bindApi(api.Transactions.TransactionImportHandler))
|
apiV1Route.POST("/transactions/import.json", bindApi(api.Transactions.TransactionImportHandler))
|
||||||
apiV1Route.GET("/transactions/import/process.json", bindApi(api.Transactions.TransactionImportProcessHandler))
|
apiV1Route.GET("/transactions/import/process.json", bindApi(api.Transactions.TransactionImportProcessHandler))
|
||||||
|
|||||||
+37
-2
@@ -169,7 +169,7 @@ transaction_from_ai_image_recognition = false
|
|||||||
max_ai_recognition_picture_size = 10485760
|
max_ai_recognition_picture_size = 10485760
|
||||||
|
|
||||||
[llm_image_recognition]
|
[llm_image_recognition]
|
||||||
# Large Language Model (LLM) provider for receipt image recognition, supports the following types: "openai", "openai_compatible", "openrouter", "ollama", "google_ai"
|
# Large Language Model (LLM) provider for receipt image recognition, supports the following types: "openai", "openai_compatible", "anthropic", "anthropic_compatible", "openrouter", "ollama", "lm_studio", "google_ai"
|
||||||
llm_provider =
|
llm_provider =
|
||||||
|
|
||||||
# For "openai" llm provider only, OpenAI API secret key, please visit https://platform.openai.com/api-keys for more information
|
# For "openai" llm provider only, OpenAI API secret key, please visit https://platform.openai.com/api-keys for more information
|
||||||
@@ -187,6 +187,30 @@ openai_compatible_api_key =
|
|||||||
# For "openai_compatible" llm provider only, receipt image recognition model for creating transactions from images
|
# For "openai_compatible" llm provider only, receipt image recognition model for creating transactions from images
|
||||||
openai_compatible_model_id =
|
openai_compatible_model_id =
|
||||||
|
|
||||||
|
# For "anthropic" llm provider only, Anthropic API key, please visit https://platform.claude.com/settings/keys for more information
|
||||||
|
anthropic_api_key =
|
||||||
|
|
||||||
|
# For "anthropic" llm provider only, receipt image recognition model for creating transactions from images
|
||||||
|
anthropic_model_id =
|
||||||
|
|
||||||
|
# For "anthropic" llm provider only, maximum allowed number of generated tokens for creating transactions from images, default is 1024
|
||||||
|
anthropic_max_tokens = 1024
|
||||||
|
|
||||||
|
# For "anthropic_compatible" llm provider only, Anthropic compatible API base url, e.g. "https://api.anthropic.com/v1/"
|
||||||
|
anthropic_compatible_base_url =
|
||||||
|
|
||||||
|
# For "anthropic_compatible" llm provider only, Anthropic compatible API version, e.g. "2023-06-01". If the LLM service does not require API versioning, leave it blank
|
||||||
|
anthropic_compatible_api_version =
|
||||||
|
|
||||||
|
# For "anthropic_compatible" llm provider only, Anthropic compatible API secret key
|
||||||
|
anthropic_compatible_api_key =
|
||||||
|
|
||||||
|
# For "anthropic_compatible" llm provider only, receipt image recognition model for creating transactions from images
|
||||||
|
anthropic_compatible_model_id =
|
||||||
|
|
||||||
|
# For "anthropic_compatible" llm provider only, maximum allowed number of generated tokens for creating transactions from images, default is 1024
|
||||||
|
anthropic_compatible_max_tokens = 1024
|
||||||
|
|
||||||
# For "openrouter" llm provider only, OpenRouter API key, please visit https://openrouter.ai/settings/keys for more information
|
# For "openrouter" llm provider only, OpenRouter API key, please visit https://openrouter.ai/settings/keys for more information
|
||||||
openrouter_api_key =
|
openrouter_api_key =
|
||||||
|
|
||||||
@@ -199,6 +223,15 @@ ollama_server_url =
|
|||||||
# For "ollama" llm provider only, receipt image recognition model for creating transactions from images
|
# For "ollama" llm provider only, receipt image recognition model for creating transactions from images
|
||||||
ollama_model_id =
|
ollama_model_id =
|
||||||
|
|
||||||
|
# For "lm_studio" llm provider only, LM Studio server url, e.g. "http://127.0.0.1:1234/"
|
||||||
|
lm_studio_server_url =
|
||||||
|
|
||||||
|
# For "lm_studio" llm provider only, LM Studio API token, if "require authentication" is not enabled in LM Studio, leave it blank
|
||||||
|
lm_studio_token =
|
||||||
|
|
||||||
|
# For "lm_studio" llm provider only, receipt image recognition model for creating transactions from images
|
||||||
|
lm_studio_model_id =
|
||||||
|
|
||||||
# For "google_ai" llm provider only, Google AI Studio API key, please visit https://aistudio.google.com/apikey for more information
|
# 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 =
|
google_ai_api_key =
|
||||||
|
|
||||||
@@ -263,6 +296,9 @@ password_reset_token_expired_time = 3600
|
|||||||
# Set to true to enable API token generation
|
# Set to true to enable API token generation
|
||||||
enable_api_token = false
|
enable_api_token = false
|
||||||
|
|
||||||
|
# Allowed remote IPs for using the API token, a comma-separated list of allowed remote IPs (asterisk * for any addresses, e.g. 192.168.1.* means any IPs in the 192.168.1.x subnet), leave blank to allow all remote IPs
|
||||||
|
api_token_allowed_remote_ips =
|
||||||
|
|
||||||
# Maximum count of password / token check failures (0 - 4294967295) per IP per minute (use the above duplicate checker), default is 5, set to 0 to disable
|
# Maximum count of password / token check failures (0 - 4294967295) per IP per minute (use the above duplicate checker), default is 5, set to 0 to disable
|
||||||
max_failures_per_ip_per_minute = 5
|
max_failures_per_ip_per_minute = 5
|
||||||
|
|
||||||
@@ -493,7 +529,6 @@ custom_map_tile_server_default_zoom_level = 14
|
|||||||
|
|
||||||
[exchange_rates]
|
[exchange_rates]
|
||||||
# Exchange rates data source, supports the following types:
|
# Exchange rates data source, supports the following types:
|
||||||
# "reserve_bank_of_australia": https://www.rba.gov.au/statistics/frequency/exchange-rates.html
|
|
||||||
# "bank_of_canada": https://www.bankofcanada.ca/rates/exchange/daily-exchange-rates/
|
# "bank_of_canada": https://www.bankofcanada.ca/rates/exchange/daily-exchange-rates/
|
||||||
# "czech_national_bank": https://www.cnb.cz/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing/
|
# "czech_national_bank": https://www.cnb.cz/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing/
|
||||||
# "danmarks_national_bank": https://www.nationalbanken.dk/en/what-we-do/stable-prices-monetary-policy-and-the-danish-economy/exchange-rates
|
# "danmarks_national_bank": https://www.nationalbanken.dk/en/what-we-do/stable-prices-monetary-policy-and-the-danish-economy/exchange-rates
|
||||||
|
|||||||
+8
-4
@@ -9,11 +9,13 @@
|
|||||||
"lvdou-bing",
|
"lvdou-bing",
|
||||||
"dshemin",
|
"dshemin",
|
||||||
"lucdsouza",
|
"lucdsouza",
|
||||||
"OuIChien"
|
"OuIChien",
|
||||||
|
"RasterCrow"
|
||||||
],
|
],
|
||||||
"translators": {
|
"translators": {
|
||||||
"de": [
|
"de": [
|
||||||
"chrgm"
|
"chrgm",
|
||||||
|
"1270o1"
|
||||||
],
|
],
|
||||||
"en": [],
|
"en": [],
|
||||||
"es": [
|
"es": [
|
||||||
@@ -41,10 +43,12 @@
|
|||||||
"automagics"
|
"automagics"
|
||||||
],
|
],
|
||||||
"pt-BR": [
|
"pt-BR": [
|
||||||
"thecodergus"
|
"thecodergus",
|
||||||
|
"balaios"
|
||||||
],
|
],
|
||||||
"ru": [
|
"ru": [
|
||||||
"artegoser"
|
"artegoser",
|
||||||
|
"dshemin"
|
||||||
],
|
],
|
||||||
"sl": [
|
"sl": [
|
||||||
"thehijacker"
|
"thehijacker"
|
||||||
|
|||||||
+4
-4
@@ -10,7 +10,7 @@ import (
|
|||||||
"github.com/urfave/cli/v3"
|
"github.com/urfave/cli/v3"
|
||||||
|
|
||||||
"github.com/mayswind/ezbookkeeping/cmd"
|
"github.com/mayswind/ezbookkeeping/cmd"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -26,9 +26,9 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
settings.Version = Version
|
core.Version = Version
|
||||||
settings.CommitHash = CommitHash
|
core.CommitHash = CommitHash
|
||||||
settings.BuildTime = BuildUnixTime
|
core.BuildTime = BuildUnixTime
|
||||||
|
|
||||||
cmd := &cli.Command{
|
cmd := &cli.Command{
|
||||||
Name: "ezBookkeeping",
|
Name: "ezBookkeeping",
|
||||||
|
|||||||
@@ -1,34 +1,34 @@
|
|||||||
module github.com/mayswind/ezbookkeeping
|
module github.com/mayswind/ezbookkeeping
|
||||||
|
|
||||||
go 1.25
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/boombuler/barcode v1.1.0
|
github.com/boombuler/barcode v1.1.0
|
||||||
github.com/coreos/go-oidc/v3 v3.17.0
|
github.com/coreos/go-oidc/v3 v3.17.0
|
||||||
github.com/extrame/xls v0.0.2-0.20200426124601-4a6cf263071b
|
github.com/extrame/xls v0.0.2-0.20200426124601-4a6cf263071b
|
||||||
github.com/gin-contrib/cache v1.4.1
|
github.com/gin-contrib/cache v1.4.3
|
||||||
github.com/gin-contrib/gzip v1.2.5
|
github.com/gin-contrib/gzip v1.2.6
|
||||||
github.com/gin-gonic/gin v1.11.0
|
github.com/gin-gonic/gin v1.12.0
|
||||||
github.com/go-co-op/gocron/v2 v2.18.2
|
github.com/go-co-op/gocron/v2 v2.19.1
|
||||||
github.com/go-playground/validator/v10 v10.28.0
|
github.com/go-playground/validator/v10 v10.30.2
|
||||||
github.com/go-sql-driver/mysql v1.9.3
|
github.com/go-sql-driver/mysql v1.9.3
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||||
github.com/invopop/jsonschema v0.13.0
|
github.com/invopop/jsonschema v0.13.0
|
||||||
github.com/lib/pq v1.10.9
|
github.com/lib/pq v1.12.1
|
||||||
github.com/mattn/go-sqlite3 v1.14.32
|
github.com/mattn/go-sqlite3 v1.14.38
|
||||||
github.com/minio/minio-go/v7 v7.0.97
|
github.com/minio/minio-go/v7 v7.0.99
|
||||||
github.com/patrickmn/go-cache v2.1.0+incompatible
|
github.com/patrickmn/go-cache v2.1.0+incompatible
|
||||||
github.com/pquerna/otp v1.5.0
|
github.com/pquerna/otp v1.5.0
|
||||||
github.com/sirupsen/logrus v1.9.3
|
github.com/sirupsen/logrus v1.9.4
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
github.com/urfave/cli/v3 v3.6.1
|
github.com/urfave/cli/v3 v3.8.0
|
||||||
github.com/wk8/go-ordered-map/v2 v2.1.8
|
github.com/wk8/go-ordered-map/v2 v2.1.8
|
||||||
github.com/xuri/excelize/v2 v2.10.0
|
github.com/xuri/excelize/v2 v2.10.1
|
||||||
golang.org/x/crypto v0.46.0
|
golang.org/x/crypto v0.49.0
|
||||||
golang.org/x/net v0.48.0
|
golang.org/x/net v0.52.0
|
||||||
golang.org/x/oauth2 v0.34.0
|
golang.org/x/oauth2 v0.36.0
|
||||||
golang.org/x/text v0.32.0
|
golang.org/x/text v0.35.0
|
||||||
gopkg.in/ini.v1 v1.67.0
|
gopkg.in/ini.v1 v1.67.1
|
||||||
gopkg.in/mail.v2 v2.3.1
|
gopkg.in/mail.v2 v2.3.1
|
||||||
xorm.io/builder v0.3.13
|
xorm.io/builder v0.3.13
|
||||||
xorm.io/xorm v1.3.11
|
xorm.io/xorm v1.3.11
|
||||||
@@ -40,8 +40,8 @@ require (
|
|||||||
github.com/bradfitz/gomemcache v0.0.0-20250403215159-8d39553ac7cf // indirect
|
github.com/bradfitz/gomemcache v0.0.0-20250403215159-8d39553ac7cf // indirect
|
||||||
github.com/buger/jsonparser v1.1.1 // indirect
|
github.com/buger/jsonparser v1.1.1 // indirect
|
||||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||||
github.com/bytedance/sonic v1.14.1 // indirect
|
github.com/bytedance/sonic v1.15.0 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect
|
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect
|
||||||
github.com/chenzhuoyu/iasm v0.9.1 // indirect
|
github.com/chenzhuoyu/iasm v0.9.1 // indirect
|
||||||
@@ -52,27 +52,27 @@ require (
|
|||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/extrame/goyymmdd v0.0.0-20210114090516-7cc815f00d1a // indirect
|
github.com/extrame/goyymmdd v0.0.0-20210114090516-7cc815f00d1a // indirect
|
||||||
github.com/extrame/ole2 v0.0.0-20160812065207-d69429661ad7 // indirect
|
github.com/extrame/ole2 v0.0.0-20160812065207-d69429661ad7 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.10 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
||||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||||
github.com/go-ini/ini v1.67.0 // indirect
|
github.com/go-ini/ini v1.67.0 // indirect
|
||||||
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
|
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
|
||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/goccy/go-json v0.10.5 // indirect
|
github.com/goccy/go-json v0.10.5 // indirect
|
||||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||||
github.com/golang/snappy v0.0.4 // indirect
|
github.com/golang/snappy v0.0.4 // indirect
|
||||||
github.com/gomodule/redigo v1.9.2 // indirect
|
github.com/gomodule/redigo v1.9.2 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/jonboulle/clockwork v0.5.0 // indirect
|
github.com/jonboulle/clockwork v0.5.0 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/klauspost/compress v1.18.0 // indirect
|
github.com/klauspost/compress v1.18.2 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
github.com/klauspost/crc32 v1.3.0 // indirect
|
github.com/klauspost/crc32 v1.3.0 // indirect
|
||||||
github.com/leodido/go-urn v1.4.0 // indirect
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
github.com/mailru/easyjson v0.7.7 // indirect
|
github.com/mailru/easyjson v0.7.7 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/memcachier/mc/v3 v3.0.3 // indirect
|
github.com/memcachier/mc/v3 v3.0.3 // indirect
|
||||||
github.com/minio/crc64nvme v1.1.0 // indirect
|
github.com/minio/crc64nvme v1.1.1 // indirect
|
||||||
github.com/minio/md5-simd v1.1.2 // indirect
|
github.com/minio/md5-simd v1.1.2 // indirect
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
@@ -80,29 +80,31 @@ require (
|
|||||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
github.com/philhofer/fwd v1.2.0 // indirect
|
github.com/philhofer/fwd v1.2.0 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/quic-go/qpack v0.5.1 // indirect
|
github.com/quic-go/qpack v0.6.0 // indirect
|
||||||
github.com/quic-go/quic-go v0.55.0 // indirect
|
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||||
github.com/richardlehane/mscfb v1.0.4 // indirect
|
github.com/richardlehane/mscfb v1.0.6 // indirect
|
||||||
github.com/richardlehane/msoleps v1.0.4 // indirect
|
github.com/richardlehane/msoleps v1.0.6 // indirect
|
||||||
github.com/robfig/cron/v3 v3.0.1 // indirect
|
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||||
github.com/robfig/go-cache v0.0.0-20130306151617-9fc39e0dbf62 // indirect
|
github.com/robfig/go-cache v0.0.0-20130306151617-9fc39e0dbf62 // indirect
|
||||||
github.com/rs/xid v1.6.0 // indirect
|
github.com/rs/xid v1.6.0 // indirect
|
||||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||||
github.com/syndtr/goleveldb v1.0.0 // indirect
|
github.com/syndtr/goleveldb v1.0.0 // indirect
|
||||||
github.com/tealeg/xlsx v1.0.5 // indirect
|
github.com/tealeg/xlsx v1.0.5 // indirect
|
||||||
github.com/tiendc/go-deepcopy v1.7.1 // indirect
|
github.com/tiendc/go-deepcopy v1.7.2 // indirect
|
||||||
github.com/tinylib/msgp v1.3.0 // indirect
|
github.com/tinylib/msgp v1.6.1 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
|
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
|
||||||
github.com/xuri/efp v0.0.1 // indirect
|
github.com/xuri/efp v0.0.1 // indirect
|
||||||
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
|
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
|
||||||
|
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
golang.org/x/arch v0.22.0 // indirect
|
golang.org/x/arch v0.22.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 // indirect
|
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 // indirect
|
||||||
golang.org/x/mod v0.30.0 // indirect
|
golang.org/x/mod v0.33.0 // indirect
|
||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
golang.org/x/sys v0.39.0 // indirect
|
golang.org/x/sys v0.42.0 // indirect
|
||||||
golang.org/x/tools v0.39.0 // indirect
|
golang.org/x/tools v0.42.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.10 // indirect
|
google.golang.org/protobuf v1.36.10 // indirect
|
||||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
|
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
|||||||
@@ -14,10 +14,10 @@ github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M
|
|||||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||||
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
|
github.com/bytedance/sonic v1.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.10.0-rc/go.mod h1:ElCzW+ufi8qKqNW0FY314xriJhyJhuoJ3gFZdAHF7NM=
|
||||||
github.com/bytedance/sonic v1.14.1 h1:FBMC0zVz5XUmE4z9wF4Jey0An5FueFvOsTKKKtwIl7w=
|
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||||
github.com/bytedance/sonic v1.14.1/go.mod h1:gi6uhQLMbTdeP0muCnrjHLeCUPyb70ujhnNlhOylAFc=
|
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||||
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
|
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||||
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
|
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
|
||||||
@@ -43,18 +43,18 @@ github.com/extrame/ole2 v0.0.0-20160812065207-d69429661ad7/go.mod h1:GPpMrAfHdb8
|
|||||||
github.com/extrame/xls v0.0.2-0.20200426124601-4a6cf263071b h1:jqW/h4gcXYEB6kVf6iuxjU9ONWA0ugUB94TP9UNmgdg=
|
github.com/extrame/xls v0.0.2-0.20200426124601-4a6cf263071b h1:jqW/h4gcXYEB6kVf6iuxjU9ONWA0ugUB94TP9UNmgdg=
|
||||||
github.com/extrame/xls v0.0.2-0.20200426124601-4a6cf263071b/go.mod h1:iACcgahst7BboCpIMSpnFs4SKyU9ZjsvZBfNbUxZOJI=
|
github.com/extrame/xls v0.0.2-0.20200426124601-4a6cf263071b/go.mod h1:iACcgahst7BboCpIMSpnFs4SKyU9ZjsvZBfNbUxZOJI=
|
||||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0=
|
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||||
github.com/gin-contrib/cache v1.4.1 h1:HcLwLfw7p+FasNp5VAnFbbBj9SzB4bDtswvon7wYSg4=
|
github.com/gin-contrib/cache v1.4.3 h1:6rmIlmTf2Vyfd/ue53+BLdTxC7hrQ7FqRgfjz31nEEE=
|
||||||
github.com/gin-contrib/cache v1.4.1/go.mod h1:tykDV+FgItJHYEO0eCasuRYsZKPPyb4BYhAjuTlG6RM=
|
github.com/gin-contrib/cache v1.4.3/go.mod h1:Znf5Qa8HTQ+QHku6ODf72WOPnJ2fHUd2nXD6mSi+6+g=
|
||||||
github.com/gin-contrib/gzip v1.2.5 h1:fIZs0S+l17pIu1P5XRJOo/YNqfIuPCrZZ3TWB7pjckI=
|
github.com/gin-contrib/gzip v1.2.6 h1:OtN8DplD5DNZCSLAnQ5HxRkD2qZ5VU+JhOrcfJrcRvg=
|
||||||
github.com/gin-contrib/gzip v1.2.5/go.mod h1:aomRgR7ftdZV3uWY0gW/m8rChfxau0n8YVvwlOHONzw=
|
github.com/gin-contrib/gzip v1.2.6/go.mod h1:BQy8/+JApnRjAVUplSGZiVtD2k8GmIE2e9rYu/hLzzU=
|
||||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||||
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||||
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||||
github.com/go-co-op/gocron/v2 v2.18.2 h1:+5VU41FUXPWSPKLXZQ/77SGzUiPCcakU0v7ENc2H20Q=
|
github.com/go-co-op/gocron/v2 v2.19.1 h1:B4iLeA0NB/2iO3EKQ7NfKn5KsQgZfjb2fkvoZJU3yBI=
|
||||||
github.com/go-co-op/gocron/v2 v2.18.2/go.mod h1:Zii6he+Zfgy5W9B+JKk/KwejFOW0kZTFvHtwIpR4aBI=
|
github.com/go-co-op/gocron/v2 v2.19.1/go.mod h1:5lEiCKk1oVJV39Zg7/YG10OnaVrDAV5GGR6O0663k6U=
|
||||||
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
|
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-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
|
||||||
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
|
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
|
||||||
@@ -63,16 +63,16 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
|
|||||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
github.com/go-playground/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 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||||
github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
|
github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ=
|
||||||
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
|
github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc=
|
||||||
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
|
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
|
||||||
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
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 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
github.com/golang/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.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||||
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
|
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
|
||||||
@@ -90,8 +90,8 @@ github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7X
|
|||||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
|
||||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||||
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
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.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||||
@@ -104,22 +104,22 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
|||||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
github.com/lib/pq v1.12.1 h1:x1nbl/338GLqeDJ/FAiILallhAsqubLzEZu/pXtHUow=
|
||||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
github.com/lib/pq v1.12.1/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
|
||||||
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
|
github.com/mattn/go-sqlite3 v1.14.38 h1:tDUzL85kMvOrvpCt8P64SbGgVFtJB11GPi2AdmITgb4=
|
||||||
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
github.com/mattn/go-sqlite3 v1.14.38/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 h1:qii+lDiPKi36O4Xg+HVKwHu6Oq+Gt17b+uEiA0Drwv4=
|
||||||
github.com/memcachier/mc/v3 v3.0.3/go.mod h1:GzjocBahcXPxt2cmqzknrgqCOmMxiSzhVKPOe90Tpug=
|
github.com/memcachier/mc/v3 v3.0.3/go.mod h1:GzjocBahcXPxt2cmqzknrgqCOmMxiSzhVKPOe90Tpug=
|
||||||
github.com/minio/crc64nvme v1.1.0 h1:e/tAguZ+4cw32D+IO/8GSf5UVr9y+3eJcxZI2WOO/7Q=
|
github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI=
|
||||||
github.com/minio/crc64nvme v1.1.0/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
|
github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
|
||||||
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
|
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
|
||||||
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
|
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
|
||||||
github.com/minio/minio-go/v7 v7.0.97 h1:lqhREPyfgHTB/ciX8k2r8k0D93WaFqxbJX36UZq5occ=
|
github.com/minio/minio-go/v7 v7.0.99 h1:2vH/byrwUkIpFQFOilvTfaUpvAX3fEFhEzO+DR3DlCE=
|
||||||
github.com/minio/minio-go/v7 v7.0.97/go.mod h1:re5VXuo0pwEtoNLsNuSr0RrLfT/MBtohwdaSmPPSRSk=
|
github.com/minio/minio-go/v7 v7.0.99/go.mod h1:EtGNKtlX20iL2yaYnxEigaIvj0G0GwSDnifnG8ClIdw=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-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 h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
@@ -139,15 +139,14 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
|||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
|
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
|
||||||
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
||||||
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||||
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||||
github.com/quic-go/quic-go v0.55.0 h1:zccPQIqYCXDt5NmcEabyYvOnomjs8Tlwl7tISjJh9Mk=
|
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||||
github.com/quic-go/quic-go v0.55.0/go.mod h1:DR51ilwU1uE164KuWXhinFcKWGlEjzys2l8zUl5Ss1U=
|
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||||
github.com/richardlehane/mscfb v1.0.4 h1:WULscsljNPConisD5hR0+OyZjwK46Pfyr6mPu5ZawpM=
|
github.com/richardlehane/mscfb v1.0.6 h1:eN3bvvZCp00bs7Zf52bxNwAx5lJDBK1tCuH19qq5aC8=
|
||||||
github.com/richardlehane/mscfb v1.0.4/go.mod h1:YzVpcZg9czvAuhk9T+a3avCpcFPMUWm7gK3DypaEsUk=
|
github.com/richardlehane/mscfb v1.0.6/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo=
|
||||||
github.com/richardlehane/msoleps v1.0.1/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
|
github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg=
|
||||||
github.com/richardlehane/msoleps v1.0.4 h1:WuESlvhX3gH2IHcd8UqyCuFY5yiq/GR/yqaSM/9/g00=
|
github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
|
||||||
github.com/richardlehane/msoleps v1.0.4/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
|
|
||||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||||
github.com/robfig/go-cache v0.0.0-20130306151617-9fc39e0dbf62 h1:pyecQtsPmlkCsMkYhT5iZ+sUXuwee+OvfuJjinEA3ko=
|
github.com/robfig/go-cache v0.0.0-20130306151617-9fc39e0dbf62 h1:pyecQtsPmlkCsMkYhT5iZ+sUXuwee+OvfuJjinEA3ko=
|
||||||
@@ -155,67 +154,70 @@ github.com/robfig/go-cache v0.0.0-20130306151617-9fc39e0dbf62/go.mod h1:65XQgovT
|
|||||||
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
||||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
||||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.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.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
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 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE=
|
||||||
github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ=
|
github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ=
|
||||||
github.com/tealeg/xlsx v1.0.5 h1:+f8oFmvY8Gw1iUXzPk+kz+4GpbDZPK1FhPiQRd+ypgE=
|
github.com/tealeg/xlsx v1.0.5 h1:+f8oFmvY8Gw1iUXzPk+kz+4GpbDZPK1FhPiQRd+ypgE=
|
||||||
github.com/tealeg/xlsx v1.0.5/go.mod h1:btRS8dz54TDnvKNosuAqxrM1QgN1udgk9O34bDCnORM=
|
github.com/tealeg/xlsx v1.0.5/go.mod h1:btRS8dz54TDnvKNosuAqxrM1QgN1udgk9O34bDCnORM=
|
||||||
github.com/tiendc/go-deepcopy v1.7.1 h1:LnubftI6nYaaMOcaz0LphzwraqN8jiWTwm416sitff4=
|
github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44=
|
||||||
github.com/tiendc/go-deepcopy v1.7.1/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=
|
github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=
|
||||||
github.com/tinylib/msgp v1.3.0 h1:ULuf7GPooDaIlbyvgAxBV/FI7ynli6LZ1/nVUNu+0ww=
|
github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY=
|
||||||
github.com/tinylib/msgp v1.3.0/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0=
|
github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||||
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||||
github.com/urfave/cli/v3 v3.6.1 h1:j8Qq8NyUawj/7rTYdBGrxcH7A/j7/G8Q5LhWEW4G3Mo=
|
github.com/urfave/cli/v3 v3.8.0 h1:XqKPrm0q4P0q5JpoclYoCAv0/MIvH/jZ2umzuf8pNTI=
|
||||||
github.com/urfave/cli/v3 v3.6.1/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso=
|
github.com/urfave/cli/v3 v3.8.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso=
|
||||||
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
|
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
|
||||||
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
|
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
|
||||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
|
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
|
||||||
github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
|
github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
|
||||||
github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
|
github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
|
||||||
github.com/xuri/excelize/v2 v2.10.0 h1:8aKsP7JD39iKLc6dH5Tw3dgV3sPRh8uRVXu/fMstfW4=
|
github.com/xuri/excelize/v2 v2.10.1 h1:V62UlqopMqha3kOpnlHy2CcRVw1V8E63jFoWUmMzxN0=
|
||||||
github.com/xuri/excelize/v2 v2.10.0/go.mod h1:SC5TzhQkaOsTWpANfm+7bJCldzcnU/jrhqkTi/iBHBU=
|
github.com/xuri/excelize/v2 v2.10.1/go.mod h1:iG5tARpgaEeIhTqt3/fgXCGoBRt4hNXgCp3tfXKoOIc=
|
||||||
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=
|
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=
|
||||||
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
|
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
|
||||||
|
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||||
|
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||||
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
||||||
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||||
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||||
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||||
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI=
|
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI=
|
||||||
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
|
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||||
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
|
|
||||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||||
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||||
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
|
||||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||||
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||||
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
|
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||||
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
|
|
||||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
|
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
|
||||||
@@ -223,8 +225,8 @@ gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod
|
|||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||||
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
gopkg.in/ini.v1 v1.67.1 h1:tVBILHy0R6e4wkYOn3XmiITt/hEVH4TFMYvAX2Ytz6k=
|
||||||
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
gopkg.in/ini.v1 v1.67.1/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss=
|
||||||
gopkg.in/mail.v2 v2.3.1 h1:WYFn/oANrAGP2C0dcV6/pbkPzv8yGzqTjPmTeO7qoXk=
|
gopkg.in/mail.v2 v2.3.1 h1:WYFn/oANrAGP2C0dcV6/pbkPzv8yGzqTjPmTeO7qoXk=
|
||||||
gopkg.in/mail.v2 v2.3.1/go.mod h1:htwXN1Qh09vZJ1NVKxQqHPBaCBbzKhp5GzuJEA4VJWw=
|
gopkg.in/mail.v2 v2.3.1/go.mod h1:htwXN1Qh09vZJ1NVKxQqHPBaCBbzKhp5GzuJEA4VJWw=
|
||||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||||
|
|||||||
Generated
+2621
-2382
File diff suppressed because it is too large
Load Diff
+57
-57
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ezbookkeeping",
|
"name": "ezbookkeeping",
|
||||||
"version": "1.3.2",
|
"version": "1.5.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
@@ -19,64 +19,64 @@
|
|||||||
"test": "cross-env TS_NODE_PROJECT=\"./tsconfig.jest.json\" jest"
|
"test": "cross-env TS_NODE_PROJECT=\"./tsconfig.jest.json\" jest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mdi/js": "^7.4.47",
|
"@mdi/js": "7.4.47",
|
||||||
"@vuepic/vue-datepicker": "^12.1.0",
|
"@vuepic/vue-datepicker": "12.1.0",
|
||||||
"axios": "^1.13.2",
|
"axios": "1.14.0",
|
||||||
"cbor-js": "^0.1.0",
|
"cbor-js": "0.1.0",
|
||||||
"chardet": "^2.1.1",
|
"chardet": "2.1.1",
|
||||||
"clipboard": "^2.0.11",
|
"clipboard": "2.0.11",
|
||||||
"crypto-js": "^4.2.0",
|
"crypto-js": "4.2.0",
|
||||||
"dom7": "^4.0.6",
|
"dom7": "4.0.6",
|
||||||
"echarts": "^6.0.0",
|
"echarts": "6.0.0",
|
||||||
"framework7": "^9.0.2",
|
"framework7": "9.0.3",
|
||||||
"framework7-icons": "^5.0.5",
|
"framework7-icons": "5.0.5",
|
||||||
"framework7-vue": "^9.0.2",
|
"framework7-vue": "9.0.3",
|
||||||
"jalaali-js": "^1.2.8",
|
"jalaali-js": "1.2.8",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "1.9.4",
|
||||||
"line-awesome": "^1.3.0",
|
"line-awesome": "1.3.0",
|
||||||
"moment": "^2.30.1",
|
"moment": "2.30.1",
|
||||||
"moment-timezone": "^0.6.0",
|
"moment-timezone": "0.6.1",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "3.0.4",
|
||||||
"register-service-worker": "^1.7.2",
|
"register-service-worker": "1.7.2",
|
||||||
"skeleton-elements": "^4.0.1",
|
"skeleton-elements": "4.0.1",
|
||||||
"swiper": "^12.0.3",
|
"swiper": "12.1.3",
|
||||||
"ua-parser-js": "^1.0.39",
|
"ua-parser-js": "1.0.39",
|
||||||
"vue": "^3.5.25",
|
"vue": "3.5.31",
|
||||||
"vue-echarts": "^8.0.1",
|
"vue-echarts": "8.0.1",
|
||||||
"vue-i18n": "^11.2.2",
|
"vue-i18n": "11.3.0",
|
||||||
"vue-router": "^4.6.4",
|
"vue-router": "5.0.4",
|
||||||
"vue3-perfect-scrollbar": "^2.0.0",
|
"vue3-perfect-scrollbar": "2.0.0",
|
||||||
"vuedraggable": "^4.1.0",
|
"vuedraggable": "4.1.0",
|
||||||
"vuetify": "^3.11.3"
|
"vuetify": "3.12.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@jest/globals": "^30.2.0",
|
"@jest/globals": "30.3.0",
|
||||||
"@tsconfig/node24": "^24.0.3",
|
"@tsconfig/node24": "24.0.4",
|
||||||
"@types/cbor-js": "^0.1.1",
|
"@types/cbor-js": "0.1.1",
|
||||||
"@types/crypto-js": "^4.2.2",
|
"@types/crypto-js": "4.2.2",
|
||||||
"@types/git-rev-sync": "^2.0.2",
|
"@types/git-rev-sync": "2.0.2",
|
||||||
"@types/jalaali-js": "^1.2.0",
|
"@types/jalaali-js": "1.2.0",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "30.0.0",
|
||||||
"@types/node": "^24.1.0",
|
"@types/node": "25.5.0",
|
||||||
"@types/ua-parser-js": "^0.7.39",
|
"@types/ua-parser-js": "0.7.39",
|
||||||
"@vitejs/plugin-vue": "^6.0.3",
|
"@vitejs/plugin-vue": "6.0.5",
|
||||||
"@vue/eslint-config-typescript": "^14.6.0",
|
"@vue/eslint-config-typescript": "14.7.0",
|
||||||
"@vue/tsconfig": "^0.8.1",
|
"@vue/tsconfig": "0.9.1",
|
||||||
"cross-env": "^10.1.0",
|
"cross-env": "10.1.0",
|
||||||
"eslint": "^9.39.1",
|
"eslint": "10.1.0",
|
||||||
"eslint-plugin-vue": "^10.6.2",
|
"eslint-plugin-vue": "10.8.0",
|
||||||
"git-rev-sync": "^3.0.2",
|
"git-rev-sync": "3.0.2",
|
||||||
"jest": "^30.2.0",
|
"jest": "30.3.0",
|
||||||
"postcss-preset-env": "^10.5.0",
|
"postcss-preset-env": "11.2.0",
|
||||||
"sass": "^1.96.0",
|
"sass": "1.98.0",
|
||||||
"ts-jest": "^29.4.6",
|
"ts-jest": "29.4.6",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "10.9.2",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "5.9.3",
|
||||||
"vite": "^7.2.7",
|
"vite": "7.3.1",
|
||||||
"vite-plugin-checker": "^0.12.0",
|
"vite-plugin-checker": "0.12.0",
|
||||||
"vite-plugin-pwa": "^1.2.0",
|
"vite-plugin-pwa": "1.2.0",
|
||||||
"vite-plugin-vuetify": "^2.1.2",
|
"vite-plugin-vuetify": "2.1.3",
|
||||||
"vue-tsc": "^3.1.8"
|
"vue-tsc": "3.2.6"
|
||||||
},
|
},
|
||||||
"browserslist": [
|
"browserslist": [
|
||||||
"last 5 Chrome versions",
|
"last 5 Chrome versions",
|
||||||
|
|||||||
@@ -713,6 +713,9 @@ func (a *AccountsApi) createNewAccountModel(uid int64, accountCreateReq *models.
|
|||||||
|
|
||||||
if !isSubAccount && accountCreateReq.Category == models.ACCOUNT_CATEGORY_CREDIT_CARD {
|
if !isSubAccount && accountCreateReq.Category == models.ACCOUNT_CATEGORY_CREDIT_CARD {
|
||||||
accountExtend.CreditCardStatementDate = &accountCreateReq.CreditCardStatementDate
|
accountExtend.CreditCardStatementDate = &accountCreateReq.CreditCardStatementDate
|
||||||
|
if accountCreateReq.CreditLimit > 0 {
|
||||||
|
accountExtend.CreditLimit = &accountCreateReq.CreditLimit
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return &models.Account{
|
return &models.Account{
|
||||||
@@ -769,6 +772,9 @@ func (a *AccountsApi) getToUpdateAccount(uid int64, accountModifyReq *models.Acc
|
|||||||
|
|
||||||
if !isSubAccount && accountModifyReq.Category == models.ACCOUNT_CATEGORY_CREDIT_CARD {
|
if !isSubAccount && accountModifyReq.Category == models.ACCOUNT_CATEGORY_CREDIT_CARD {
|
||||||
newAccountExtend.CreditCardStatementDate = &accountModifyReq.CreditCardStatementDate
|
newAccountExtend.CreditCardStatementDate = &accountModifyReq.CreditCardStatementDate
|
||||||
|
if accountModifyReq.CreditLimit > 0 {
|
||||||
|
newAccountExtend.CreditLimit = &accountModifyReq.CreditLimit
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
newAccount := &models.Account{
|
newAccount := &models.Account{
|
||||||
@@ -804,6 +810,12 @@ func (a *AccountsApi) getToUpdateAccount(uid int64, accountModifyReq *models.Acc
|
|||||||
return newAccount
|
return newAccount
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if newAccountExtend.CreditLimit != oldAccountExtend.CreditLimit {
|
||||||
|
if newAccountExtend.CreditLimit == nil || oldAccountExtend.CreditLimit == nil || *newAccountExtend.CreditLimit != *oldAccountExtend.CreditLimit {
|
||||||
|
return newAccount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-3
@@ -3,7 +3,6 @@ package api
|
|||||||
import (
|
import (
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// HealthsApi represents health api
|
// HealthsApi represents health api
|
||||||
@@ -18,8 +17,8 @@ var (
|
|||||||
func (a *HealthsApi) HealthStatusHandler(c *core.WebContext) (any, *errs.Error) {
|
func (a *HealthsApi) HealthStatusHandler(c *core.WebContext) (any, *errs.Error) {
|
||||||
result := make(map[string]string)
|
result := make(map[string]string)
|
||||||
|
|
||||||
result["version"] = settings.Version
|
result["version"] = core.Version
|
||||||
result["commit"] = settings.CommitHash
|
result["commit"] = core.CommitHash
|
||||||
result["status"] = "ok"
|
result["status"] = "ok"
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ func (a *ModelContextProtocolAPI) InitializeHandler(c *core.WebContext, jsonRPCR
|
|||||||
ServerInfo: &mcp.MCPImplementation{
|
ServerInfo: &mcp.MCPImplementation{
|
||||||
Name: mcpServerName,
|
Name: mcpServerName,
|
||||||
Title: core.ApplicationName,
|
Title: core.ApplicationName,
|
||||||
Version: settings.Version,
|
Version: core.Version,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
-5
@@ -3,7 +3,6 @@ package api
|
|||||||
import (
|
import (
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// SystemsApi represents system api
|
// SystemsApi represents system api
|
||||||
@@ -18,11 +17,11 @@ var (
|
|||||||
func (a *SystemsApi) VersionHandler(c *core.WebContext) (any, *errs.Error) {
|
func (a *SystemsApi) VersionHandler(c *core.WebContext) (any, *errs.Error) {
|
||||||
result := make(map[string]string)
|
result := make(map[string]string)
|
||||||
|
|
||||||
result["version"] = settings.Version
|
result["version"] = core.Version
|
||||||
result["commitHash"] = settings.CommitHash
|
result["commitHash"] = core.CommitHash
|
||||||
|
|
||||||
if settings.BuildTime != "" {
|
if core.BuildTime != "" {
|
||||||
result["buildTime"] = settings.BuildTime
|
result["buildTime"] = core.BuildTime
|
||||||
}
|
}
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
|
|||||||
+4
-4
@@ -69,10 +69,10 @@ func (a *TokensApi) TokenListHandler(c *core.WebContext) (any, *errs.Error) {
|
|||||||
tokenResp.IsCurrent = true
|
tokenResp.IsCurrent = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if token.TokenType == core.USER_TOKEN_TYPE_API && token.UserAgent != services.TokenUserAgentCreatedViaCli {
|
if token.TokenType == core.USER_TOKEN_TYPE_API && token.UserAgent != core.TokenUserAgentCreatedViaCli {
|
||||||
tokenResp.UserAgent = services.TokenUserAgentForAPI
|
tokenResp.UserAgent = core.TokenUserAgentForAPI
|
||||||
} else if token.TokenType == core.USER_TOKEN_TYPE_MCP && token.UserAgent != services.TokenUserAgentCreatedViaCli {
|
} else if token.TokenType == core.USER_TOKEN_TYPE_MCP && token.UserAgent != core.TokenUserAgentCreatedViaCli {
|
||||||
tokenResp.UserAgent = services.TokenUserAgentForMCP
|
tokenResp.UserAgent = core.TokenUserAgentForMCP
|
||||||
}
|
}
|
||||||
|
|
||||||
tokenResps[i] = tokenResp
|
tokenResps[i] = tokenResp
|
||||||
|
|||||||
@@ -12,13 +12,15 @@ import (
|
|||||||
|
|
||||||
// TransactionTagsApi represents transaction tag api
|
// TransactionTagsApi represents transaction tag api
|
||||||
type TransactionTagsApi struct {
|
type TransactionTagsApi struct {
|
||||||
tags *services.TransactionTagService
|
tags *services.TransactionTagService
|
||||||
|
tagGroups *services.TransactionTagGroupService
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize a transaction tag api singleton instance
|
// Initialize a transaction tag api singleton instance
|
||||||
var (
|
var (
|
||||||
TransactionTags = &TransactionTagsApi{
|
TransactionTags = &TransactionTagsApi{
|
||||||
tags: services.TransactionTags,
|
tags: services.TransactionTags,
|
||||||
|
tagGroups: services.TransactionTagGroups,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -78,6 +80,20 @@ func (a *TransactionTagsApi) TagCreateHandler(c *core.WebContext) (any, *errs.Er
|
|||||||
|
|
||||||
uid := c.GetCurrentUid()
|
uid := c.GetCurrentUid()
|
||||||
|
|
||||||
|
if tagCreateReq.GroupId > 0 {
|
||||||
|
tagGroup, err := a.tagGroups.GetTagGroupByTagGroupId(c, uid, tagCreateReq.GroupId)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf(c, "[transaction_tags.TagCreateHandler] failed to get tag group \"id:%d\" for user \"uid:%d\", because %s", tagCreateReq.GroupId, uid, err.Error())
|
||||||
|
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tagGroup == nil {
|
||||||
|
log.Warnf(c, "[transaction_tags.TagCreateHandler] the tag group \"id:%d\" does not exist for user \"uid:%d\"", tagCreateReq.GroupId, uid)
|
||||||
|
return nil, errs.ErrTransactionTagGroupNotFound
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
maxOrderId, err := a.tags.GetMaxDisplayOrder(c, uid, tagCreateReq.GroupId)
|
maxOrderId, err := a.tags.GetMaxDisplayOrder(c, uid, tagCreateReq.GroupId)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -120,6 +136,20 @@ func (a *TransactionTagsApi) TagCreateBatchHandler(c *core.WebContext) (any, *er
|
|||||||
|
|
||||||
uid := c.GetCurrentUid()
|
uid := c.GetCurrentUid()
|
||||||
|
|
||||||
|
if tagCreateBatchReq.GroupId > 0 {
|
||||||
|
tagGroup, err := a.tagGroups.GetTagGroupByTagGroupId(c, uid, tagCreateBatchReq.GroupId)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf(c, "[transaction_tags.TagCreateBatchHandler] failed to get tag group \"id:%d\" for user \"uid:%d\", because %s", tagCreateBatchReq.GroupId, uid, err.Error())
|
||||||
|
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tagGroup == nil {
|
||||||
|
log.Warnf(c, "[transaction_tags.TagCreateBatchHandler] the tag group \"id:%d\" does not exist for user \"uid:%d\"", tagCreateBatchReq.GroupId, uid)
|
||||||
|
return nil, errs.ErrTransactionTagGroupNotFound
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
maxOrderId, err := a.tags.GetMaxDisplayOrder(c, uid, tagCreateBatchReq.GroupId)
|
maxOrderId, err := a.tags.GetMaxDisplayOrder(c, uid, tagCreateBatchReq.GroupId)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -167,6 +197,20 @@ func (a *TransactionTagsApi) TagModifyHandler(c *core.WebContext) (any, *errs.Er
|
|||||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if tagModifyReq.GroupId != tag.TagGroupId && tagModifyReq.GroupId > 0 {
|
||||||
|
tagGroup, err := a.tagGroups.GetTagGroupByTagGroupId(c, uid, tagModifyReq.GroupId)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf(c, "[transaction_tags.TagModifyHandler] failed to get tag group \"id:%d\" for user \"uid:%d\", because %s", tagModifyReq.GroupId, uid, err.Error())
|
||||||
|
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tagGroup == nil {
|
||||||
|
log.Warnf(c, "[transaction_tags.TagModifyHandler] the tag group \"id:%d\" does not exist for user \"uid:%d\"", tagModifyReq.GroupId, uid)
|
||||||
|
return nil, errs.ErrTransactionTagGroupNotFound
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
newTag := &models.TransactionTag{
|
newTag := &models.TransactionTag{
|
||||||
TagId: tag.TagId,
|
TagId: tag.TagId,
|
||||||
Uid: uid,
|
Uid: uid,
|
||||||
|
|||||||
+149
-47
@@ -192,7 +192,15 @@ func (a *TransactionsApi) TransactionListHandler(c *core.WebContext) (any, *errs
|
|||||||
transactions = transactions[:transactionListReq.Count]
|
transactions = transactions[:transactionListReq.Count]
|
||||||
}
|
}
|
||||||
|
|
||||||
transactionResult, err := a.getTransactionResponseListResult(c, user, transactions, clientTimezone, transactionListReq.WithPictures, transactionListReq.TrimAccount, transactionListReq.TrimCategory, transactionListReq.TrimTag)
|
accountMap, categoryMap, tagMap, allTransactionTagIds, pictureInfoMap, err := a.getTransactionEssentialDataByTransactionIds(c, user, transactions, transactionListReq.WithPictures, transactionListReq.TrimCategory, transactionListReq.TrimTag)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf(c, "[transactions.TransactionListHandler] failed to get essential data for assembling transaction result for user \"uid:%d\", because %s", uid, err.Error())
|
||||||
|
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||||
|
}
|
||||||
|
|
||||||
|
transactions = a.filterTransactions(c, uid, transactions, accountMap)
|
||||||
|
transactionResult, err := a.getTransactionResponseListResult(c, user, transactions, accountMap, categoryMap, tagMap, allTransactionTagIds, pictureInfoMap, clientTimezone, transactionListReq.WithPictures, transactionListReq.TrimAccount, transactionListReq.TrimCategory, transactionListReq.TrimTag)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf(c, "[transactions.TransactionListHandler] failed to assemble transaction result for user \"uid:%d\", because %s", uid, err.Error())
|
log.Errorf(c, "[transactions.TransactionListHandler] failed to assemble transaction result for user \"uid:%d\", because %s", uid, err.Error())
|
||||||
@@ -275,7 +283,15 @@ func (a *TransactionsApi) TransactionMonthListHandler(c *core.WebContext) (any,
|
|||||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||||
}
|
}
|
||||||
|
|
||||||
transactionResult, err := a.getTransactionResponseListResult(c, user, transactions, clientTimezone, transactionListReq.WithPictures, transactionListReq.TrimAccount, transactionListReq.TrimCategory, transactionListReq.TrimTag)
|
accountMap, categoryMap, tagMap, allTransactionTagIds, pictureInfoMap, err := a.getTransactionEssentialDataByTransactionIds(c, user, transactions, transactionListReq.WithPictures, transactionListReq.TrimCategory, transactionListReq.TrimTag)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf(c, "[transactions.TransactionMonthListHandler] failed to get essential data for assembling transaction result for user \"uid:%d\", because %s", uid, err.Error())
|
||||||
|
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||||
|
}
|
||||||
|
|
||||||
|
transactions = a.filterTransactions(c, uid, transactions, accountMap)
|
||||||
|
transactionResult, err := a.getTransactionResponseListResult(c, user, transactions, accountMap, categoryMap, tagMap, allTransactionTagIds, pictureInfoMap, clientTimezone, transactionListReq.WithPictures, transactionListReq.TrimAccount, transactionListReq.TrimCategory, transactionListReq.TrimTag)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf(c, "[transactions.TransactionMonthListHandler] failed to assemble transaction result for user \"uid:%d\", because %s", uid, err.Error())
|
log.Errorf(c, "[transactions.TransactionMonthListHandler] failed to assemble transaction result for user \"uid:%d\", because %s", uid, err.Error())
|
||||||
@@ -362,7 +378,25 @@ func (a *TransactionsApi) TransactionListAllHandler(c *core.WebContext) (any, *e
|
|||||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||||
}
|
}
|
||||||
|
|
||||||
transactionResult, err := a.getTransactionResponseListResult(c, user, allTransactions, clientTimezone, transactionAllListReq.WithPictures, transactionAllListReq.TrimAccount, transactionAllListReq.TrimCategory, transactionAllListReq.TrimTag)
|
var accountMap map[int64]*models.Account
|
||||||
|
var categoryMap map[int64]*models.TransactionCategory
|
||||||
|
var tagMap map[int64]*models.TransactionTag
|
||||||
|
var allTransactionTagIds map[int64][]int64
|
||||||
|
var pictureInfoMap map[int64][]*models.TransactionPictureInfo
|
||||||
|
|
||||||
|
if minTransactionTime == 0 && maxTransactionTime == math.MaxInt64 && len(allCategoryIds) < 1 && len(allAccountIds) < 1 && len(tagFilters) < 1 && transactionAllListReq.AmountFilter == "" && transactionAllListReq.Keyword == "" {
|
||||||
|
accountMap, categoryMap, tagMap, allTransactionTagIds, pictureInfoMap, err = a.getTransactionAllEssentialData(c, user, transactionAllListReq.WithPictures, transactionAllListReq.TrimCategory, transactionAllListReq.TrimTag)
|
||||||
|
} else {
|
||||||
|
accountMap, categoryMap, tagMap, allTransactionTagIds, pictureInfoMap, err = a.getTransactionEssentialDataByTransactionIds(c, user, allTransactions, transactionAllListReq.WithPictures, transactionAllListReq.TrimCategory, transactionAllListReq.TrimTag)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf(c, "[transactions.TransactionListAllHandler] failed to get essential data for assembling transaction result for user \"uid:%d\", because %s", uid, err.Error())
|
||||||
|
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||||
|
}
|
||||||
|
|
||||||
|
allTransactions = a.filterTransactions(c, uid, allTransactions, accountMap)
|
||||||
|
transactionResult, err := a.getTransactionResponseListResult(c, user, allTransactions, accountMap, categoryMap, tagMap, allTransactionTagIds, pictureInfoMap, clientTimezone, transactionAllListReq.WithPictures, transactionAllListReq.TrimAccount, transactionAllListReq.TrimCategory, transactionAllListReq.TrimTag)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf(c, "[transactions.TransactionListAllHandler] failed to assemble transaction result for user \"uid:%d\", because %s", uid, err.Error())
|
log.Errorf(c, "[transactions.TransactionListAllHandler] failed to assemble transaction result for user \"uid:%d\", because %s", uid, err.Error())
|
||||||
@@ -441,7 +475,24 @@ func (a *TransactionsApi) TransactionReconciliationStatementHandler(c *core.WebC
|
|||||||
transactionAccountBalanceMap[transactionWithBalance.RelatedId] = transactionWithBalance
|
transactionAccountBalanceMap[transactionWithBalance.RelatedId] = transactionWithBalance
|
||||||
}
|
}
|
||||||
|
|
||||||
transactionResult, err := a.getTransactionResponseListResult(c, user, transactions, clientTimezone, false, true, true, true)
|
allAccountIds := make([]int64, 0, len(transactions)*2)
|
||||||
|
|
||||||
|
for i := 0; i < len(transactions); i++ {
|
||||||
|
allAccountIds = append(allAccountIds, transactions[i].AccountId)
|
||||||
|
|
||||||
|
if transactions[i].Type == models.TRANSACTION_DB_TYPE_TRANSFER_IN || transactions[i].Type == models.TRANSACTION_DB_TYPE_TRANSFER_OUT {
|
||||||
|
allAccountIds = append(allAccountIds, transactions[i].RelatedAccountId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
allAccounts, err := a.accounts.GetAccountsByAccountIds(c, uid, utils.ToUniqueInt64Slice(allAccountIds))
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf(c, "[transactions.TransactionReconciliationStatementHandler] failed to get essential data for assembling transaction result for user \"uid:%d\", because %s", uid, err.Error())
|
||||||
|
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||||
|
}
|
||||||
|
|
||||||
|
transactionResult, err := a.getTransactionResponseListResult(c, user, transactions, allAccounts, nil, nil, nil, nil, clientTimezone, false, true, true, true)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf(c, "[transactions.TransactionReconciliationStatementHandler] failed to assemble transaction result for user \"uid:%d\", because %s", uid, err.Error())
|
log.Errorf(c, "[transactions.TransactionReconciliationStatementHandler] failed to assemble transaction result for user \"uid:%d\", because %s", uid, err.Error())
|
||||||
@@ -1401,13 +1452,13 @@ func (a *TransactionsApi) TransactionDeleteHandler(c *core.WebContext) (any, *er
|
|||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// TransactionParseImportDsvFileDataHandler returns the parsed file data by request parameters for current user
|
// TransactionParseImportCustomFileDataHandler returns the parsed file data by request parameters for current user
|
||||||
func (a *TransactionsApi) TransactionParseImportDsvFileDataHandler(c *core.WebContext) (any, *errs.Error) {
|
func (a *TransactionsApi) TransactionParseImportCustomFileDataHandler(c *core.WebContext) (any, *errs.Error) {
|
||||||
uid := c.GetCurrentUid()
|
uid := c.GetCurrentUid()
|
||||||
form, err := c.MultipartForm()
|
form, err := c.MultipartForm()
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf(c, "[transactions.TransactionParseImportDsvFileDataHandler] failed to get multi-part form data for user \"uid:%d\", because %s", uid, err.Error())
|
log.Errorf(c, "[transactions.TransactionParseImportCustomFileDataHandler] failed to get multi-part form data for user \"uid:%d\", because %s", uid, err.Error())
|
||||||
return nil, errs.ErrParameterInvalid
|
return nil, errs.ErrParameterInvalid
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1419,18 +1470,18 @@ func (a *TransactionsApi) TransactionParseImportDsvFileDataHandler(c *core.WebCo
|
|||||||
|
|
||||||
fileType := fileTypes[0]
|
fileType := fileTypes[0]
|
||||||
|
|
||||||
if !converters.IsCustomDelimiterSeparatedValuesFileType(fileType) {
|
if !converters.IsCustomFileFormatFileType(fileType) {
|
||||||
return nil, errs.Or(err, errs.ErrImportFileTypeNotSupported)
|
return nil, errs.Or(err, errs.ErrImportFileTypeNotSupported)
|
||||||
}
|
}
|
||||||
|
|
||||||
fileEncodings := form.Value["fileEncoding"]
|
fileEncodings := form.Value["fileEncoding"]
|
||||||
|
fileEncoding := ""
|
||||||
|
|
||||||
if len(fileEncodings) < 1 || fileEncodings[0] == "" {
|
if len(fileEncodings) > 0 {
|
||||||
return nil, errs.ErrImportFileEncodingIsEmpty
|
fileEncoding = fileEncodings[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
fileEncoding := fileEncodings[0]
|
dataParser, err := converters.CreateNewCustomFileFormatTransactionDataParser(fileType, fileEncoding)
|
||||||
dataParser, err := converters.CreateNewDelimiterSeparatedValuesDataParser(fileType, fileEncoding)
|
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errs.Or(err, errs.ErrImportFileTypeNotSupported)
|
return nil, errs.Or(err, errs.ErrImportFileTypeNotSupported)
|
||||||
@@ -1439,24 +1490,24 @@ func (a *TransactionsApi) TransactionParseImportDsvFileDataHandler(c *core.WebCo
|
|||||||
importFiles := form.File["file"]
|
importFiles := form.File["file"]
|
||||||
|
|
||||||
if len(importFiles) < 1 {
|
if len(importFiles) < 1 {
|
||||||
log.Warnf(c, "[transactions.TransactionParseImportDsvFileDataHandler] there is no import file in request for user \"uid:%d\"", uid)
|
log.Warnf(c, "[transactions.TransactionParseImportCustomFileDataHandler] there is no import file in request for user \"uid:%d\"", uid)
|
||||||
return nil, errs.ErrNoFilesUpload
|
return nil, errs.ErrNoFilesUpload
|
||||||
}
|
}
|
||||||
|
|
||||||
if importFiles[0].Size < 1 {
|
if importFiles[0].Size < 1 {
|
||||||
log.Warnf(c, "[transactions.TransactionParseImportDsvFileDataHandler] the size of import file in request is zero for user \"uid:%d\"", uid)
|
log.Warnf(c, "[transactions.TransactionParseImportCustomFileDataHandler] the size of import file in request is zero for user \"uid:%d\"", uid)
|
||||||
return nil, errs.ErrUploadedFileEmpty
|
return nil, errs.ErrUploadedFileEmpty
|
||||||
}
|
}
|
||||||
|
|
||||||
if importFiles[0].Size > int64(a.CurrentConfig().MaxImportFileSize) {
|
if importFiles[0].Size > int64(a.CurrentConfig().MaxImportFileSize) {
|
||||||
log.Warnf(c, "[transactions.TransactionParseImportDsvFileDataHandler] the upload file size \"%d\" exceeds the maximum size \"%d\" of import file for user \"uid:%d\"", importFiles[0].Size, a.CurrentConfig().MaxImportFileSize, uid)
|
log.Warnf(c, "[transactions.TransactionParseImportCustomFileDataHandler] the upload file size \"%d\" exceeds the maximum size \"%d\" of import file for user \"uid:%d\"", importFiles[0].Size, a.CurrentConfig().MaxImportFileSize, uid)
|
||||||
return nil, errs.ErrExceedMaxUploadFileSize
|
return nil, errs.ErrExceedMaxUploadFileSize
|
||||||
}
|
}
|
||||||
|
|
||||||
importFile, err := importFiles[0].Open()
|
importFile, err := importFiles[0].Open()
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf(c, "[transactions.TransactionParseImportDsvFileDataHandler] failed to get import file from request for user \"uid:%d\", because %s", uid, err.Error())
|
log.Errorf(c, "[transactions.TransactionParseImportCustomFileDataHandler] failed to get import file from request for user \"uid:%d\", because %s", uid, err.Error())
|
||||||
return nil, errs.ErrOperationFailed
|
return nil, errs.ErrOperationFailed
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1464,14 +1515,14 @@ func (a *TransactionsApi) TransactionParseImportDsvFileDataHandler(c *core.WebCo
|
|||||||
fileData, err := io.ReadAll(importFile)
|
fileData, err := io.ReadAll(importFile)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf(c, "[transactions.TransactionParseImportDsvFileDataHandler] failed to read import file data for user \"uid:%d\", because %s", uid, err.Error())
|
log.Errorf(c, "[transactions.TransactionParseImportCustomFileDataHandler] failed to read import file data for user \"uid:%d\", because %s", uid, err.Error())
|
||||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||||
}
|
}
|
||||||
|
|
||||||
allLines, err := dataParser.ParseDsvFileLines(c, fileData)
|
allLines, err := dataParser.ParseDataLines(c, fileData)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf(c, "[transactions.TransactionParseImportDsvFileDataHandler] failed to parse import file data for user \"uid:%d\", because %s", uid, err.Error())
|
log.Errorf(c, "[transactions.TransactionParseImportCustomFileDataHandler] failed to parse import file data for user \"uid:%d\", because %s", uid, err.Error())
|
||||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1514,15 +1565,14 @@ func (a *TransactionsApi) TransactionParseImportFileHandler(c *core.WebContext)
|
|||||||
|
|
||||||
var dataImporter converter.TransactionDataImporter
|
var dataImporter converter.TransactionDataImporter
|
||||||
|
|
||||||
if converters.IsCustomDelimiterSeparatedValuesFileType(fileType) {
|
if converters.IsCustomFileFormatFileType(fileType) {
|
||||||
fileEncodings := form.Value["fileEncoding"]
|
fileEncodings := form.Value["fileEncoding"]
|
||||||
|
fileEncoding := ""
|
||||||
|
|
||||||
if len(fileEncodings) < 1 || fileEncodings[0] == "" {
|
if len(fileEncodings) > 0 {
|
||||||
return nil, errs.ErrImportFileEncodingIsEmpty
|
fileEncoding = fileEncodings[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
fileEncoding := fileEncodings[0]
|
|
||||||
|
|
||||||
columnMappings := form.Value["columnMapping"]
|
columnMappings := form.Value["columnMapping"]
|
||||||
|
|
||||||
if len(columnMappings) < 1 || columnMappings[0] == "" {
|
if len(columnMappings) < 1 || columnMappings[0] == "" {
|
||||||
@@ -1606,7 +1656,7 @@ func (a *TransactionsApi) TransactionParseImportFileHandler(c *core.WebContext)
|
|||||||
transactionTagSeparator = transactionTagSeparators[0]
|
transactionTagSeparator = transactionTagSeparators[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
dataImporter, err = converters.CreateNewDelimiterSeparatedValuesDataImporter(fileType, fileEncoding, columnIndexMapping, transactionTypeNameMapping, hasHeaderLine, timeFormats[0], timezoneFormat, amountDecimalSeparator, amountDigitGroupingSymbol, geoLocationSeparator, geoLocationOrder, transactionTagSeparator)
|
dataImporter, err = converters.CreateNewCustomTransactionDataImporter(fileType, fileEncoding, columnIndexMapping, transactionTypeNameMapping, hasHeaderLine, timeFormats[0], timezoneFormat, amountDecimalSeparator, amountDigitGroupingSymbol, geoLocationSeparator, geoLocationOrder, transactionTagSeparator)
|
||||||
} else {
|
} else {
|
||||||
dataImporter, err = converters.GetTransactionDataImporter(fileType)
|
dataImporter, err = converters.GetTransactionDataImporter(fileType)
|
||||||
}
|
}
|
||||||
@@ -1928,7 +1978,61 @@ func (a *TransactionsApi) getTransactionTagInfoResponses(tagIds []int64, allTran
|
|||||||
return allTags
|
return allTags
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *TransactionsApi) getTransactionResponseListResult(c *core.WebContext, user *models.User, transactions []*models.Transaction, clientTimezone *time.Location, withPictures bool, trimAccount bool, trimCategory bool, trimTag bool) (models.TransactionInfoResponseSlice, error) {
|
func (a *TransactionsApi) getTransactionAllEssentialData(c *core.WebContext, user *models.User, withPictures bool, trimCategory bool, trimTag bool) (accountMap map[int64]*models.Account, categoryMap map[int64]*models.TransactionCategory, tagMap map[int64]*models.TransactionTag, allTransactionTagIds map[int64][]int64, pictureInfoMap map[int64][]*models.TransactionPictureInfo, err error) {
|
||||||
|
uid := user.Uid
|
||||||
|
allAccounts, err := a.accounts.GetAllAccountsByUid(c, uid)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf(c, "[transactions.getTransactionAllEssentialData] failed to get all accounts for user \"uid:%d\", because %s", uid, err.Error())
|
||||||
|
return nil, nil, nil, nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
accountMap = a.accounts.GetAccountMapByList(allAccounts)
|
||||||
|
|
||||||
|
allTagIndexes, err := a.transactionTags.GetAllTagIdsOfAllTransactions(c, uid)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf(c, "[transactions.getTransactionAllEssentialData] failed to get all transactions tag ids for user \"uid:%d\", because %s", uid, err.Error())
|
||||||
|
return nil, nil, nil, nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
allTransactionTagIds = a.transactionTags.GetGroupedTransactionTagIds(allTagIndexes)
|
||||||
|
|
||||||
|
if !trimCategory {
|
||||||
|
allCategories, err := a.transactionCategories.GetAllCategoriesByUid(c, uid, 0, -1)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf(c, "[transactions.getTransactionAllEssentialData] failed to get all transactions categories for user \"uid:%d\", because %s", uid, err.Error())
|
||||||
|
return nil, nil, nil, nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
categoryMap = a.transactionCategories.GetCategoryMapByList(allCategories)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !trimTag {
|
||||||
|
allTags, err := a.transactionTags.GetAllTagsByUid(c, uid)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf(c, "[transactions.getTransactionAllEssentialData] failed to get all transactions tags for user \"uid:%d\", because %s", uid, err.Error())
|
||||||
|
return nil, nil, nil, nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
tagMap = a.transactionTags.GetTagMapByList(allTags)
|
||||||
|
}
|
||||||
|
|
||||||
|
if withPictures && a.CurrentConfig().EnableTransactionPictures {
|
||||||
|
pictureInfoMap, err = a.transactionPictures.GetAllPictureInfosOfAllTransactions(c, uid)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf(c, "[transactions.getTransactionAllEssentialData] failed to get all transactions pictures for user \"uid:%d\", because %s", uid, err.Error())
|
||||||
|
return nil, nil, nil, nil, nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return accountMap, categoryMap, tagMap, allTransactionTagIds, pictureInfoMap, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *TransactionsApi) getTransactionEssentialDataByTransactionIds(c *core.WebContext, user *models.User, transactions []*models.Transaction, withPictures bool, trimCategory bool, trimTag bool) (accountMap map[int64]*models.Account, categoryMap map[int64]*models.TransactionCategory, tagMap map[int64]*models.TransactionTag, allTransactionTagIds map[int64][]int64, pictureInfoMap map[int64][]*models.TransactionPictureInfo, err error) {
|
||||||
uid := user.Uid
|
uid := user.Uid
|
||||||
transactionIds := make([]int64, len(transactions))
|
transactionIds := make([]int64, len(transactions))
|
||||||
accountIds := make([]int64, 0, len(transactions)*2)
|
accountIds := make([]int64, 0, len(transactions)*2)
|
||||||
@@ -1951,32 +2055,26 @@ func (a *TransactionsApi) getTransactionResponseListResult(c *core.WebContext, u
|
|||||||
categoryIds = append(categoryIds, transactions[i].CategoryId)
|
categoryIds = append(categoryIds, transactions[i].CategoryId)
|
||||||
}
|
}
|
||||||
|
|
||||||
allAccounts, err := a.accounts.GetAccountsByAccountIds(c, uid, utils.ToUniqueInt64Slice(accountIds))
|
accountMap, err = a.accounts.GetAccountsByAccountIds(c, uid, utils.ToUniqueInt64Slice(accountIds))
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf(c, "[transactions.getTransactionResponseListResult] failed to get accounts for user \"uid:%d\", because %s", uid, err.Error())
|
log.Errorf(c, "[transactions.getTransactionEssentialDataByTransactionIds] failed to get accounts for user \"uid:%d\", because %s", uid, err.Error())
|
||||||
return nil, err
|
return nil, nil, nil, nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
transactions = a.filterTransactions(c, uid, transactions, allAccounts)
|
allTransactionTagIds, err = a.transactionTags.GetAllTagIdsOfTransactions(c, uid, transactionIds)
|
||||||
|
|
||||||
allTransactionTagIds, err := a.transactionTags.GetAllTagIdsOfTransactions(c, uid, transactionIds)
|
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf(c, "[transactions.getTransactionResponseListResult] failed to get transactions tag ids for user \"uid:%d\", because %s", uid, err.Error())
|
log.Errorf(c, "[transactions.getTransactionEssentialDataByTransactionIds] failed to get transactions tag ids for user \"uid:%d\", because %s", uid, err.Error())
|
||||||
return nil, err
|
return nil, nil, nil, nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var categoryMap map[int64]*models.TransactionCategory
|
|
||||||
var tagMap map[int64]*models.TransactionTag
|
|
||||||
var pictureInfoMap map[int64][]*models.TransactionPictureInfo
|
|
||||||
|
|
||||||
if !trimCategory {
|
if !trimCategory {
|
||||||
categoryMap, err = a.transactionCategories.GetCategoriesByCategoryIds(c, uid, utils.ToUniqueInt64Slice(categoryIds))
|
categoryMap, err = a.transactionCategories.GetCategoriesByCategoryIds(c, uid, utils.ToUniqueInt64Slice(categoryIds))
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf(c, "[transactions.getTransactionResponseListResult] failed to get transactions categories for user \"uid:%d\", because %s", uid, err.Error())
|
log.Errorf(c, "[transactions.getTransactionEssentialDataByTransactionIds] failed to get transactions categories for user \"uid:%d\", because %s", uid, err.Error())
|
||||||
return nil, err
|
return nil, nil, nil, nil, nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1984,8 +2082,8 @@ func (a *TransactionsApi) getTransactionResponseListResult(c *core.WebContext, u
|
|||||||
tagMap, err = a.transactionTags.GetTagsByTagIds(c, uid, utils.ToUniqueInt64Slice(a.transactionTags.GetTransactionTagIds(allTransactionTagIds)))
|
tagMap, err = a.transactionTags.GetTagsByTagIds(c, uid, utils.ToUniqueInt64Slice(a.transactionTags.GetTransactionTagIds(allTransactionTagIds)))
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf(c, "[transactions.getTransactionResponseListResult] failed to get transactions tags for user \"uid:%d\", because %s", uid, err.Error())
|
log.Errorf(c, "[transactions.getTransactionEssentialDataByTransactionIds] failed to get transactions tags for user \"uid:%d\", because %s", uid, err.Error())
|
||||||
return nil, err
|
return nil, nil, nil, nil, nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1993,11 +2091,15 @@ func (a *TransactionsApi) getTransactionResponseListResult(c *core.WebContext, u
|
|||||||
pictureInfoMap, err = a.transactionPictures.GetPictureInfosByTransactionIds(c, uid, utils.ToUniqueInt64Slice(a.transactions.GetTransactionIds(transactions)))
|
pictureInfoMap, err = a.transactionPictures.GetPictureInfosByTransactionIds(c, uid, utils.ToUniqueInt64Slice(a.transactions.GetTransactionIds(transactions)))
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf(c, "[transactions.getTransactionResponseListResult] failed to get transactions pictures for user \"uid:%d\", because %s", uid, err.Error())
|
log.Errorf(c, "[transactions.getTransactionEssentialDataByTransactionIds] failed to get transactions pictures for user \"uid:%d\", because %s", uid, err.Error())
|
||||||
return nil, err
|
return nil, nil, nil, nil, nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return accountMap, categoryMap, tagMap, allTransactionTagIds, pictureInfoMap, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *TransactionsApi) getTransactionResponseListResult(c *core.WebContext, user *models.User, transactions []*models.Transaction, allAccounts map[int64]*models.Account, categoryMap map[int64]*models.TransactionCategory, tagMap map[int64]*models.TransactionTag, allTransactionTagIds map[int64][]int64, pictureInfoMap map[int64][]*models.TransactionPictureInfo, clientTimezone *time.Location, withPictures bool, trimAccount bool, trimCategory bool, trimTag bool) (models.TransactionInfoResponseSlice, error) {
|
||||||
result := make(models.TransactionInfoResponseSlice, len(transactions))
|
result := make(models.TransactionInfoResponseSlice, len(transactions))
|
||||||
|
|
||||||
for i := 0; i < len(transactions); i++ {
|
for i := 0; i < len(transactions); i++ {
|
||||||
@@ -2021,17 +2123,17 @@ func (a *TransactionsApi) getTransactionResponseListResult(c *core.WebContext, u
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !trimCategory {
|
if !trimCategory && categoryMap != nil {
|
||||||
if category := categoryMap[transaction.CategoryId]; category != nil {
|
if category := categoryMap[transaction.CategoryId]; category != nil {
|
||||||
result[i].Category = category.ToTransactionCategoryInfoResponse()
|
result[i].Category = category.ToTransactionCategoryInfoResponse()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !trimTag {
|
if !trimTag && tagMap != nil && transactionTagIds != nil {
|
||||||
result[i].Tags = a.getTransactionTagInfoResponses(transactionTagIds, tagMap)
|
result[i].Tags = a.getTransactionTagInfoResponses(transactionTagIds, tagMap)
|
||||||
}
|
}
|
||||||
|
|
||||||
if withPictures && a.CurrentConfig().EnableTransactionPictures {
|
if withPictures && a.CurrentConfig().EnableTransactionPictures && pictureInfoMap != nil {
|
||||||
pictureInfos, exists := pictureInfoMap[transaction.TransactionId]
|
pictureInfos, exists := pictureInfoMap[transaction.TransactionId]
|
||||||
|
|
||||||
if exists {
|
if exists {
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ func InitializeOAuth2Provider(config *settings.Config) error {
|
|||||||
|
|
||||||
Container.current = oauth2Provider
|
Container.current = oauth2Provider
|
||||||
Container.usePKCE = config.OAuth2UsePKCE
|
Container.usePKCE = config.OAuth2UsePKCE
|
||||||
Container.oauth2HttpClient = httpclient.NewHttpClient(config.OAuth2RequestTimeout, config.OAuth2Proxy, config.OAuth2SkipTLSVerify, settings.GetUserAgent(), config.EnableDebugLog)
|
Container.oauth2HttpClient = httpclient.NewHttpClient(config.OAuth2RequestTimeout, config.OAuth2Proxy, config.OAuth2SkipTLSVerify, core.GetOutgoingUserAgent(), config.EnableDebugLog)
|
||||||
Container.externalUserAuthType = externalUserAuthType
|
Container.externalUserAuthType = externalUserAuthType
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ func (l *UserDataCli) ModifyUserPassword(c *core.CliContext, username string, pa
|
|||||||
Password: password,
|
Password: password,
|
||||||
}
|
}
|
||||||
|
|
||||||
_, _, err = l.users.UpdateUser(c, userNew, false)
|
err = l.users.UpdateUserPassword(c, userNew)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.CliErrorf(c, "[user_data.ModifyUserPassword] failed to update user \"%s\" password, because %s", user.Username, err.Error())
|
log.CliErrorf(c, "[user_data.ModifyUserPassword] failed to update user \"%s\" password, because %s", user.Username, err.Error())
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package custom
|
||||||
|
|
||||||
|
import "github.com/mayswind/ezbookkeeping/pkg/core"
|
||||||
|
|
||||||
|
// CustomTransactionDataParser represents the parser for custom transaction data files
|
||||||
|
type CustomTransactionDataParser interface {
|
||||||
|
ParseDataLines(ctx core.Context, data []byte) ([][]string, error)
|
||||||
|
}
|
||||||
+67
-63
@@ -1,4 +1,4 @@
|
|||||||
package dsv
|
package custom
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"golang.org/x/text/encoding/simplifiedchinese"
|
"golang.org/x/text/encoding/simplifiedchinese"
|
||||||
"golang.org/x/text/encoding/traditionalchinese"
|
"golang.org/x/text/encoding/traditionalchinese"
|
||||||
"golang.org/x/text/encoding/unicode"
|
"golang.org/x/text/encoding/unicode"
|
||||||
|
"golang.org/x/text/encoding/unicode/utf32"
|
||||||
"golang.org/x/text/transform"
|
"golang.org/x/text/transform"
|
||||||
|
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/converters/converter"
|
"github.com/mayswind/ezbookkeeping/pkg/converters/converter"
|
||||||
@@ -33,58 +34,57 @@ var supportedFileTypeSeparators = map[string]rune{
|
|||||||
}
|
}
|
||||||
|
|
||||||
var supportedFileEncodings = map[string]encoding.Encoding{
|
var supportedFileEncodings = map[string]encoding.Encoding{
|
||||||
"utf-8": unicode.UTF8, // UTF-8
|
"utf-8": unicode.UTF8BOM, // UTF-8
|
||||||
"utf-8-bom": unicode.UTF8BOM, // UTF-8 with BOM
|
"utf-16le": unicode.UTF16(unicode.LittleEndian, unicode.UseBOM), // UTF-16 Little Endian
|
||||||
"utf-16le": unicode.UTF16(unicode.LittleEndian, unicode.UseBOM), // UTF-16 Little Endian
|
"utf-16be": unicode.UTF16(unicode.BigEndian, unicode.UseBOM), // UTF-16 Big Endian
|
||||||
"utf-16be": unicode.UTF16(unicode.BigEndian, unicode.UseBOM), // UTF-16 Big Endian
|
"utf-32le": utf32.UTF32(utf32.LittleEndian, utf32.UseBOM), // UTF-32 Little Endian
|
||||||
"utf-16le-bom": unicode.UTF16(unicode.LittleEndian, unicode.ExpectBOM), // UTF-16 Little Endian with BOM
|
"utf-32be": utf32.UTF32(utf32.BigEndian, utf32.UseBOM), // UTF-32 Big Endian
|
||||||
"utf-16be-bom": unicode.UTF16(unicode.BigEndian, unicode.ExpectBOM), // UTF-16 Big Endian with BOM
|
"cp437": charmap.CodePage437, // OEM United States (CP-437)
|
||||||
"cp437": charmap.CodePage437, // OEM United States (CP-437)
|
"cp863": charmap.CodePage863, // OEM Canadian French (CP-863)
|
||||||
"cp863": charmap.CodePage863, // OEM Canadian French (CP-863)
|
"cp037": charmap.CodePage037, // IBM EBCDIC US/Canada (CP-037)
|
||||||
"cp037": charmap.CodePage037, // IBM EBCDIC US/Canada (CP-037)
|
"cp1047": charmap.CodePage1047, // IBM EBCDIC Open Systems (CP-1047)
|
||||||
"cp1047": charmap.CodePage1047, // IBM EBCDIC Open Systems (CP-1047)
|
"cp1140": charmap.CodePage1140, // IBM EBCDIC US/Canada with Euro (CP-1140)
|
||||||
"cp1140": charmap.CodePage1140, // IBM EBCDIC US/Canada with Euro (CP-1140)
|
"iso-8859-1": charmap.ISO8859_1, // Western European (ISO-8859-1)
|
||||||
"iso-8859-1": charmap.ISO8859_1, // Western European (ISO-8859-1)
|
"cp850": charmap.CodePage850, // Western European (CP-850)
|
||||||
"cp850": charmap.CodePage850, // Western European (CP-850)
|
"cp858": charmap.CodePage858, // Western European with Euro (CP-858)
|
||||||
"cp858": charmap.CodePage858, // Western European with Euro (CP-858)
|
"windows-1252": charmap.Windows1252, // Western European (Windows-1252)
|
||||||
"windows-1252": charmap.Windows1252, // Western European (Windows-1252)
|
"iso-8859-15": charmap.ISO8859_15, // Western European (ISO-8859-15)
|
||||||
"iso-8859-15": charmap.ISO8859_15, // Western European (ISO-8859-15)
|
"iso-8859-4": charmap.ISO8859_4, // North European (ISO-8859-4)
|
||||||
"iso-8859-4": charmap.ISO8859_4, // North European (ISO-8859-4)
|
"iso-8859-10": charmap.ISO8859_10, // North European (ISO-8859-10)
|
||||||
"iso-8859-10": charmap.ISO8859_10, // North European (ISO-8859-10)
|
"cp865": charmap.CodePage865, // North European (CP-865)
|
||||||
"cp865": charmap.CodePage865, // North European (CP-865)
|
"iso-8859-2": charmap.ISO8859_2, // Central European (ISO-8859-2)
|
||||||
"iso-8859-2": charmap.ISO8859_2, // Central European (ISO-8859-2)
|
"cp852": charmap.CodePage852, // Central European (CP-852)
|
||||||
"cp852": charmap.CodePage852, // Central European (CP-852)
|
"windows-1250": charmap.Windows1250, // Central European (Windows-1250)
|
||||||
"windows-1250": charmap.Windows1250, // Central European (Windows-1250)
|
"iso-8859-14": charmap.ISO8859_14, // Celtic (ISO-8859-14)
|
||||||
"iso-8859-14": charmap.ISO8859_14, // Celtic (ISO-8859-14)
|
"iso-8859-3": charmap.ISO8859_3, // South European (ISO-8859-3)
|
||||||
"iso-8859-3": charmap.ISO8859_3, // South European (ISO-8859-3)
|
"cp860": charmap.CodePage860, // Portuguese (CP-860)
|
||||||
"cp860": charmap.CodePage860, // Portuguese (CP-860)
|
"iso-8859-7": charmap.ISO8859_7, // Greek (ISO-8859-7)
|
||||||
"iso-8859-7": charmap.ISO8859_7, // Greek (ISO-8859-7)
|
"windows-1253": charmap.Windows1253, // Greek (Windows-1253)
|
||||||
"windows-1253": charmap.Windows1253, // Greek (Windows-1253)
|
"iso-8859-9": charmap.ISO8859_9, // Turkish (ISO-8859-9)
|
||||||
"iso-8859-9": charmap.ISO8859_9, // Turkish (ISO-8859-9)
|
"windows-1254": charmap.Windows1254, // Turkish (Windows-1254)
|
||||||
"windows-1254": charmap.Windows1254, // Turkish (Windows-1254)
|
"iso-8859-13": charmap.ISO8859_13, // Baltic (ISO-8859-13)
|
||||||
"iso-8859-13": charmap.ISO8859_13, // Baltic (ISO-8859-13)
|
"windows-1257": charmap.Windows1257, // Baltic (Windows-1257)
|
||||||
"windows-1257": charmap.Windows1257, // Baltic (Windows-1257)
|
"iso-8859-16": charmap.ISO8859_16, // South-Eastern European (ISO-8859-16)
|
||||||
"iso-8859-16": charmap.ISO8859_16, // South-Eastern European (ISO-8859-16)
|
"iso-8859-5": charmap.ISO8859_5, // Cyrillic (ISO-8859-5)
|
||||||
"iso-8859-5": charmap.ISO8859_5, // Cyrillic (ISO-8859-5)
|
"cp855": charmap.CodePage855, // Cyrillic (CP-855)
|
||||||
"cp855": charmap.CodePage855, // Cyrillic (CP-855)
|
"cp866": charmap.CodePage866, // Cyrillic (CP-866)
|
||||||
"cp866": charmap.CodePage866, // Cyrillic (CP-866)
|
"windows-1251": charmap.Windows1251, // Cyrillic (Windows-1251)
|
||||||
"windows-1251": charmap.Windows1251, // Cyrillic (Windows-1251)
|
"koi8r": charmap.KOI8R, // Cyrillic (KOI8-R)
|
||||||
"koi8r": charmap.KOI8R, // Cyrillic (KOI8-R)
|
"koi8u": charmap.KOI8U, // Cyrillic (KOI8-U)
|
||||||
"koi8u": charmap.KOI8U, // Cyrillic (KOI8-U)
|
"iso-8859-6": charmap.ISO8859_6, // Arabic (ISO-8859-6)
|
||||||
"iso-8859-6": charmap.ISO8859_6, // Arabic (ISO-8859-6)
|
"windows-1256": charmap.Windows1256, // Arabic (Windows-1256)
|
||||||
"windows-1256": charmap.Windows1256, // Arabic (Windows-1256)
|
"iso-8859-8": charmap.ISO8859_8, // Hebrew (ISO-8859-8)
|
||||||
"iso-8859-8": charmap.ISO8859_8, // Hebrew (ISO-8859-8)
|
"cp862": charmap.CodePage862, // Hebrew (CP-862)
|
||||||
"cp862": charmap.CodePage862, // Hebrew (CP-862)
|
"windows-1255": charmap.Windows1255, // Hebrew (Windows-1255)
|
||||||
"windows-1255": charmap.Windows1255, // Hebrew (Windows-1255)
|
"windows-874": charmap.Windows874, // Thai (Windows-874)
|
||||||
"windows-874": charmap.Windows874, // Thai (Windows-874)
|
"windows-1258": charmap.Windows1258, // Vietnamese (Windows-1258)
|
||||||
"windows-1258": charmap.Windows1258, // Vietnamese (Windows-1258)
|
"gb18030": simplifiedchinese.GB18030, // Chinese (Simplified, GB18030)
|
||||||
"gb18030": simplifiedchinese.GB18030, // Chinese (Simplified, GB18030)
|
"gbk": simplifiedchinese.GBK, // Chinese (Simplified, GBK)
|
||||||
"gbk": simplifiedchinese.GBK, // Chinese (Simplified, GBK)
|
"big5": traditionalchinese.Big5, // Chinese (Traditional, Big5)
|
||||||
"big5": traditionalchinese.Big5, // Chinese (Traditional, Big5)
|
"euc-kr": korean.EUCKR, // Korean (EUC-KR)
|
||||||
"euc-kr": korean.EUCKR, // Korean (EUC-KR)
|
"euc-jp": japanese.EUCJP, // Japanese (EUC-JP)
|
||||||
"euc-jp": japanese.EUCJP, // Japanese (EUC-JP)
|
"iso-2022-jp": japanese.ISO2022JP, // Japanese (ISO-2022-JP)
|
||||||
"iso-2022-jp": japanese.ISO2022JP, // Japanese (ISO-2022-JP)
|
"shift_jis": japanese.ShiftJIS, // Japanese (Shift JIS)
|
||||||
"shift_jis": japanese.ShiftJIS, // Japanese (Shift JIS)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var customTransactionTypeNameMapping = map[models.TransactionType]string{
|
var customTransactionTypeNameMapping = map[models.TransactionType]string{
|
||||||
@@ -94,10 +94,6 @@ var customTransactionTypeNameMapping = map[models.TransactionType]string{
|
|||||||
models.TRANSACTION_TYPE_TRANSFER: utils.IntToString(int(models.TRANSACTION_TYPE_TRANSFER)),
|
models.TRANSACTION_TYPE_TRANSFER: utils.IntToString(int(models.TRANSACTION_TYPE_TRANSFER)),
|
||||||
}
|
}
|
||||||
|
|
||||||
type CustomTransactionDataDsvFileParser interface {
|
|
||||||
ParseDsvFileLines(ctx core.Context, data []byte) ([][]string, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// customTransactionDataDsvFileImporter defines the structure of custom dsv importer for transaction data
|
// customTransactionDataDsvFileImporter defines the structure of custom dsv importer for transaction data
|
||||||
type customTransactionDataDsvFileImporter struct {
|
type customTransactionDataDsvFileImporter struct {
|
||||||
fileEncoding encoding.Encoding
|
fileEncoding encoding.Encoding
|
||||||
@@ -114,8 +110,8 @@ type customTransactionDataDsvFileImporter struct {
|
|||||||
transactionTagSeparator string
|
transactionTagSeparator string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseDsvFileLines returns the parsed file lines for specified the dsv file data
|
// ParseDataLines returns the parsed file lines for specified the dsv file data
|
||||||
func (c *customTransactionDataDsvFileImporter) ParseDsvFileLines(ctx core.Context, data []byte) ([][]string, error) {
|
func (c *customTransactionDataDsvFileImporter) ParseDataLines(ctx core.Context, data []byte) ([][]string, error) {
|
||||||
reader := transform.NewReader(bytes.NewReader(data), c.fileEncoding.NewDecoder())
|
reader := transform.NewReader(bytes.NewReader(data), c.fileEncoding.NewDecoder())
|
||||||
csvReader := csv.NewReader(reader)
|
csvReader := csv.NewReader(reader)
|
||||||
csvReader.Comma = c.separator
|
csvReader.Comma = c.separator
|
||||||
@@ -131,7 +127,7 @@ func (c *customTransactionDataDsvFileImporter) ParseDsvFileLines(ctx core.Contex
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf(ctx, "[custom_transaction_data_dsv_file_importer.ParseDsvFileLines] cannot parse dsv data, because %s", err.Error())
|
log.Errorf(ctx, "[custom_transaction_data_dsv_file_importer.ParseDataLines] cannot parse dsv data, because %s", err.Error())
|
||||||
return nil, errs.ErrInvalidCSVFile
|
return nil, errs.ErrInvalidCSVFile
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,7 +147,7 @@ func (c *customTransactionDataDsvFileImporter) ParseDsvFileLines(ctx core.Contex
|
|||||||
|
|
||||||
// ParseImportedData returns the imported data by parsing the custom transaction dsv data
|
// ParseImportedData returns the imported data by parsing the custom transaction dsv data
|
||||||
func (c *customTransactionDataDsvFileImporter) ParseImportedData(ctx core.Context, user *models.User, data []byte, defaultTimezone *time.Location, additionalOptions converter.TransactionDataImporterOptions, accountMap map[string]*models.Account, expenseCategoryMap map[string]map[string]*models.TransactionCategory, incomeCategoryMap map[string]map[string]*models.TransactionCategory, transferCategoryMap map[string]map[string]*models.TransactionCategory, tagMap map[string]*models.TransactionTag) (models.ImportedTransactionSlice, []*models.Account, []*models.TransactionCategory, []*models.TransactionCategory, []*models.TransactionCategory, []*models.TransactionTag, error) {
|
func (c *customTransactionDataDsvFileImporter) ParseImportedData(ctx core.Context, user *models.User, data []byte, defaultTimezone *time.Location, additionalOptions converter.TransactionDataImporterOptions, accountMap map[string]*models.Account, expenseCategoryMap map[string]map[string]*models.TransactionCategory, incomeCategoryMap map[string]map[string]*models.TransactionCategory, transferCategoryMap map[string]map[string]*models.TransactionCategory, tagMap map[string]*models.TransactionTag) (models.ImportedTransactionSlice, []*models.Account, []*models.TransactionCategory, []*models.TransactionCategory, []*models.TransactionCategory, []*models.TransactionTag, error) {
|
||||||
allLines, err := c.ParseDsvFileLines(ctx, data)
|
allLines, err := c.ParseDataLines(ctx, data)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, nil, nil, nil, nil, err
|
return nil, nil, nil, nil, nil, nil, err
|
||||||
@@ -170,14 +166,18 @@ func IsDelimiterSeparatedValuesFileType(fileType string) bool {
|
|||||||
return exists
|
return exists
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateNewCustomTransactionDataDsvFileParser returns a new custom dsv parser for transaction data
|
// CreateNewCustomTransactionDataDsvFileParser returns a new custom transaction data parser
|
||||||
func CreateNewCustomTransactionDataDsvFileParser(fileType string, fileEncoding string) (CustomTransactionDataDsvFileParser, error) {
|
func CreateNewCustomTransactionDataDsvFileParser(fileType string, fileEncoding string) (CustomTransactionDataParser, error) {
|
||||||
separator, exists := supportedFileTypeSeparators[fileType]
|
separator, exists := supportedFileTypeSeparators[fileType]
|
||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
return nil, errs.ErrImportFileTypeNotSupported
|
return nil, errs.ErrImportFileTypeNotSupported
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if fileEncoding == "" {
|
||||||
|
return nil, errs.ErrImportFileEncodingIsEmpty
|
||||||
|
}
|
||||||
|
|
||||||
enc, exists := supportedFileEncodings[fileEncoding]
|
enc, exists := supportedFileEncodings[fileEncoding]
|
||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
@@ -198,6 +198,10 @@ func CreateNewCustomTransactionDataDsvFileImporter(fileType string, fileEncoding
|
|||||||
return nil, errs.ErrImportFileTypeNotSupported
|
return nil, errs.ErrImportFileTypeNotSupported
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if fileEncoding == "" {
|
||||||
|
return nil, errs.ErrImportFileEncodingIsEmpty
|
||||||
|
}
|
||||||
|
|
||||||
enc, exists := supportedFileEncodings[fileEncoding]
|
enc, exists := supportedFileEncodings[fileEncoding]
|
||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
+5
-5
@@ -1,4 +1,4 @@
|
|||||||
package dsv
|
package custom
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
@@ -25,13 +25,13 @@ func TestIsDelimiterSeparatedValuesFileType(t *testing.T) {
|
|||||||
assert.False(t, IsDelimiterSeparatedValuesFileType("ssv"))
|
assert.False(t, IsDelimiterSeparatedValuesFileType("ssv"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCustomTransactionDataDsvFileParser_ParseDsvFileLines(t *testing.T) {
|
func TestCustomTransactionDataDsvFileParser_ParseDataLines(t *testing.T) {
|
||||||
importer, err := CreateNewCustomTransactionDataDsvFileParser("custom_csv", "utf-8")
|
importer, err := CreateNewCustomTransactionDataDsvFileParser("custom_csv", "utf-8")
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
|
|
||||||
context := core.NewNullContext()
|
context := core.NewNullContext()
|
||||||
|
|
||||||
allLines, err := importer.ParseDsvFileLines(context, []byte(
|
allLines, err := importer.ParseDataLines(context, []byte(
|
||||||
"2024-09-01 00:00:00,B,123.45\n"+
|
"2024-09-01 00:00:00,B,123.45\n"+
|
||||||
"2024-09-01 01:23:45,I,0.12\n"))
|
"2024-09-01 01:23:45,I,0.12\n"))
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
@@ -51,7 +51,7 @@ func TestCustomTransactionDataDsvFileParser_ParseDsvFileLines(t *testing.T) {
|
|||||||
importer, err = CreateNewCustomTransactionDataDsvFileParser("custom_tsv", "utf-8")
|
importer, err = CreateNewCustomTransactionDataDsvFileParser("custom_tsv", "utf-8")
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
|
|
||||||
allLines, err = importer.ParseDsvFileLines(context, []byte(
|
allLines, err = importer.ParseDataLines(context, []byte(
|
||||||
"2024-09-01 12:34:56\tE\t1.00\n"+
|
"2024-09-01 12:34:56\tE\t1.00\n"+
|
||||||
"2024-09-01 23:59:59\tT\t0.05"))
|
"2024-09-01 23:59:59\tT\t0.05"))
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
@@ -71,7 +71,7 @@ func TestCustomTransactionDataDsvFileParser_ParseDsvFileLines(t *testing.T) {
|
|||||||
importer, err = CreateNewCustomTransactionDataDsvFileParser("custom_ssv", "utf-8")
|
importer, err = CreateNewCustomTransactionDataDsvFileParser("custom_ssv", "utf-8")
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
|
|
||||||
allLines, err = importer.ParseDsvFileLines(context, []byte(
|
allLines, err = importer.ParseDataLines(context, []byte(
|
||||||
"2024-09-01 12:34:56;E;1.00\n"+
|
"2024-09-01 12:34:56;E;1.00\n"+
|
||||||
"2024-09-01 23:59:59;T;0.05"))
|
"2024-09-01 23:59:59;T;0.05"))
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
package custom
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/converters/converter"
|
||||||
|
csvconverter "github.com/mayswind/ezbookkeeping/pkg/converters/csv"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/converters/excel"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
const customOOXMLExcelFileType = "custom_xlsx"
|
||||||
|
const customMSCFBExcelFileType = "custom_xls"
|
||||||
|
|
||||||
|
// customTransactionDataExcelFileImporter defines the structure of custom excel importer for transaction data
|
||||||
|
type customTransactionDataExcelFileImporter struct {
|
||||||
|
fileType string
|
||||||
|
columnIndexMapping map[datatable.TransactionDataTableColumn]int
|
||||||
|
transactionTypeNameMapping map[string]models.TransactionType
|
||||||
|
hasHeaderLine bool
|
||||||
|
timeFormat string
|
||||||
|
timezoneFormat string
|
||||||
|
amountDecimalSeparator string
|
||||||
|
amountDigitGroupingSymbol string
|
||||||
|
geoLocationSeparator string
|
||||||
|
geoLocationOrder converter.TransactionGeoLocationOrder
|
||||||
|
transactionTagSeparator string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseDataLines returns the parsed file lines for specified the excel file data
|
||||||
|
func (c *customTransactionDataExcelFileImporter) ParseDataLines(ctx core.Context, data []byte) ([][]string, error) {
|
||||||
|
var excelDataTable datatable.BasicDataTable
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if c.fileType == customOOXMLExcelFileType {
|
||||||
|
excelDataTable, err = excel.CreateNewExcelOOXMLFileBasicDataTable(data, false)
|
||||||
|
} else if c.fileType == customMSCFBExcelFileType {
|
||||||
|
excelDataTable, err = excel.CreateNewExcelMSCFBFileBasicDataTable(data, false)
|
||||||
|
} else {
|
||||||
|
return nil, errs.ErrImportFileTypeNotSupported
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
iterator := excelDataTable.DataRowIterator()
|
||||||
|
allLines := make([][]string, 0)
|
||||||
|
|
||||||
|
for iterator.HasNext() {
|
||||||
|
row := iterator.Next()
|
||||||
|
items := make([]string, row.ColumnCount())
|
||||||
|
|
||||||
|
for i := 0; i < row.ColumnCount(); i++ {
|
||||||
|
items[i] = strings.Trim(row.GetData(i), " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
allLines = append(allLines, items)
|
||||||
|
}
|
||||||
|
|
||||||
|
return allLines, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseImportedData returns the imported data by parsing the custom transaction dsv data
|
||||||
|
func (c *customTransactionDataExcelFileImporter) ParseImportedData(ctx core.Context, user *models.User, data []byte, defaultTimezone *time.Location, additionalOptions converter.TransactionDataImporterOptions, accountMap map[string]*models.Account, expenseCategoryMap map[string]map[string]*models.TransactionCategory, incomeCategoryMap map[string]map[string]*models.TransactionCategory, transferCategoryMap map[string]map[string]*models.TransactionCategory, tagMap map[string]*models.TransactionTag) (models.ImportedTransactionSlice, []*models.Account, []*models.TransactionCategory, []*models.TransactionCategory, []*models.TransactionCategory, []*models.TransactionTag, error) {
|
||||||
|
allLines, err := c.ParseDataLines(ctx, data)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, nil, nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
dataTable := csvconverter.CreateNewCustomCsvBasicDataTable(allLines, c.hasHeaderLine)
|
||||||
|
transactionDataTable := CreateNewCustomPlainTextDataTable(dataTable, c.columnIndexMapping, c.transactionTypeNameMapping, c.timeFormat, c.timezoneFormat, c.amountDecimalSeparator, c.amountDigitGroupingSymbol)
|
||||||
|
dataTableImporter := converter.CreateNewImporterWithTypeNameMapping(customTransactionTypeNameMapping, c.geoLocationSeparator, c.geoLocationOrder, c.transactionTagSeparator)
|
||||||
|
|
||||||
|
return dataTableImporter.ParseImportedData(ctx, user, transactionDataTable, defaultTimezone, additionalOptions, accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsCustomExcelFileType returns whether the file type is the custom excel file type
|
||||||
|
func IsCustomExcelFileType(fileType string) bool {
|
||||||
|
return fileType == customOOXMLExcelFileType || fileType == customMSCFBExcelFileType
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateNewCustomTransactionDataExcelFileParser returns a new custom transaction data parser
|
||||||
|
func CreateNewCustomTransactionDataExcelFileParser(fileType string) (CustomTransactionDataParser, error) {
|
||||||
|
if fileType != customOOXMLExcelFileType && fileType != customMSCFBExcelFileType {
|
||||||
|
return nil, errs.ErrImportFileTypeNotSupported
|
||||||
|
}
|
||||||
|
|
||||||
|
return &customTransactionDataExcelFileImporter{
|
||||||
|
fileType: fileType,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateNewCustomTransactionDataExcelFileImporter returns a new custom excel importer for transaction data
|
||||||
|
func CreateNewCustomTransactionDataExcelFileImporter(fileType string, columnIndexMapping map[datatable.TransactionDataTableColumn]int, transactionTypeNameMapping map[string]models.TransactionType, hasHeaderLine bool, timeFormat string, timezoneFormat string, amountDecimalSeparator string, amountDigitGroupingSymbol string, geoLocationSeparator string, geoLocationOrder string, transactionTagSeparator string) (converter.TransactionDataImporter, error) {
|
||||||
|
if fileType != customOOXMLExcelFileType && fileType != customMSCFBExcelFileType {
|
||||||
|
return nil, errs.ErrImportFileTypeNotSupported
|
||||||
|
}
|
||||||
|
|
||||||
|
if geoLocationOrder == "" {
|
||||||
|
geoLocationOrder = string(converter.TRANSACTION_GEO_LOCATION_ORDER_LONGITUDE_LATITUDE)
|
||||||
|
} else if geoLocationOrder != string(converter.TRANSACTION_GEO_LOCATION_ORDER_LONGITUDE_LATITUDE) &&
|
||||||
|
geoLocationOrder != string(converter.TRANSACTION_GEO_LOCATION_ORDER_LATITUDE_LONGITUDE) {
|
||||||
|
return nil, errs.ErrImportFileTypeNotSupported
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, exists := columnIndexMapping[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME]; !exists {
|
||||||
|
return nil, errs.ErrMissingRequiredFieldInHeaderRow
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, exists := columnIndexMapping[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE]; !exists {
|
||||||
|
return nil, errs.ErrMissingRequiredFieldInHeaderRow
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, exists := columnIndexMapping[datatable.TRANSACTION_DATA_TABLE_AMOUNT]; !exists {
|
||||||
|
return nil, errs.ErrMissingRequiredFieldInHeaderRow
|
||||||
|
}
|
||||||
|
|
||||||
|
return &customTransactionDataExcelFileImporter{
|
||||||
|
fileType: fileType,
|
||||||
|
columnIndexMapping: columnIndexMapping,
|
||||||
|
transactionTypeNameMapping: transactionTypeNameMapping,
|
||||||
|
hasHeaderLine: hasHeaderLine,
|
||||||
|
timeFormat: timeFormat,
|
||||||
|
timezoneFormat: timezoneFormat,
|
||||||
|
amountDecimalSeparator: amountDecimalSeparator,
|
||||||
|
amountDigitGroupingSymbol: amountDigitGroupingSymbol,
|
||||||
|
geoLocationSeparator: geoLocationSeparator,
|
||||||
|
geoLocationOrder: converter.TransactionGeoLocationOrder(geoLocationOrder),
|
||||||
|
transactionTagSeparator: transactionTagSeparator,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
package custom
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIsCustomExcelFileType(t *testing.T) {
|
||||||
|
assert.True(t, IsCustomExcelFileType("custom_xlsx"))
|
||||||
|
assert.True(t, IsCustomExcelFileType("custom_xls"))
|
||||||
|
|
||||||
|
assert.False(t, IsCustomExcelFileType("xlsx"))
|
||||||
|
assert.False(t, IsCustomExcelFileType("xls"))
|
||||||
|
assert.False(t, IsCustomExcelFileType("excel"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCustomTransactionDataParser_ParseOOXMLExcelDataLines_EmptyData(t *testing.T) {
|
||||||
|
importer, err := CreateNewCustomTransactionDataExcelFileParser("custom_xlsx")
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
context := core.NewNullContext()
|
||||||
|
|
||||||
|
testdata, err := os.ReadFile("../../../testdata/empty_excel_file.xlsx")
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
allLines, err := importer.ParseDataLines(context, testdata)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, 0, len(allLines))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCustomTransactionDataParser_ParseOOXMLExcelDataLines_SingleSheet(t *testing.T) {
|
||||||
|
importer, err := CreateNewCustomTransactionDataExcelFileParser("custom_xlsx")
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
context := core.NewNullContext()
|
||||||
|
|
||||||
|
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xlsx")
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
allLines, err := importer.ParseDataLines(context, testdata)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, 3, len(allLines))
|
||||||
|
|
||||||
|
assert.Equal(t, 3, len(allLines[0]))
|
||||||
|
assert.Equal(t, "A1", allLines[0][0])
|
||||||
|
assert.Equal(t, "B1", allLines[0][1])
|
||||||
|
assert.Equal(t, "C1", allLines[0][2])
|
||||||
|
|
||||||
|
assert.Equal(t, 3, len(allLines[1]))
|
||||||
|
assert.Equal(t, "A2", allLines[1][0])
|
||||||
|
assert.Equal(t, "B2", allLines[1][1])
|
||||||
|
assert.Equal(t, "C2", allLines[1][2])
|
||||||
|
|
||||||
|
assert.Equal(t, 3, len(allLines[2]))
|
||||||
|
assert.Equal(t, "A3", allLines[2][0])
|
||||||
|
assert.Equal(t, "B3", allLines[2][1])
|
||||||
|
assert.Equal(t, "C3", allLines[2][2])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCustomTransactionDataParser_ParseOOXMLExcelDataLines_MultipleSheet(t *testing.T) {
|
||||||
|
importer, err := CreateNewCustomTransactionDataExcelFileParser("custom_xlsx")
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
context := core.NewNullContext()
|
||||||
|
|
||||||
|
testdata, err := os.ReadFile("../../../testdata/multiple_sheets_excel_file.xlsx")
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
allLines, err := importer.ParseDataLines(context, testdata)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, 9, len(allLines))
|
||||||
|
|
||||||
|
assert.Equal(t, 3, len(allLines[0]))
|
||||||
|
assert.Equal(t, "A1", allLines[0][0])
|
||||||
|
assert.Equal(t, "B1", allLines[0][1])
|
||||||
|
assert.Equal(t, "C1", allLines[0][2])
|
||||||
|
|
||||||
|
assert.Equal(t, 3, len(allLines[1]))
|
||||||
|
assert.Equal(t, "1-A2", allLines[1][0])
|
||||||
|
assert.Equal(t, "1-B2", allLines[1][1])
|
||||||
|
assert.Equal(t, "1-C2", allLines[1][2])
|
||||||
|
|
||||||
|
assert.Equal(t, 3, len(allLines[2]))
|
||||||
|
assert.Equal(t, "1-A3", allLines[2][0])
|
||||||
|
assert.Equal(t, "1-B3", allLines[2][1])
|
||||||
|
assert.Equal(t, "1-C3", allLines[2][2])
|
||||||
|
|
||||||
|
assert.Equal(t, 3, len(allLines[3]))
|
||||||
|
assert.Equal(t, "A1", allLines[3][0])
|
||||||
|
assert.Equal(t, "B1", allLines[3][1])
|
||||||
|
assert.Equal(t, "C1", allLines[3][2])
|
||||||
|
|
||||||
|
assert.Equal(t, 2, len(allLines[4]))
|
||||||
|
assert.Equal(t, "3-A2", allLines[4][0])
|
||||||
|
assert.Equal(t, "3-B2", allLines[4][1])
|
||||||
|
|
||||||
|
assert.Equal(t, 3, len(allLines[5]))
|
||||||
|
assert.Equal(t, "A1", allLines[5][0])
|
||||||
|
assert.Equal(t, "B1", allLines[5][1])
|
||||||
|
assert.Equal(t, "C1", allLines[5][2])
|
||||||
|
|
||||||
|
assert.Equal(t, 3, len(allLines[6]))
|
||||||
|
assert.Equal(t, "A1", allLines[6][0])
|
||||||
|
assert.Equal(t, "B1", allLines[6][1])
|
||||||
|
assert.Equal(t, "C1", allLines[6][2])
|
||||||
|
|
||||||
|
assert.Equal(t, 3, len(allLines[7]))
|
||||||
|
assert.Equal(t, "5-A2", allLines[7][0])
|
||||||
|
assert.Equal(t, "5-B2", allLines[7][1])
|
||||||
|
assert.Equal(t, "5-C2", allLines[7][2])
|
||||||
|
|
||||||
|
assert.Equal(t, 3, len(allLines[8]))
|
||||||
|
assert.Equal(t, "5-A3", allLines[8][0])
|
||||||
|
assert.Equal(t, "5-B3", allLines[8][1])
|
||||||
|
assert.Equal(t, "5-C3", allLines[8][2])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCustomTransactionDataParser_ParseOOXMLExcelDataLines_MultipleSheetWithDifferentColumnCount(t *testing.T) {
|
||||||
|
importer, err := CreateNewCustomTransactionDataExcelFileParser("custom_xlsx")
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
context := core.NewNullContext()
|
||||||
|
|
||||||
|
testdata, err := os.ReadFile("../../../testdata/multiple_sheets_with_different_header_row_excel_file.xlsx")
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
_, err = importer.ParseDataLines(context, testdata)
|
||||||
|
assert.EqualError(t, err, errs.ErrFieldsInMultiTableAreDifferent.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCustomTransactionDataParser_ParseMSCFBExcelDataLines_EmptyData(t *testing.T) {
|
||||||
|
importer, err := CreateNewCustomTransactionDataExcelFileParser("custom_xls")
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
context := core.NewNullContext()
|
||||||
|
|
||||||
|
testdata, err := os.ReadFile("../../../testdata/empty_excel_file.xls")
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
allLines, err := importer.ParseDataLines(context, testdata)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, 0, len(allLines))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCustomTransactionDataParser_ParseMSCFBExcelDataLines_SingleSheet(t *testing.T) {
|
||||||
|
importer, err := CreateNewCustomTransactionDataExcelFileParser("custom_xls")
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
context := core.NewNullContext()
|
||||||
|
|
||||||
|
testdata, err := os.ReadFile("../../../testdata/simple_excel_file.xls")
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
allLines, err := importer.ParseDataLines(context, testdata)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, 3, len(allLines))
|
||||||
|
|
||||||
|
assert.Equal(t, 3, len(allLines[0]))
|
||||||
|
assert.Equal(t, "A1", allLines[0][0])
|
||||||
|
assert.Equal(t, "B1", allLines[0][1])
|
||||||
|
assert.Equal(t, "C1", allLines[0][2])
|
||||||
|
|
||||||
|
assert.Equal(t, 3, len(allLines[1]))
|
||||||
|
assert.Equal(t, "A2", allLines[1][0])
|
||||||
|
assert.Equal(t, "B2", allLines[1][1])
|
||||||
|
assert.Equal(t, "C2", allLines[1][2])
|
||||||
|
|
||||||
|
assert.Equal(t, 3, len(allLines[2]))
|
||||||
|
assert.Equal(t, "A3", allLines[2][0])
|
||||||
|
assert.Equal(t, "B3", allLines[2][1])
|
||||||
|
assert.Equal(t, "C3", allLines[2][2])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCustomTransactionDataParser_ParseMSCFBExcelDataLines_MultipleSheet(t *testing.T) {
|
||||||
|
importer, err := CreateNewCustomTransactionDataExcelFileParser("custom_xls")
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
context := core.NewNullContext()
|
||||||
|
|
||||||
|
testdata, err := os.ReadFile("../../../testdata/multiple_sheets_excel_file.xls")
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
allLines, err := importer.ParseDataLines(context, testdata)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, 9, len(allLines))
|
||||||
|
|
||||||
|
assert.Equal(t, 3, len(allLines[0]))
|
||||||
|
assert.Equal(t, "A1", allLines[0][0])
|
||||||
|
assert.Equal(t, "B1", allLines[0][1])
|
||||||
|
assert.Equal(t, "C1", allLines[0][2])
|
||||||
|
|
||||||
|
assert.Equal(t, 3, len(allLines[1]))
|
||||||
|
assert.Equal(t, "1-A2", allLines[1][0])
|
||||||
|
assert.Equal(t, "1-B2", allLines[1][1])
|
||||||
|
assert.Equal(t, "1-C2", allLines[1][2])
|
||||||
|
|
||||||
|
assert.Equal(t, 3, len(allLines[2]))
|
||||||
|
assert.Equal(t, "1-A3", allLines[2][0])
|
||||||
|
assert.Equal(t, "1-B3", allLines[2][1])
|
||||||
|
assert.Equal(t, "1-C3", allLines[2][2])
|
||||||
|
|
||||||
|
assert.Equal(t, 3, len(allLines[3]))
|
||||||
|
assert.Equal(t, "A1", allLines[3][0])
|
||||||
|
assert.Equal(t, "B1", allLines[3][1])
|
||||||
|
assert.Equal(t, "C1", allLines[3][2])
|
||||||
|
|
||||||
|
assert.Equal(t, 3, len(allLines[4]))
|
||||||
|
assert.Equal(t, "3-A2", allLines[4][0])
|
||||||
|
assert.Equal(t, "3-B2", allLines[4][1])
|
||||||
|
assert.Equal(t, "", allLines[4][2])
|
||||||
|
|
||||||
|
assert.Equal(t, 3, len(allLines[5]))
|
||||||
|
assert.Equal(t, "A1", allLines[5][0])
|
||||||
|
assert.Equal(t, "B1", allLines[5][1])
|
||||||
|
assert.Equal(t, "C1", allLines[5][2])
|
||||||
|
|
||||||
|
assert.Equal(t, 3, len(allLines[6]))
|
||||||
|
assert.Equal(t, "A1", allLines[6][0])
|
||||||
|
assert.Equal(t, "B1", allLines[6][1])
|
||||||
|
assert.Equal(t, "C1", allLines[6][2])
|
||||||
|
|
||||||
|
assert.Equal(t, 3, len(allLines[7]))
|
||||||
|
assert.Equal(t, "5-A2", allLines[7][0])
|
||||||
|
assert.Equal(t, "5-B2", allLines[7][1])
|
||||||
|
assert.Equal(t, "5-C2", allLines[7][2])
|
||||||
|
|
||||||
|
assert.Equal(t, 3, len(allLines[8]))
|
||||||
|
assert.Equal(t, "5-A3", allLines[8][0])
|
||||||
|
assert.Equal(t, "5-B3", allLines[8][1])
|
||||||
|
assert.Equal(t, "5-C3", allLines[8][2])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCustomTransactionDataParser_ParseMSCFBExcelDataLines_MultipleSheetWithDifferentColumnCount(t *testing.T) {
|
||||||
|
importer, err := CreateNewCustomTransactionDataExcelFileParser("custom_xls")
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
context := core.NewNullContext()
|
||||||
|
|
||||||
|
testdata, err := os.ReadFile("../../../testdata/multiple_sheets_with_different_header_row_excel_file.xls")
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
_, err = importer.ParseDataLines(context, testdata)
|
||||||
|
assert.EqualError(t, err, errs.ErrFieldsInMultiTableAreDifferent.Message)
|
||||||
|
}
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package dsv
|
package custom
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
@@ -86,7 +86,7 @@ func (t *ExcelMSCFBFileBasicDataTable) DataRowIterator() datatable.BasicDataTabl
|
|||||||
// ColumnCount returns the total count of column in this data row
|
// ColumnCount returns the total count of column in this data row
|
||||||
func (r *ExcelMSCFBFileBasicDataTableRow) ColumnCount() int {
|
func (r *ExcelMSCFBFileBasicDataTableRow) ColumnCount() int {
|
||||||
row := r.sheet.Row(r.rowIndex)
|
row := r.sheet.Row(r.rowIndex)
|
||||||
return row.LastCol() + 1
|
return row.LastCol()
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetData returns the data in the specified column index
|
// GetData returns the data in the specified column index
|
||||||
@@ -195,7 +195,10 @@ func CreateNewExcelMSCFBFileBasicDataTable(data []byte, hasTitleLine bool) (data
|
|||||||
}
|
}
|
||||||
|
|
||||||
if i == 0 {
|
if i == 0 {
|
||||||
for j := 0; j <= row.LastCol(); j++ {
|
// row.LastCol() returns "colMac" in the "Row" struct, that is an unsigned integer that specifies the one-based index of the last column.
|
||||||
|
// But row.FirstCol() returns "colMic" in the "Row" struct, that is an unsigned integer that specifies the zero-based index of the first column.
|
||||||
|
// Reference: https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-xls/4aab09eb-49ed-4d01-a3b1-1d726247d3c2
|
||||||
|
for j := 0; j < row.LastCol(); j++ {
|
||||||
headerItem := row.Col(j)
|
headerItem := row.Col(j)
|
||||||
|
|
||||||
if headerItem == "" {
|
if headerItem == "" {
|
||||||
@@ -205,7 +208,7 @@ func CreateNewExcelMSCFBFileBasicDataTable(data []byte, hasTitleLine bool) (data
|
|||||||
firstRowItems = append(firstRowItems, headerItem)
|
firstRowItems = append(firstRowItems, headerItem)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
for j := 0; j <= min(row.LastCol(), len(firstRowItems)-1); j++ {
|
for j := 0; j < min(row.LastCol(), len(firstRowItems)); j++ {
|
||||||
headerItem := row.Col(j)
|
headerItem := row.Col(j)
|
||||||
|
|
||||||
if headerItem != firstRowItems[j] {
|
if headerItem != firstRowItems[j] {
|
||||||
|
|||||||
@@ -300,10 +300,10 @@ func TestExcelMSCFBFileBasicDataRowColumnCount(t *testing.T) {
|
|||||||
iterator := datatable.DataRowIterator()
|
iterator := datatable.DataRowIterator()
|
||||||
|
|
||||||
row1 := iterator.Next()
|
row1 := iterator.Next()
|
||||||
assert.EqualValues(t, 4, row1.ColumnCount())
|
assert.EqualValues(t, 3, row1.ColumnCount())
|
||||||
|
|
||||||
row2 := iterator.Next()
|
row2 := iterator.Next()
|
||||||
assert.EqualValues(t, 4, row2.ColumnCount())
|
assert.EqualValues(t, 3, row2.ColumnCount())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExcelMSCFBFileBasicDataRowGetData(t *testing.T) {
|
func TestExcelMSCFBFileBasicDataRowGetData(t *testing.T) {
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import (
|
|||||||
"github.com/mayswind/ezbookkeeping/pkg/converters/beancount"
|
"github.com/mayswind/ezbookkeeping/pkg/converters/beancount"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/converters/camt"
|
"github.com/mayswind/ezbookkeeping/pkg/converters/camt"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/converters/converter"
|
"github.com/mayswind/ezbookkeeping/pkg/converters/converter"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/converters/custom"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
|
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/converters/default"
|
"github.com/mayswind/ezbookkeeping/pkg/converters/default"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/converters/dsv"
|
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/converters/feidee"
|
"github.com/mayswind/ezbookkeeping/pkg/converters/feidee"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/converters/fireflyIII"
|
"github.com/mayswind/ezbookkeeping/pkg/converters/fireflyIII"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/converters/gnucash"
|
"github.com/mayswind/ezbookkeeping/pkg/converters/gnucash"
|
||||||
@@ -85,17 +85,29 @@ func GetTransactionDataImporter(fileType string) (converter.TransactionDataImpor
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsCustomDelimiterSeparatedValuesFileType returns whether the file type is the delimiter-separated values file type
|
// IsCustomFileFormatFileType returns whether the file type is the custom file format
|
||||||
func IsCustomDelimiterSeparatedValuesFileType(fileType string) bool {
|
func IsCustomFileFormatFileType(fileType string) bool {
|
||||||
return dsv.IsDelimiterSeparatedValuesFileType(fileType)
|
return custom.IsDelimiterSeparatedValuesFileType(fileType) || custom.IsCustomExcelFileType(fileType)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateNewDelimiterSeparatedValuesDataParser returns a new delimiter-separated values data parser according to the file type and encoding
|
// CreateNewCustomFileFormatTransactionDataParser returns a new custom transaction data parser according to the file type and encoding
|
||||||
func CreateNewDelimiterSeparatedValuesDataParser(fileType string, fileEncoding string) (dsv.CustomTransactionDataDsvFileParser, error) {
|
func CreateNewCustomFileFormatTransactionDataParser(fileType string, fileEncoding string) (custom.CustomTransactionDataParser, error) {
|
||||||
return dsv.CreateNewCustomTransactionDataDsvFileParser(fileType, fileEncoding)
|
if custom.IsDelimiterSeparatedValuesFileType(fileType) {
|
||||||
|
return custom.CreateNewCustomTransactionDataDsvFileParser(fileType, fileEncoding)
|
||||||
|
} else if custom.IsCustomExcelFileType(fileType) {
|
||||||
|
return custom.CreateNewCustomTransactionDataExcelFileParser(fileType)
|
||||||
|
} else {
|
||||||
|
return nil, errs.ErrImportFileTypeNotSupported
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateNewDelimiterSeparatedValuesDataImporter returns a new delimiter-separated values data importer according to the file type and encoding
|
// CreateNewCustomTransactionDataImporter returns a new custom transaction data importer according to the file type and encoding
|
||||||
func CreateNewDelimiterSeparatedValuesDataImporter(fileType string, fileEncoding string, columnIndexMapping map[datatable.TransactionDataTableColumn]int, transactionTypeNameMapping map[string]models.TransactionType, hasHeaderLine bool, timeFormat string, timezoneFormat string, amountDecimalSeparator string, amountDigitGroupingSymbol string, geoLocationSeparator string, geoLocationOrder string, transactionTagSeparator string) (converter.TransactionDataImporter, error) {
|
func CreateNewCustomTransactionDataImporter(fileType string, fileEncoding string, columnIndexMapping map[datatable.TransactionDataTableColumn]int, transactionTypeNameMapping map[string]models.TransactionType, hasHeaderLine bool, timeFormat string, timezoneFormat string, amountDecimalSeparator string, amountDigitGroupingSymbol string, geoLocationSeparator string, geoLocationOrder string, transactionTagSeparator string) (converter.TransactionDataImporter, error) {
|
||||||
return dsv.CreateNewCustomTransactionDataDsvFileImporter(fileType, fileEncoding, columnIndexMapping, transactionTypeNameMapping, hasHeaderLine, timeFormat, timezoneFormat, amountDecimalSeparator, amountDigitGroupingSymbol, geoLocationSeparator, geoLocationOrder, transactionTagSeparator)
|
if custom.IsDelimiterSeparatedValuesFileType(fileType) {
|
||||||
|
return custom.CreateNewCustomTransactionDataDsvFileImporter(fileType, fileEncoding, columnIndexMapping, transactionTypeNameMapping, hasHeaderLine, timeFormat, timezoneFormat, amountDecimalSeparator, amountDigitGroupingSymbol, geoLocationSeparator, geoLocationOrder, transactionTagSeparator)
|
||||||
|
} else if custom.IsCustomExcelFileType(fileType) {
|
||||||
|
return custom.CreateNewCustomTransactionDataExcelFileImporter(fileType, columnIndexMapping, transactionTypeNameMapping, hasHeaderLine, timeFormat, timezoneFormat, amountDecimalSeparator, amountDigitGroupingSymbol, geoLocationSeparator, geoLocationOrder, transactionTagSeparator)
|
||||||
|
} else {
|
||||||
|
return nil, errs.ErrImportFileTypeNotSupported
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -170,6 +170,29 @@ func TestWeChatPayCsvFileImporterParseImportedData_ParseInvalidType(t *testing.T
|
|||||||
assert.EqualError(t, err, errs.ErrNotFoundTransactionDataInFile.Message)
|
assert.EqualError(t, err, errs.ErrNotFoundTransactionDataInFile.Message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWeChatPayCsvFileImporterParseImportedData_ParseAmountWithThousandSeparator(t *testing.T) {
|
||||||
|
importer := WeChatPayTransactionDataCsvFileImporter
|
||||||
|
context := core.NewNullContext()
|
||||||
|
|
||||||
|
user := &models.User{
|
||||||
|
Uid: 1234567890,
|
||||||
|
DefaultCurrency: "CNY",
|
||||||
|
}
|
||||||
|
|
||||||
|
data1 := "微信支付账单明细,,,,\n" +
|
||||||
|
"微信昵称:[xxx],,,,\n" +
|
||||||
|
"起始时间:[2024-01-01 00:00:00] 终止时间:[2024-09-01 23:59:59],,,,\n" +
|
||||||
|
",,,,\n" +
|
||||||
|
"----------------------微信支付账单明细列表--------------------,,,,\n" +
|
||||||
|
"交易时间,交易类型,收/支,金额(元),支付方式,当前状态\n" +
|
||||||
|
"2024-09-01 01:23:45,二维码收款,收入,\"¥1,234.56\",/,已收钱\n"
|
||||||
|
allNewTransactions, _, _, _, _, _, err := importer.ParseImportedData(context, user, []byte(data1), time.UTC, converter.DefaultImporterOptions, nil, nil, nil, nil, nil)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, 1, len(allNewTransactions))
|
||||||
|
assert.Equal(t, int64(123456), allNewTransactions[0].Amount)
|
||||||
|
}
|
||||||
|
|
||||||
func TestWeChatPayCsvFileImporterParseImportedData_ParseInvalidAmount(t *testing.T) {
|
func TestWeChatPayCsvFileImporterParseImportedData_ParseInvalidAmount(t *testing.T) {
|
||||||
importer := WeChatPayTransactionDataCsvFileImporter
|
importer := WeChatPayTransactionDataCsvFileImporter
|
||||||
context := core.NewNullContext()
|
context := core.NewNullContext()
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ func (p *weChatPayTransactionDataRowParser) Parse(ctx core.Context, user *models
|
|||||||
}
|
}
|
||||||
|
|
||||||
if p.hasOriginalColumn(wechatPayTransactionAmountColumnName) {
|
if p.hasOriginalColumn(wechatPayTransactionAmountColumnName) {
|
||||||
amount, success := utils.ParseFirstConsecutiveNumber(dataRow.GetData(wechatPayTransactionAmountColumnName))
|
amount, success := utils.ParseFirstConsecutiveNumber(strings.ReplaceAll(dataRow.GetData(wechatPayTransactionAmountColumnName), ",", ""))
|
||||||
|
|
||||||
if !success {
|
if !success {
|
||||||
log.Errorf(ctx, "[wechat_pay_transaction_data_row_parser.Parse] cannot parse amount \"%s\" of transaction in row \"%s\"", dataRow.GetData(wechatPayTransactionAmountColumnName), rowId)
|
log.Errorf(ctx, "[wechat_pay_transaction_data_row_parser.Parse] cannot parse amount \"%s\" of transaction in row \"%s\"", dataRow.GetData(wechatPayTransactionAmountColumnName), rowId)
|
||||||
|
|||||||
@@ -1,4 +1,21 @@
|
|||||||
package core
|
package core
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
// ApplicationName represents the application name
|
// ApplicationName represents the application name
|
||||||
const ApplicationName = "ezBookkeeping"
|
const ApplicationName = "ezBookkeeping"
|
||||||
|
|
||||||
|
// Version, CommitHash and BuildTime are set at build
|
||||||
|
var (
|
||||||
|
Version string
|
||||||
|
CommitHash string
|
||||||
|
BuildTime string
|
||||||
|
)
|
||||||
|
|
||||||
|
func GetOutgoingUserAgent() string {
|
||||||
|
if Version == "" {
|
||||||
|
return ApplicationName
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("%s/%s", ApplicationName, Version)
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,15 @@ import (
|
|||||||
"github.com/golang-jwt/jwt/v5"
|
"github.com/golang-jwt/jwt/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// TokenUserAgentCreatedViaCli is the user agent of token created via cli
|
||||||
|
const TokenUserAgentCreatedViaCli = ApplicationName + " Cli"
|
||||||
|
|
||||||
|
// TokenUserAgentForAPI is the user agent for API token
|
||||||
|
const TokenUserAgentForAPI = ApplicationName + " API"
|
||||||
|
|
||||||
|
// TokenUserAgentForMCP is the user agent for MCP token
|
||||||
|
const TokenUserAgentForMCP = ApplicationName + " MCP"
|
||||||
|
|
||||||
// TokenType represents token type
|
// TokenType represents token type
|
||||||
type TokenType byte
|
type TokenType byte
|
||||||
|
|
||||||
|
|||||||
@@ -108,6 +108,6 @@ func (e *CommonHttpExchangeRatesDataProvider) GetLatestExchangeRates(c core.Cont
|
|||||||
func newCommonHttpExchangeRatesDataProvider(config *settings.Config, dataSource HttpExchangeRatesDataSource) *CommonHttpExchangeRatesDataProvider {
|
func newCommonHttpExchangeRatesDataProvider(config *settings.Config, dataSource HttpExchangeRatesDataSource) *CommonHttpExchangeRatesDataProvider {
|
||||||
return &CommonHttpExchangeRatesDataProvider{
|
return &CommonHttpExchangeRatesDataProvider{
|
||||||
dataSource: dataSource,
|
dataSource: dataSource,
|
||||||
httpClient: httpclient.NewHttpClient(config.ExchangeRatesRequestTimeout, config.ExchangeRatesProxy, config.ExchangeRatesSkipTLSVerify, settings.GetUserAgent(), config.EnableDebugLog),
|
httpClient: httpclient.NewHttpClient(config.ExchangeRatesRequestTimeout, config.ExchangeRatesProxy, config.ExchangeRatesSkipTLSVerify, core.GetOutgoingUserAgent(), config.EnableDebugLog),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,21 +14,6 @@ import (
|
|||||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestExchangeRatesApiLatestExchangeRateHandler_ReserveBankOfAustraliaDataSource(t *testing.T) {
|
|
||||||
exchangeRateResponse := executeLatestExchangeRateHandler(t, settings.ReserveBankOfAustraliaDataSource)
|
|
||||||
|
|
||||||
if exchangeRateResponse == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Equal(t, "AUD", exchangeRateResponse.BaseCurrency)
|
|
||||||
|
|
||||||
supportedCurrencyCodes := []string{"CAD", "CHF", "CNY", "EUR", "GBP", "HKD", "IDR", "INR", "JPY", "KRW",
|
|
||||||
"MYR", "NZD", "PGK", "PHP", "SGD", "THB", "TWD", "USD", "VND"}
|
|
||||||
|
|
||||||
checkExchangeRatesHaveSpecifiedCurrencies(t, exchangeRateResponse.BaseCurrency, supportedCurrencyCodes, exchangeRateResponse.ExchangeRates)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExchangeRatesApiLatestExchangeRateHandler_BankOfCanadaDataSource(t *testing.T) {
|
func TestExchangeRatesApiLatestExchangeRateHandler_BankOfCanadaDataSource(t *testing.T) {
|
||||||
exchangeRateResponse := executeLatestExchangeRateHandler(t, settings.BankOfCanadaDataSource)
|
exchangeRateResponse := executeLatestExchangeRateHandler(t, settings.BankOfCanadaDataSource)
|
||||||
|
|
||||||
|
|||||||
@@ -19,10 +19,7 @@ var (
|
|||||||
|
|
||||||
// InitializeExchangeRatesDataSource initializes the current exchange rates data source according to the config
|
// InitializeExchangeRatesDataSource initializes the current exchange rates data source according to the config
|
||||||
func InitializeExchangeRatesDataSource(config *settings.Config) error {
|
func InitializeExchangeRatesDataSource(config *settings.Config) error {
|
||||||
if config.ExchangeRatesDataSource == settings.ReserveBankOfAustraliaDataSource {
|
if config.ExchangeRatesDataSource == settings.BankOfCanadaDataSource {
|
||||||
Container.current = newCommonHttpExchangeRatesDataProvider(config, &ReserveBankOfAustraliaDataSource{})
|
|
||||||
return nil
|
|
||||||
} else if config.ExchangeRatesDataSource == settings.BankOfCanadaDataSource {
|
|
||||||
Container.current = newCommonHttpExchangeRatesDataProvider(config, &BankOfCanadaDataSource{})
|
Container.current = newCommonHttpExchangeRatesDataProvider(config, &BankOfCanadaDataSource{})
|
||||||
return nil
|
return nil
|
||||||
} else if config.ExchangeRatesDataSource == settings.CzechNationalBankDataSource {
|
} else if config.ExchangeRatesDataSource == settings.CzechNationalBankDataSource {
|
||||||
|
|||||||
@@ -1,161 +0,0 @@
|
|||||||
package exchangerates
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/xml"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"golang.org/x/net/html/charset"
|
|
||||||
|
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/validators"
|
|
||||||
)
|
|
||||||
|
|
||||||
const reserveBankOfAustraliaExchangeRateUrl = "https://www.rba.gov.au/rss/rss-cb-exchange-rates.xml"
|
|
||||||
const reserveBankOfAustraliaExchangeRateReferenceUrl = "https://www.rba.gov.au/statistics/frequency/exchange-rates.html"
|
|
||||||
const reserveBankOfAustraliaDataSource = "Reserve Bank of Australia"
|
|
||||||
const reserveBankOfAustraliaBaseCurrency = "AUD"
|
|
||||||
|
|
||||||
const reserveBankOfAustraliaDataUpdateDateFormat = "2006-01-02T15:04:05Z07:00"
|
|
||||||
|
|
||||||
// ReserveBankOfAustraliaDataSource defines the structure of exchange rates data source of the reserve bank of Australia
|
|
||||||
type ReserveBankOfAustraliaDataSource struct {
|
|
||||||
HttpExchangeRatesDataSource
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReserveBankOfAustraliaData represents the whole data from the reserve bank of Australia
|
|
||||||
type ReserveBankOfAustraliaData struct {
|
|
||||||
XMLName xml.Name `xml:"RDF"`
|
|
||||||
Channel *ReserveBankOfAustraliaRssChannel `xml:"channel"`
|
|
||||||
Items []*ReserveBankOfAustraliaRssItem `xml:"item"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReserveBankOfAustraliaRssChannel represents the rss channel from the reserve bank of Australia
|
|
||||||
type ReserveBankOfAustraliaRssChannel struct {
|
|
||||||
Date string `xml:"date"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReserveBankOfAustraliaRssItem represents the rss item from the reserve bank of Australia
|
|
||||||
type ReserveBankOfAustraliaRssItem struct {
|
|
||||||
Statistics *ReserveBankOfAustraliaItemStatistics `xml:"statistics"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReserveBankOfAustraliaItemStatistics represents the item statistics from the reserve bank of Australia
|
|
||||||
type ReserveBankOfAustraliaItemStatistics struct {
|
|
||||||
ExchangeRate *ReserveBankOfAustraliaExchangeRate `xml:"exchangeRate"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReserveBankOfAustraliaExchangeRate represents the exchange rate from the reserve bank of Australia
|
|
||||||
type ReserveBankOfAustraliaExchangeRate struct {
|
|
||||||
BaseCurrency string `xml:"baseCurrency"`
|
|
||||||
TargetCurrency string `xml:"targetCurrency"`
|
|
||||||
Observation *ReserveBankOfAustraliaExchangeRateObservation `xml:"observation"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReserveBankOfAustraliaExchangeRateObservation represents the exchange rate data from the reserve bank of Australia
|
|
||||||
type ReserveBankOfAustraliaExchangeRateObservation struct {
|
|
||||||
Value string `xml:"value"`
|
|
||||||
Unit string `xml:"unit"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ToLatestExchangeRateResponse returns a view-object according to original data from the reserve bank of Australia
|
|
||||||
func (e *ReserveBankOfAustraliaData) ToLatestExchangeRateResponse(c core.Context) *models.LatestExchangeRateResponse {
|
|
||||||
if e.Channel == nil {
|
|
||||||
log.Errorf(c, "[reserve_bank_of_australia_datasource.ToLatestExchangeRateResponse] rss channel does not exist")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(e.Items) < 1 {
|
|
||||||
log.Errorf(c, "[reserve_bank_of_australia_datasource.ToLatestExchangeRateResponse] rss items is empty")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
exchangeRates := make(models.LatestExchangeRateSlice, 0, len(e.Items))
|
|
||||||
|
|
||||||
for i := 0; i < len(e.Items); i++ {
|
|
||||||
item := e.Items[i]
|
|
||||||
|
|
||||||
if item.Statistics == nil || item.Statistics.ExchangeRate == nil || item.Statistics.ExchangeRate.Observation == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if item.Statistics.ExchangeRate.BaseCurrency != reserveBankOfAustraliaBaseCurrency || item.Statistics.ExchangeRate.Observation.Unit != reserveBankOfAustraliaBaseCurrency {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, exists := validators.AllCurrencyNames[item.Statistics.ExchangeRate.TargetCurrency]; !exists {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := utils.StringToFloat64(item.Statistics.ExchangeRate.Observation.Value); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
exchangeRates = append(exchangeRates, item.Statistics.ExchangeRate.ToLatestExchangeRate())
|
|
||||||
}
|
|
||||||
|
|
||||||
updateDateTime := e.Channel.Date
|
|
||||||
updateTime, err := time.Parse(reserveBankOfAustraliaDataUpdateDateFormat, updateDateTime)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf(c, "[reserve_bank_of_australia_datasource.ToLatestExchangeRateResponse] failed to parse update date, datetime is %s", updateDateTime)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
latestExchangeRateResp := &models.LatestExchangeRateResponse{
|
|
||||||
DataSource: reserveBankOfAustraliaDataSource,
|
|
||||||
ReferenceUrl: reserveBankOfAustraliaExchangeRateReferenceUrl,
|
|
||||||
UpdateTime: updateTime.Unix(),
|
|
||||||
BaseCurrency: reserveBankOfAustraliaBaseCurrency,
|
|
||||||
ExchangeRates: exchangeRates,
|
|
||||||
}
|
|
||||||
|
|
||||||
return latestExchangeRateResp
|
|
||||||
}
|
|
||||||
|
|
||||||
// ToLatestExchangeRate returns a data pair according to original data from the reserve bank of Australia
|
|
||||||
func (e *ReserveBankOfAustraliaExchangeRate) ToLatestExchangeRate() *models.LatestExchangeRate {
|
|
||||||
return &models.LatestExchangeRate{
|
|
||||||
Currency: e.TargetCurrency,
|
|
||||||
Rate: e.Observation.Value,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// BuildRequests returns the reserve bank of Australia exchange rates http requests
|
|
||||||
func (e *ReserveBankOfAustraliaDataSource) BuildRequests() ([]*http.Request, error) {
|
|
||||||
req, err := http.NewRequest("GET", reserveBankOfAustraliaExchangeRateUrl, nil)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return []*http.Request{req}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse returns the common response entity according to the the reserve bank of Australia data source raw response
|
|
||||||
func (e *ReserveBankOfAustraliaDataSource) Parse(c core.Context, content []byte) (*models.LatestExchangeRateResponse, error) {
|
|
||||||
xmlDecoder := xml.NewDecoder(bytes.NewReader(content))
|
|
||||||
xmlDecoder.CharsetReader = charset.NewReaderLabel
|
|
||||||
|
|
||||||
reserveBankOfAustraliaData := &ReserveBankOfAustraliaData{}
|
|
||||||
err := xmlDecoder.Decode(reserveBankOfAustraliaData)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf(c, "[reserve_bank_of_australia_datasource.Parse] failed to parse xml data, content is %s, because %s", string(content), err.Error())
|
|
||||||
return nil, errs.ErrFailedToRequestRemoteApi
|
|
||||||
}
|
|
||||||
|
|
||||||
latestExchangeRateResponse := reserveBankOfAustraliaData.ToLatestExchangeRateResponse(c)
|
|
||||||
|
|
||||||
if latestExchangeRateResponse == nil {
|
|
||||||
log.Errorf(c, "[reserve_bank_of_australia_datasource.Parse] failed to parse latest exchange rate data, content is %s", string(content))
|
|
||||||
return nil, errs.ErrFailedToRequestRemoteApi
|
|
||||||
}
|
|
||||||
|
|
||||||
return latestExchangeRateResponse, nil
|
|
||||||
}
|
|
||||||
@@ -1,268 +0,0 @@
|
|||||||
package exchangerates
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
|
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
|
||||||
)
|
|
||||||
|
|
||||||
const reserveBankOfAustraliaMinimumRequiredContent = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" +
|
|
||||||
"<rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\" xmlns:rba=\"https://www.rba.gov.au/statistics/frequency/exchange-rates.html\" xmlns:cb=\"http://www.cbwiki.net/wiki/index.php/Specification_1.2/\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:dcterms=\"http://purl.org/dc/terms/\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns=\"http://purl.org/rss/1.0/\" xsi:schemaLocation=\"http://www.w3.org/1999/02/22-rdf-syntax-ns# rdf.xsd\">\n" +
|
|
||||||
" <channel rdf:about=\"https://www.rba.gov.au/rss/rss-cb-exchange-rates.xml\">\n" +
|
|
||||||
" <dc:date>2021-04-01T16:45:00+11:00</dc:date>\n" +
|
|
||||||
" </channel>\n" +
|
|
||||||
" <item rdf:about=\"https://www.rba.gov.au/statistics/frequency/exchange-rates.html#USD\">\n" +
|
|
||||||
" <cb:statistics rdf:parseType=\"Resource\">\n" +
|
|
||||||
" <cb:exchangeRate rdf:parseType=\"Resource\">\n" +
|
|
||||||
" <cb:observation rdf:parseType=\"Resource\">\n" +
|
|
||||||
" <cb:value>0.7543</cb:value>\n" +
|
|
||||||
" <cb:unit>AUD</cb:unit>\n" +
|
|
||||||
" </cb:observation>\n" +
|
|
||||||
" <cb:baseCurrency>AUD</cb:baseCurrency>\n" +
|
|
||||||
" <cb:targetCurrency>USD</cb:targetCurrency>\n" +
|
|
||||||
" </cb:exchangeRate>\n" +
|
|
||||||
" </cb:statistics>\n" +
|
|
||||||
" </item>\n" +
|
|
||||||
" <item rdf:about=\"https://www.rba.gov.au/statistics/frequency/exchange-rates.html#CNY\">\n" +
|
|
||||||
" <cb:statistics rdf:parseType=\"Resource\">\n" +
|
|
||||||
" <cb:exchangeRate rdf:parseType=\"Resource\">\n" +
|
|
||||||
" <cb:observation rdf:parseType=\"Resource\">\n" +
|
|
||||||
" <cb:value>4.9577</cb:value>\n" +
|
|
||||||
" <cb:unit>AUD</cb:unit>\n" +
|
|
||||||
" </cb:observation>\n" +
|
|
||||||
" <cb:baseCurrency>AUD</cb:baseCurrency>\n" +
|
|
||||||
" <cb:targetCurrency>CNY</cb:targetCurrency>\n" +
|
|
||||||
" </cb:exchangeRate>\n" +
|
|
||||||
" </cb:statistics>\n" +
|
|
||||||
" </item>\n" +
|
|
||||||
"</rdf:RDF>"
|
|
||||||
|
|
||||||
func TestReserveBankOfAustraliaDataSource_StandardDataExtractBaseCurrency(t *testing.T) {
|
|
||||||
dataSource := &ReserveBankOfAustraliaDataSource{}
|
|
||||||
context := core.NewNullContext()
|
|
||||||
|
|
||||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(reserveBankOfAustraliaMinimumRequiredContent))
|
|
||||||
assert.Equal(t, nil, err)
|
|
||||||
assert.Equal(t, "AUD", actualLatestExchangeRateResponse.BaseCurrency)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReserveBankOfAustraliaDataSource_StandardDataExtractUpdateTime(t *testing.T) {
|
|
||||||
dataSource := &ReserveBankOfAustraliaDataSource{}
|
|
||||||
context := core.NewNullContext()
|
|
||||||
|
|
||||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(reserveBankOfAustraliaMinimumRequiredContent))
|
|
||||||
assert.Equal(t, nil, err)
|
|
||||||
assert.Equal(t, int64(1617255900), actualLatestExchangeRateResponse.UpdateTime)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReserveBankOfAustraliaDataSource_StandardDataExtractExchangeRates(t *testing.T) {
|
|
||||||
dataSource := &ReserveBankOfAustraliaDataSource{}
|
|
||||||
context := core.NewNullContext()
|
|
||||||
|
|
||||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte(reserveBankOfAustraliaMinimumRequiredContent))
|
|
||||||
assert.Equal(t, nil, err)
|
|
||||||
assert.Contains(t, actualLatestExchangeRateResponse.ExchangeRates, &models.LatestExchangeRate{
|
|
||||||
Currency: "USD",
|
|
||||||
Rate: "0.7543",
|
|
||||||
})
|
|
||||||
assert.Contains(t, actualLatestExchangeRateResponse.ExchangeRates, &models.LatestExchangeRate{
|
|
||||||
Currency: "CNY",
|
|
||||||
Rate: "4.9577",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReserveBankOfAustraliaDataSource_BlankContent(t *testing.T) {
|
|
||||||
dataSource := &ReserveBankOfAustraliaDataSource{}
|
|
||||||
context := core.NewNullContext()
|
|
||||||
|
|
||||||
_, err := dataSource.Parse(context, []byte(""))
|
|
||||||
assert.NotEqual(t, nil, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReserveBankOfAustraliaDataSource_OnlyXMLHeader(t *testing.T) {
|
|
||||||
dataSource := &ReserveBankOfAustraliaDataSource{}
|
|
||||||
context := core.NewNullContext()
|
|
||||||
|
|
||||||
_, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"UTF-8\"?>"))
|
|
||||||
assert.NotEqual(t, nil, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReserveBankOfAustraliaDataSource_EmptyRDFContent(t *testing.T) {
|
|
||||||
dataSource := &ReserveBankOfAustraliaDataSource{}
|
|
||||||
context := core.NewNullContext()
|
|
||||||
|
|
||||||
_, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"+
|
|
||||||
"<rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\" xmlns:rba=\"https://www.rba.gov.au/statistics/frequency/exchange-rates.html\" xmlns:cb=\"http://www.cbwiki.net/wiki/index.php/Specification_1.2/\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:dcterms=\"http://purl.org/dc/terms/\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns=\"http://purl.org/rss/1.0/\" xsi:schemaLocation=\"http://www.w3.org/1999/02/22-rdf-syntax-ns# rdf.xsd\">\n"+
|
|
||||||
"</rdf:RDF>"))
|
|
||||||
assert.NotEqual(t, nil, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReserveBankOfAustraliaDataSource_EmptyChannelContent(t *testing.T) {
|
|
||||||
dataSource := &ReserveBankOfAustraliaDataSource{}
|
|
||||||
context := core.NewNullContext()
|
|
||||||
|
|
||||||
_, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"+
|
|
||||||
"<rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\" xmlns:rba=\"https://www.rba.gov.au/statistics/frequency/exchange-rates.html\" xmlns:cb=\"http://www.cbwiki.net/wiki/index.php/Specification_1.2/\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:dcterms=\"http://purl.org/dc/terms/\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns=\"http://purl.org/rss/1.0/\" xsi:schemaLocation=\"http://www.w3.org/1999/02/22-rdf-syntax-ns# rdf.xsd\">\n"+
|
|
||||||
" <channel rdf:about=\"https://www.rba.gov.au/rss/rss-cb-exchange-rates.xml\">\n"+
|
|
||||||
" </channel>"+
|
|
||||||
" <item rdf:about=\"https://www.rba.gov.au/statistics/frequency/exchange-rates.html#USD\">\n"+
|
|
||||||
" <cb:statistics rdf:parseType=\"Resource\">\n"+
|
|
||||||
" <cb:exchangeRate rdf:parseType=\"Resource\">\n"+
|
|
||||||
" <cb:observation rdf:parseType=\"Resource\">\n"+
|
|
||||||
" <cb:value>0.7543</cb:value>\n"+
|
|
||||||
" <cb:unit>AUD</cb:unit>\n"+
|
|
||||||
" </cb:observation>\n"+
|
|
||||||
" <cb:baseCurrency>AUD</cb:baseCurrency>\n"+
|
|
||||||
" <cb:targetCurrency>USD</cb:targetCurrency>\n"+
|
|
||||||
" </cb:exchangeRate>\n"+
|
|
||||||
" </cb:statistics>\n"+
|
|
||||||
" </item>\n"+
|
|
||||||
"</rdf:RDF>"))
|
|
||||||
assert.NotEqual(t, nil, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReserveBankOfAustraliaDataSource_NoItem(t *testing.T) {
|
|
||||||
dataSource := &ReserveBankOfAustraliaDataSource{}
|
|
||||||
context := core.NewNullContext()
|
|
||||||
|
|
||||||
_, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"+
|
|
||||||
"<rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\" xmlns:rba=\"https://www.rba.gov.au/statistics/frequency/exchange-rates.html\" xmlns:cb=\"http://www.cbwiki.net/wiki/index.php/Specification_1.2/\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:dcterms=\"http://purl.org/dc/terms/\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns=\"http://purl.org/rss/1.0/\" xsi:schemaLocation=\"http://www.w3.org/1999/02/22-rdf-syntax-ns# rdf.xsd\">\n"+
|
|
||||||
" <channel rdf:about=\"https://www.rba.gov.au/rss/rss-cb-exchange-rates.xml\">\n"+
|
|
||||||
" <dc:date>2021-04-01T16:45:00+11:00</dc:date>\n"+
|
|
||||||
" </channel>\n"+
|
|
||||||
"</rdf:RDF>"))
|
|
||||||
assert.NotEqual(t, nil, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReserveBankOfAustraliaDataSource_BaseCurrencyNotEqualPreset(t *testing.T) {
|
|
||||||
dataSource := &ReserveBankOfAustraliaDataSource{}
|
|
||||||
context := core.NewNullContext()
|
|
||||||
|
|
||||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"+
|
|
||||||
"<rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\" xmlns:rba=\"https://www.rba.gov.au/statistics/frequency/exchange-rates.html\" xmlns:cb=\"http://www.cbwiki.net/wiki/index.php/Specification_1.2/\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:dcterms=\"http://purl.org/dc/terms/\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns=\"http://purl.org/rss/1.0/\" xsi:schemaLocation=\"http://www.w3.org/1999/02/22-rdf-syntax-ns# rdf.xsd\">\n"+
|
|
||||||
" <channel rdf:about=\"https://www.rba.gov.au/rss/rss-cb-exchange-rates.xml\">\n"+
|
|
||||||
" <dc:date>2021-04-01T16:45:00+11:00</dc:date>\n"+
|
|
||||||
" </channel>\n"+
|
|
||||||
" <item rdf:about=\"https://www.rba.gov.au/statistics/frequency/exchange-rates.html#USD\">\n"+
|
|
||||||
" <cb:statistics rdf:parseType=\"Resource\">\n"+
|
|
||||||
" <cb:exchangeRate rdf:parseType=\"Resource\">\n"+
|
|
||||||
" <cb:observation rdf:parseType=\"Resource\">\n"+
|
|
||||||
" <cb:value>0.7543</cb:value>\n"+
|
|
||||||
" <cb:unit>AUD</cb:unit>\n"+
|
|
||||||
" </cb:observation>\n"+
|
|
||||||
" <cb:baseCurrency>USD</cb:baseCurrency>\n"+
|
|
||||||
" <cb:targetCurrency>USD</cb:targetCurrency>\n"+
|
|
||||||
" </cb:exchangeRate>\n"+
|
|
||||||
" </cb:statistics>\n"+
|
|
||||||
" </item>\n"+
|
|
||||||
"</rdf:RDF>"))
|
|
||||||
assert.Equal(t, nil, err)
|
|
||||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReserveBankOfAustraliaDataSource_UnitCurrencyNotEqualPreset(t *testing.T) {
|
|
||||||
dataSource := &ReserveBankOfAustraliaDataSource{}
|
|
||||||
context := core.NewNullContext()
|
|
||||||
|
|
||||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"+
|
|
||||||
"<rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\" xmlns:rba=\"https://www.rba.gov.au/statistics/frequency/exchange-rates.html\" xmlns:cb=\"http://www.cbwiki.net/wiki/index.php/Specification_1.2/\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:dcterms=\"http://purl.org/dc/terms/\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns=\"http://purl.org/rss/1.0/\" xsi:schemaLocation=\"http://www.w3.org/1999/02/22-rdf-syntax-ns# rdf.xsd\">\n"+
|
|
||||||
" <channel rdf:about=\"https://www.rba.gov.au/rss/rss-cb-exchange-rates.xml\">\n"+
|
|
||||||
" <dc:date>2021-04-01T16:45:00+11:00</dc:date>\n"+
|
|
||||||
" </channel>\n"+
|
|
||||||
" <item rdf:about=\"https://www.rba.gov.au/statistics/frequency/exchange-rates.html#USD\">\n"+
|
|
||||||
" <cb:statistics rdf:parseType=\"Resource\">\n"+
|
|
||||||
" <cb:exchangeRate rdf:parseType=\"Resource\">\n"+
|
|
||||||
" <cb:observation rdf:parseType=\"Resource\">\n"+
|
|
||||||
" <cb:value>0.7543</cb:value>\n"+
|
|
||||||
" <cb:unit>USD</cb:unit>\n"+
|
|
||||||
" </cb:observation>\n"+
|
|
||||||
" <cb:baseCurrency>AUD</cb:baseCurrency>\n"+
|
|
||||||
" <cb:targetCurrency>USD</cb:targetCurrency>\n"+
|
|
||||||
" </cb:exchangeRate>\n"+
|
|
||||||
" </cb:statistics>\n"+
|
|
||||||
" </item>\n"+
|
|
||||||
"</rdf:RDF>"))
|
|
||||||
assert.Equal(t, nil, err)
|
|
||||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReserveBankOfAustraliaDataSource_InvalidCurrency(t *testing.T) {
|
|
||||||
dataSource := &ReserveBankOfAustraliaDataSource{}
|
|
||||||
context := core.NewNullContext()
|
|
||||||
|
|
||||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"+
|
|
||||||
"<rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\" xmlns:rba=\"https://www.rba.gov.au/statistics/frequency/exchange-rates.html\" xmlns:cb=\"http://www.cbwiki.net/wiki/index.php/Specification_1.2/\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:dcterms=\"http://purl.org/dc/terms/\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns=\"http://purl.org/rss/1.0/\" xsi:schemaLocation=\"http://www.w3.org/1999/02/22-rdf-syntax-ns# rdf.xsd\">\n"+
|
|
||||||
" <channel rdf:about=\"https://www.rba.gov.au/rss/rss-cb-exchange-rates.xml\">\n"+
|
|
||||||
" <dc:date>2021-04-01T16:45:00+11:00</dc:date>\n"+
|
|
||||||
" </channel>\n"+
|
|
||||||
" <item rdf:about=\"https://www.rba.gov.au/statistics/frequency/exchange-rates.html#USD\">\n"+
|
|
||||||
" <cb:statistics rdf:parseType=\"Resource\">\n"+
|
|
||||||
" <cb:exchangeRate rdf:parseType=\"Resource\">\n"+
|
|
||||||
" <cb:observation rdf:parseType=\"Resource\">\n"+
|
|
||||||
" <cb:value>1</cb:value>\n"+
|
|
||||||
" <cb:unit>AUD</cb:unit>\n"+
|
|
||||||
" </cb:observation>\n"+
|
|
||||||
" <cb:baseCurrency>AUD</cb:baseCurrency>\n"+
|
|
||||||
" <cb:targetCurrency>XXX</cb:targetCurrency>\n"+
|
|
||||||
" </cb:exchangeRate>\n"+
|
|
||||||
" </cb:statistics>\n"+
|
|
||||||
" </item>\n"+
|
|
||||||
"</rdf:RDF>"))
|
|
||||||
assert.Equal(t, nil, err)
|
|
||||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReserveBankOfAustraliaDataSource_EmptyRate(t *testing.T) {
|
|
||||||
dataSource := &ReserveBankOfAustraliaDataSource{}
|
|
||||||
context := core.NewNullContext()
|
|
||||||
|
|
||||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"+
|
|
||||||
"<rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\" xmlns:rba=\"https://www.rba.gov.au/statistics/frequency/exchange-rates.html\" xmlns:cb=\"http://www.cbwiki.net/wiki/index.php/Specification_1.2/\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:dcterms=\"http://purl.org/dc/terms/\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns=\"http://purl.org/rss/1.0/\" xsi:schemaLocation=\"http://www.w3.org/1999/02/22-rdf-syntax-ns# rdf.xsd\">\n"+
|
|
||||||
" <channel rdf:about=\"https://www.rba.gov.au/rss/rss-cb-exchange-rates.xml\">\n"+
|
|
||||||
" <dc:date>2021-04-01T16:45:00+11:00</dc:date>\n"+
|
|
||||||
" </channel>\n"+
|
|
||||||
" <item rdf:about=\"https://www.rba.gov.au/statistics/frequency/exchange-rates.html#USD\">\n"+
|
|
||||||
" <cb:statistics rdf:parseType=\"Resource\">\n"+
|
|
||||||
" <cb:exchangeRate rdf:parseType=\"Resource\">\n"+
|
|
||||||
" <cb:observation rdf:parseType=\"Resource\">\n"+
|
|
||||||
" <cb:value></cb:value>\n"+
|
|
||||||
" <cb:unit>AUD</cb:unit>\n"+
|
|
||||||
" </cb:observation>\n"+
|
|
||||||
" <cb:baseCurrency>AUD</cb:baseCurrency>\n"+
|
|
||||||
" <cb:targetCurrency>USD</cb:targetCurrency>\n"+
|
|
||||||
" </cb:exchangeRate>\n"+
|
|
||||||
" </cb:statistics>\n"+
|
|
||||||
" </item>\n"+
|
|
||||||
"</rdf:RDF>"))
|
|
||||||
assert.Equal(t, nil, err)
|
|
||||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReserveBankOfAustraliaDataSource_InvalidRate(t *testing.T) {
|
|
||||||
dataSource := &ReserveBankOfAustraliaDataSource{}
|
|
||||||
context := core.NewNullContext()
|
|
||||||
|
|
||||||
actualLatestExchangeRateResponse, err := dataSource.Parse(context, []byte("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"+
|
|
||||||
"<rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\" xmlns:rba=\"https://www.rba.gov.au/statistics/frequency/exchange-rates.html\" xmlns:cb=\"http://www.cbwiki.net/wiki/index.php/Specification_1.2/\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:dcterms=\"http://purl.org/dc/terms/\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns=\"http://purl.org/rss/1.0/\" xsi:schemaLocation=\"http://www.w3.org/1999/02/22-rdf-syntax-ns# rdf.xsd\">\n"+
|
|
||||||
" <channel rdf:about=\"https://www.rba.gov.au/rss/rss-cb-exchange-rates.xml\">\n"+
|
|
||||||
" <dc:date>2021-04-01T16:45:00+11:00</dc:date>\n"+
|
|
||||||
" </channel>\n"+
|
|
||||||
" <item rdf:about=\"https://www.rba.gov.au/statistics/frequency/exchange-rates.html#USD\">\n"+
|
|
||||||
" <cb:statistics rdf:parseType=\"Resource\">\n"+
|
|
||||||
" <cb:exchangeRate rdf:parseType=\"Resource\">\n"+
|
|
||||||
" <cb:observation rdf:parseType=\"Resource\">\n"+
|
|
||||||
" <cb:value>null</cb:value>\n"+
|
|
||||||
" <cb:unit>AUD</cb:unit>\n"+
|
|
||||||
" </cb:observation>\n"+
|
|
||||||
" <cb:baseCurrency>AUD</cb:baseCurrency>\n"+
|
|
||||||
" <cb:targetCurrency>USD</cb:targetCurrency>\n"+
|
|
||||||
" </cb:exchangeRate>\n"+
|
|
||||||
" </cb:statistics>\n"+
|
|
||||||
" </item>\n"+
|
|
||||||
"</rdf:RDF>"))
|
|
||||||
assert.Equal(t, nil, err)
|
|
||||||
assert.Len(t, actualLatestExchangeRateResponse.ExchangeRates, 0)
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,9 @@ import (
|
|||||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/llm/data"
|
"github.com/mayswind/ezbookkeeping/pkg/llm/data"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/llm/provider"
|
"github.com/mayswind/ezbookkeeping/pkg/llm/provider"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/llm/provider/anthropic"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/llm/provider/googleai"
|
"github.com/mayswind/ezbookkeeping/pkg/llm/provider/googleai"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/llm/provider/lmstudio"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/llm/provider/ollama"
|
"github.com/mayswind/ezbookkeeping/pkg/llm/provider/ollama"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/llm/provider/openai"
|
"github.com/mayswind/ezbookkeeping/pkg/llm/provider/openai"
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||||
@@ -41,10 +43,16 @@ func initializeLargeLanguageModelProvider(llmConfig *settings.LLMConfig, enableR
|
|||||||
return openai.NewOpenAILargeLanguageModelProvider(llmConfig, enableResponseLog), nil
|
return openai.NewOpenAILargeLanguageModelProvider(llmConfig, enableResponseLog), nil
|
||||||
} else if llmConfig.LLMProvider == settings.OpenAICompatibleLLMProvider {
|
} else if llmConfig.LLMProvider == settings.OpenAICompatibleLLMProvider {
|
||||||
return openai.NewOpenAICompatibleLargeLanguageModelProvider(llmConfig, enableResponseLog), nil
|
return openai.NewOpenAICompatibleLargeLanguageModelProvider(llmConfig, enableResponseLog), nil
|
||||||
|
} else if llmConfig.LLMProvider == settings.AnthropicLLMProvider {
|
||||||
|
return anthropic.NewAnthropicLargeLanguageModelProvider(llmConfig, enableResponseLog), nil
|
||||||
|
} else if llmConfig.LLMProvider == settings.AnthropicCompatibleLLMProvider {
|
||||||
|
return anthropic.NewAnthropicCompatibleLargeLanguageModelProvider(llmConfig, enableResponseLog), nil
|
||||||
} else if llmConfig.LLMProvider == settings.OpenRouterLLMProvider {
|
} else if llmConfig.LLMProvider == settings.OpenRouterLLMProvider {
|
||||||
return openai.NewOpenRouterLargeLanguageModelProvider(llmConfig, enableResponseLog), nil
|
return openai.NewOpenRouterLargeLanguageModelProvider(llmConfig, enableResponseLog), nil
|
||||||
} else if llmConfig.LLMProvider == settings.OllamaLLMProvider {
|
} else if llmConfig.LLMProvider == settings.OllamaLLMProvider {
|
||||||
return ollama.NewOllamaLargeLanguageModelProvider(llmConfig, enableResponseLog), nil
|
return ollama.NewOllamaLargeLanguageModelProvider(llmConfig, enableResponseLog), nil
|
||||||
|
} else if llmConfig.LLMProvider == settings.LMStudioLLMProvider {
|
||||||
|
return lmstudio.NewLMStudioLargeLanguageModelProvider(llmConfig, enableResponseLog), nil
|
||||||
} else if llmConfig.LLMProvider == settings.GoogleAILLMProvider {
|
} else if llmConfig.LLMProvider == settings.GoogleAILLMProvider {
|
||||||
return googleai.NewGoogleAILargeLanguageModelProvider(llmConfig, enableResponseLog), nil
|
return googleai.NewGoogleAILargeLanguageModelProvider(llmConfig, enableResponseLog), nil
|
||||||
} else if llmConfig.LLMProvider == "" {
|
} else if llmConfig.LLMProvider == "" {
|
||||||
|
|||||||
+196
@@ -0,0 +1,196 @@
|
|||||||
|
package anthropic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/llm/data"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/llm/provider"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/llm/provider/common"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AnthropicMessagesAPIProvider defines the structure of Anthropic messages API provider
|
||||||
|
type AnthropicMessagesAPIProvider interface {
|
||||||
|
// BuildMessagesHttpRequest returns the messages http request
|
||||||
|
BuildMessagesHttpRequest(c core.Context, uid int64) (*http.Request, error)
|
||||||
|
|
||||||
|
// GetModelID returns the model id
|
||||||
|
GetModelID() string
|
||||||
|
|
||||||
|
// GetMaxTokens returns the max tokens to generate
|
||||||
|
GetMaxTokens() uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommonAnthropicMessagesAPILargeLanguageModelAdapter defines the structure of Anthropic common compatible large language model adapter based on messages api
|
||||||
|
type CommonAnthropicMessagesAPILargeLanguageModelAdapter struct {
|
||||||
|
common.HttpLargeLanguageModelAdapter
|
||||||
|
apiProvider AnthropicMessagesAPIProvider
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnthropicMessageRole defines the role of Anthropic message
|
||||||
|
type AnthropicMessageRole string
|
||||||
|
|
||||||
|
// Anthropic Message Roles
|
||||||
|
const (
|
||||||
|
AnthropicMessageRoleUser AnthropicMessageRole = "user"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AnthropicThinkingType string
|
||||||
|
|
||||||
|
// Anthropic Thinking Types
|
||||||
|
const (
|
||||||
|
AnthropicThinkingTypeDisabled AnthropicThinkingType = "disabled"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AnthropicMessagesRequest defines the structure of Anthropic messages request
|
||||||
|
type AnthropicMessagesRequest struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
MaxTokens uint32 `json:"max_tokens"`
|
||||||
|
Stream bool `json:"stream"`
|
||||||
|
System string `json:"system,omitempty"`
|
||||||
|
Messages []any `json:"messages"`
|
||||||
|
Thinking *AnthropicMessagesRequestThinkingConfigParam `json:"thinking,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnthropicMessagesRequestMessage defines the structure of Anthropic messages request message
|
||||||
|
type AnthropicMessagesRequestMessage[T string | []*AnthropicMessagesRequestImageBlockParam] struct {
|
||||||
|
Role AnthropicMessageRole `json:"role"`
|
||||||
|
Content T `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnthropicMessagesRequestImageBlockParam defines the structure of Anthropic messages request image content block param
|
||||||
|
type AnthropicMessagesRequestImageBlockParam struct {
|
||||||
|
Source *AnthropicMessagesRequestBase64ImageSource `json:"source"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnthropicMessagesRequestBase64ImageSource defines the structure of Anthropic messages request base64 image source
|
||||||
|
type AnthropicMessagesRequestBase64ImageSource struct {
|
||||||
|
Data string `json:"data"`
|
||||||
|
MediaType string `json:"media_type"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnthropicMessagesRequestThinkingConfigParam defines the structure of Anthropic messages request thinking config param
|
||||||
|
type AnthropicMessagesRequestThinkingConfigParam struct {
|
||||||
|
Type AnthropicThinkingType `json:"type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnthropicMessagesResponse defines the structure of Anthropic messages response
|
||||||
|
type AnthropicMessagesResponse struct {
|
||||||
|
Content []*AnthropicMessagesResponseContentBlock `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnthropicMessagesResponseContentBlock defines the structure of Anthropic messages response content block
|
||||||
|
type AnthropicMessagesResponseContentBlock struct {
|
||||||
|
Text *string `json:"text"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildTextualRequest returns the http request by Anthropic common compatible adapter
|
||||||
|
func (p *CommonAnthropicMessagesAPILargeLanguageModelAdapter) BuildTextualRequest(c core.Context, uid int64, request *data.LargeLanguageModelRequest, responseType data.LargeLanguageModelResponseFormat) (*http.Request, error) {
|
||||||
|
requestBody, err := p.buildJsonRequestBody(c, uid, request, responseType)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
httpRequest, err := p.apiProvider.BuildMessagesHttpRequest(c, uid)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
httpRequest.Body = io.NopCloser(bytes.NewReader(requestBody))
|
||||||
|
httpRequest.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
return httpRequest, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseTextualResponse returns the textual response by Anthropic common compatible adapter
|
||||||
|
func (p *CommonAnthropicMessagesAPILargeLanguageModelAdapter) ParseTextualResponse(c core.Context, uid int64, body []byte, responseType data.LargeLanguageModelResponseFormat) (*data.LargeLanguageModelTextualResponse, error) {
|
||||||
|
messagesResponse := &AnthropicMessagesResponse{}
|
||||||
|
err := json.Unmarshal(body, &messagesResponse)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf(c, "[anthropic_common_compatible_large_language_model_adapter.ParseTextualResponse] failed to parse messages response for user \"uid:%d\", because %s", uid, err.Error())
|
||||||
|
return nil, errs.ErrFailedToRequestRemoteApi
|
||||||
|
}
|
||||||
|
|
||||||
|
if messagesResponse == nil || messagesResponse.Content == nil || len(messagesResponse.Content) < 1 || messagesResponse.Content[0].Text == nil {
|
||||||
|
log.Errorf(c, "[anthropic_common_compatible_large_language_model_adapter.ParseTextualResponse] messages response is invalid for user \"uid:%d\"", uid)
|
||||||
|
return nil, errs.ErrFailedToRequestRemoteApi
|
||||||
|
}
|
||||||
|
|
||||||
|
textualResponse := &data.LargeLanguageModelTextualResponse{
|
||||||
|
Content: *messagesResponse.Content[0].Text,
|
||||||
|
}
|
||||||
|
|
||||||
|
return textualResponse, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *CommonAnthropicMessagesAPILargeLanguageModelAdapter) buildJsonRequestBody(c core.Context, uid int64, request *data.LargeLanguageModelRequest, responseType data.LargeLanguageModelResponseFormat) ([]byte, error) {
|
||||||
|
if p.apiProvider.GetModelID() == "" {
|
||||||
|
return nil, errs.ErrInvalidLLMModelId
|
||||||
|
}
|
||||||
|
|
||||||
|
messagesRequest := &AnthropicMessagesRequest{
|
||||||
|
Model: p.apiProvider.GetModelID(),
|
||||||
|
MaxTokens: p.apiProvider.GetMaxTokens(),
|
||||||
|
Stream: request.Stream,
|
||||||
|
Messages: make([]any, 0, 1),
|
||||||
|
Thinking: &AnthropicMessagesRequestThinkingConfigParam{
|
||||||
|
Type: AnthropicThinkingTypeDisabled,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if request.SystemPrompt != "" {
|
||||||
|
messagesRequest.System = request.SystemPrompt
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(request.UserPrompt) > 0 {
|
||||||
|
if request.UserPromptType == data.LARGE_LANGUAGE_MODEL_REQUEST_PROMPT_TYPE_IMAGE_URL {
|
||||||
|
imageBase64Data := base64.StdEncoding.EncodeToString(request.UserPrompt)
|
||||||
|
messagesRequest.Messages = append(messagesRequest.Messages, &AnthropicMessagesRequestMessage[[]*AnthropicMessagesRequestImageBlockParam]{
|
||||||
|
Role: AnthropicMessageRoleUser,
|
||||||
|
Content: []*AnthropicMessagesRequestImageBlockParam{
|
||||||
|
{
|
||||||
|
Type: "image",
|
||||||
|
Source: &AnthropicMessagesRequestBase64ImageSource{
|
||||||
|
Data: imageBase64Data,
|
||||||
|
MediaType: request.UserPromptContentType,
|
||||||
|
Type: "base64",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
messagesRequest.Messages = append(messagesRequest.Messages, &AnthropicMessagesRequestMessage[string]{
|
||||||
|
Role: AnthropicMessageRoleUser,
|
||||||
|
Content: string(request.UserPrompt),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
requestBodyBytes, err := json.Marshal(messagesRequest)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf(c, "[anthropic_common_compatible_large_language_model_adapter.buildJsonRequestBody] failed to marshal request body for user \"uid:%d\", because %s", uid, err.Error())
|
||||||
|
return nil, errs.ErrOperationFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debugf(c, "[anthropic_common_compatible_large_language_model_adapter.buildJsonRequestBody] request body is %s", requestBodyBytes)
|
||||||
|
return requestBodyBytes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCommonAnthropicMessagesAPILargeLanguageModelAdapter(llmConfig *settings.LLMConfig, enableResponseLog bool, apiProvider AnthropicMessagesAPIProvider) provider.LargeLanguageModelProvider {
|
||||||
|
return common.NewCommonHttpLargeLanguageModelProvider(llmConfig, enableResponseLog, &CommonAnthropicMessagesAPILargeLanguageModelAdapter{
|
||||||
|
apiProvider: apiProvider,
|
||||||
|
})
|
||||||
|
}
|
||||||
+152
@@ -0,0 +1,152 @@
|
|||||||
|
package anthropic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/llm/data"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCommonAnthropicMessagesAPILargeLanguageModelAdapter_buildJsonRequestBody_TextualUserPrompt(t *testing.T) {
|
||||||
|
adapter := &CommonAnthropicMessagesAPILargeLanguageModelAdapter{
|
||||||
|
apiProvider: &AnthropicOfficialMessagesAPIProvider{
|
||||||
|
AnthropicModelID: "test",
|
||||||
|
AnthropicMaxTokens: 128,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
request := &data.LargeLanguageModelRequest{
|
||||||
|
SystemPrompt: "You are a helpful assistant.",
|
||||||
|
UserPrompt: []byte("Hello, how are you?"),
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyBytes, err := adapter.buildJsonRequestBody(core.NewNullContext(), 0, request, data.LARGE_LANGUAGE_MODEL_RESPONSE_FORMAT_JSON)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
var body map[string]interface{}
|
||||||
|
err = json.Unmarshal(bodyBytes, &body)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, "{\"model\":\"test\",\"max_tokens\":128,\"stream\":false,\"system\":\"You are a helpful assistant.\",\"messages\":[{\"role\":\"user\",\"content\":\"Hello, how are you?\"}],\"thinking\":{\"type\":\"disabled\"}}", string(bodyBytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommonAnthropicMessagesAPILargeLanguageModelAdapter_buildJsonRequestBody_ImageUserPrompt(t *testing.T) {
|
||||||
|
adapter := &CommonAnthropicMessagesAPILargeLanguageModelAdapter{
|
||||||
|
apiProvider: &AnthropicOfficialMessagesAPIProvider{
|
||||||
|
AnthropicModelID: "test",
|
||||||
|
AnthropicMaxTokens: 128,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
request := &data.LargeLanguageModelRequest{
|
||||||
|
SystemPrompt: "What's in this image?",
|
||||||
|
UserPrompt: []byte("fakedata"),
|
||||||
|
UserPromptType: data.LARGE_LANGUAGE_MODEL_REQUEST_PROMPT_TYPE_IMAGE_URL,
|
||||||
|
UserPromptContentType: "image/png",
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyBytes, err := adapter.buildJsonRequestBody(core.NewNullContext(), 0, request, data.LARGE_LANGUAGE_MODEL_RESPONSE_FORMAT_JSON)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
var body map[string]interface{}
|
||||||
|
err = json.Unmarshal(bodyBytes, &body)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, "{\"model\":\"test\",\"max_tokens\":128,\"stream\":false,\"system\":\"What's in this image?\",\"messages\":[{\"role\":\"user\",\"content\":[{\"source\":{\"data\":\"ZmFrZWRhdGE=\",\"media_type\":\"image/png\",\"type\":\"base64\"},\"type\":\"image\"}]}],\"thinking\":{\"type\":\"disabled\"}}", string(bodyBytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommonAnthropicMessagesAPILargeLanguageModelAdapter_ParseTextualResponse_ValidJsonResponse(t *testing.T) {
|
||||||
|
adapter := &CommonAnthropicMessagesAPILargeLanguageModelAdapter{
|
||||||
|
apiProvider: &AnthropicOfficialMessagesAPIProvider{},
|
||||||
|
}
|
||||||
|
|
||||||
|
response := `{
|
||||||
|
"id": "test-123",
|
||||||
|
"role": "assistant",
|
||||||
|
"type": "message",
|
||||||
|
"model": "test",
|
||||||
|
"usage": {
|
||||||
|
"input_tokens": 13,
|
||||||
|
"output_tokens": 7
|
||||||
|
},
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": "This is a test response"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`
|
||||||
|
|
||||||
|
result, err := adapter.ParseTextualResponse(core.NewNullContext(), 0, []byte(response), data.LARGE_LANGUAGE_MODEL_RESPONSE_FORMAT_JSON)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.Equal(t, "This is a test response", result.Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommonAnthropicMessagesAPILargeLanguageModelAdapter_ParseTextualResponse_EmptyContentText(t *testing.T) {
|
||||||
|
adapter := &CommonAnthropicMessagesAPILargeLanguageModelAdapter{
|
||||||
|
apiProvider: &AnthropicOfficialMessagesAPIProvider{},
|
||||||
|
}
|
||||||
|
|
||||||
|
response := `{
|
||||||
|
"id": "test-123",
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`
|
||||||
|
|
||||||
|
result, err := adapter.ParseTextualResponse(core.NewNullContext(), 0, []byte(response), data.LARGE_LANGUAGE_MODEL_RESPONSE_FORMAT_JSON)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.Equal(t, "", result.Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommonAnthropicMessagesAPILargeLanguageModelAdapter_ParseTextualResponse_EmptyContent(t *testing.T) {
|
||||||
|
adapter := &CommonAnthropicMessagesAPILargeLanguageModelAdapter{
|
||||||
|
apiProvider: &AnthropicOfficialMessagesAPIProvider{},
|
||||||
|
}
|
||||||
|
|
||||||
|
response := `{
|
||||||
|
"id": "test-123",
|
||||||
|
"role": "assistant",
|
||||||
|
"content": []
|
||||||
|
}`
|
||||||
|
|
||||||
|
_, err := adapter.ParseTextualResponse(core.NewNullContext(), 0, []byte(response), data.LARGE_LANGUAGE_MODEL_RESPONSE_FORMAT_JSON)
|
||||||
|
assert.EqualError(t, err, "failed to request third party api")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommonAnthropicMessagesAPILargeLanguageModelAdapter_ParseTextualResponse_NoContentText(t *testing.T) {
|
||||||
|
adapter := &CommonAnthropicMessagesAPILargeLanguageModelAdapter{
|
||||||
|
apiProvider: &AnthropicOfficialMessagesAPIProvider{},
|
||||||
|
}
|
||||||
|
|
||||||
|
response := `{
|
||||||
|
"id": "msg_123",
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`
|
||||||
|
|
||||||
|
_, err := adapter.ParseTextualResponse(core.NewNullContext(), 0, []byte(response), data.LARGE_LANGUAGE_MODEL_RESPONSE_FORMAT_JSON)
|
||||||
|
assert.EqualError(t, err, "failed to request third party api")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommonAnthropicMessagesAPILargeLanguageModelAdapter_ParseTextualResponse_InvalidJson(t *testing.T) {
|
||||||
|
adapter := &CommonAnthropicMessagesAPILargeLanguageModelAdapter{
|
||||||
|
apiProvider: &AnthropicOfficialMessagesAPIProvider{},
|
||||||
|
}
|
||||||
|
|
||||||
|
response := "error"
|
||||||
|
|
||||||
|
_, err := adapter.ParseTextualResponse(core.NewNullContext(), 0, []byte(response), data.LARGE_LANGUAGE_MODEL_RESPONSE_FORMAT_JSON)
|
||||||
|
assert.EqualError(t, err, "failed to request third party api")
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package anthropic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/llm/provider"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const anthropicCompatibleMessagesPath = "messages"
|
||||||
|
|
||||||
|
// AnthropicCompatibleMessagesAPIProvider defines the structure of Anthropic compatible messages API provider
|
||||||
|
type AnthropicCompatibleMessagesAPIProvider struct {
|
||||||
|
AnthropicMessagesAPIProvider
|
||||||
|
AnthropicCompatibleBaseURL string
|
||||||
|
AnthropicCompatibleAPIVersion string
|
||||||
|
AnthropicCompatibleAPIKey string
|
||||||
|
AnthropicCompatibleModelID string
|
||||||
|
AnthropicCompatibleMaxTokens uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildMessagesHttpRequest returns the messages http request by Anthropic compatible messages API provider
|
||||||
|
func (p *AnthropicCompatibleMessagesAPIProvider) BuildMessagesHttpRequest(c core.Context, uid int64) (*http.Request, error) {
|
||||||
|
req, err := http.NewRequest("POST", p.getFinalMessagesRequestUrl(), nil)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.AnthropicCompatibleAPIVersion != "" {
|
||||||
|
req.Header.Set("anthropic-version", p.AnthropicCompatibleAPIVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.AnthropicCompatibleAPIKey != "" {
|
||||||
|
req.Header.Set("X-Api-Key", p.AnthropicCompatibleAPIKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
return req, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetModelID returns the model id of Anthropic compatible messages API provider
|
||||||
|
func (p *AnthropicCompatibleMessagesAPIProvider) GetModelID() string {
|
||||||
|
return p.AnthropicCompatibleModelID
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMaxTokens returns the max tokens to generate of Anthropic compatible messages API provider
|
||||||
|
func (p *AnthropicCompatibleMessagesAPIProvider) GetMaxTokens() uint32 {
|
||||||
|
return p.AnthropicCompatibleMaxTokens
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *AnthropicCompatibleMessagesAPIProvider) getFinalMessagesRequestUrl() string {
|
||||||
|
url := p.AnthropicCompatibleBaseURL
|
||||||
|
|
||||||
|
if url[len(url)-1] != '/' {
|
||||||
|
url += "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
url += anthropicCompatibleMessagesPath
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAnthropicCompatibleLargeLanguageModelProvider creates a new Anthropic compatible large language model provider instance
|
||||||
|
func NewAnthropicCompatibleLargeLanguageModelProvider(llmConfig *settings.LLMConfig, enableResponseLog bool) provider.LargeLanguageModelProvider {
|
||||||
|
return newCommonAnthropicMessagesAPILargeLanguageModelAdapter(llmConfig, enableResponseLog, &AnthropicCompatibleMessagesAPIProvider{
|
||||||
|
AnthropicCompatibleBaseURL: llmConfig.AnthropicCompatibleBaseURL,
|
||||||
|
AnthropicCompatibleAPIVersion: llmConfig.AnthropicCompatibleAPIVersion,
|
||||||
|
AnthropicCompatibleAPIKey: llmConfig.AnthropicCompatibleAPIKey,
|
||||||
|
AnthropicCompatibleModelID: llmConfig.AnthropicCompatibleModelID,
|
||||||
|
AnthropicCompatibleMaxTokens: llmConfig.AnthropicCompatibleMaxTokens,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package anthropic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAnthropicCompatibleMessagesAPIProvider_GetFinalRequestUrl(t *testing.T) {
|
||||||
|
apiProvider := &AnthropicCompatibleMessagesAPIProvider{
|
||||||
|
AnthropicCompatibleBaseURL: "https://api.example.com/v1/",
|
||||||
|
}
|
||||||
|
url := apiProvider.getFinalMessagesRequestUrl()
|
||||||
|
assert.Equal(t, "https://api.example.com/v1/messages", url)
|
||||||
|
|
||||||
|
apiProvider = &AnthropicCompatibleMessagesAPIProvider{
|
||||||
|
AnthropicCompatibleBaseURL: "https://api.example.com/v1",
|
||||||
|
}
|
||||||
|
url = apiProvider.getFinalMessagesRequestUrl()
|
||||||
|
assert.Equal(t, "https://api.example.com/v1/messages", url)
|
||||||
|
|
||||||
|
apiProvider = &AnthropicCompatibleMessagesAPIProvider{
|
||||||
|
AnthropicCompatibleBaseURL: "https://example.com/api",
|
||||||
|
}
|
||||||
|
url = apiProvider.getFinalMessagesRequestUrl()
|
||||||
|
assert.Equal(t, "https://example.com/api/messages", url)
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package anthropic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/llm/provider"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AnthropicOfficialMessagesAPIProvider defines the structure of Anthropic official messages API provider
|
||||||
|
type AnthropicOfficialMessagesAPIProvider struct {
|
||||||
|
AnthropicMessagesAPIProvider
|
||||||
|
AnthropicAPIKey string
|
||||||
|
AnthropicModelID string
|
||||||
|
AnthropicMaxTokens uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
const anthropicMessagesUrl = "https://api.anthropic.com/v1/messages"
|
||||||
|
const anthropicAPIVersion = "2023-06-01"
|
||||||
|
|
||||||
|
// BuildMessagesHttpRequest returns the messages http request by Anthropic official messages API provider
|
||||||
|
func (p *AnthropicOfficialMessagesAPIProvider) BuildMessagesHttpRequest(c core.Context, uid int64) (*http.Request, error) {
|
||||||
|
req, err := http.NewRequest("POST", anthropicMessagesUrl, nil)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("anthropic-version", anthropicAPIVersion)
|
||||||
|
req.Header.Set("X-Api-Key", p.AnthropicAPIKey)
|
||||||
|
|
||||||
|
return req, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetModelID returns the model id of Anthropic official messages API provider
|
||||||
|
func (p *AnthropicOfficialMessagesAPIProvider) GetModelID() string {
|
||||||
|
return p.AnthropicModelID
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMaxTokens returns the max tokens to generate of Anthropic official messages API provider
|
||||||
|
func (p *AnthropicOfficialMessagesAPIProvider) GetMaxTokens() uint32 {
|
||||||
|
return p.AnthropicMaxTokens
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAnthropicLargeLanguageModelProvider creates a new Anthropic large language model provider instance
|
||||||
|
func NewAnthropicLargeLanguageModelProvider(llmConfig *settings.LLMConfig, enableResponseLog bool) provider.LargeLanguageModelProvider {
|
||||||
|
return newCommonAnthropicMessagesAPILargeLanguageModelAdapter(llmConfig, enableResponseLog, &AnthropicOfficialMessagesAPIProvider{
|
||||||
|
AnthropicAPIKey: llmConfig.AnthropicAPIKey,
|
||||||
|
AnthropicModelID: llmConfig.AnthropicModelID,
|
||||||
|
AnthropicMaxTokens: llmConfig.AnthropicMaxTokens,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -83,6 +83,6 @@ func (p *CommonHttpLargeLanguageModelProvider) getTextualResponse(c core.Context
|
|||||||
func NewCommonHttpLargeLanguageModelProvider(llmConfig *settings.LLMConfig, enableResponseLog bool, adapter HttpLargeLanguageModelAdapter) *CommonHttpLargeLanguageModelProvider {
|
func NewCommonHttpLargeLanguageModelProvider(llmConfig *settings.LLMConfig, enableResponseLog bool, adapter HttpLargeLanguageModelAdapter) *CommonHttpLargeLanguageModelProvider {
|
||||||
return &CommonHttpLargeLanguageModelProvider{
|
return &CommonHttpLargeLanguageModelProvider{
|
||||||
adapter: adapter,
|
adapter: adapter,
|
||||||
httpClient: httpclient.NewHttpClient(llmConfig.LargeLanguageModelAPIRequestTimeout, llmConfig.LargeLanguageModelAPIProxy, llmConfig.LargeLanguageModelAPISkipTLSVerify, settings.GetUserAgent(), enableResponseLog),
|
httpClient: httpclient.NewHttpClient(llmConfig.LargeLanguageModelAPIRequestTimeout, llmConfig.LargeLanguageModelAPIProxy, llmConfig.LargeLanguageModelAPISkipTLSVerify, core.GetOutgoingUserAgent(), enableResponseLog),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
package lmstudio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/llm/data"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/llm/provider"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/llm/provider/common"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const lmStudioChatPath = "api/v1/chat"
|
||||||
|
|
||||||
|
// LMStudioLargeLanguageModelAdapter defines the structure of LM Studio large language model adapter
|
||||||
|
type LMStudioLargeLanguageModelAdapter struct {
|
||||||
|
common.HttpLargeLanguageModelAdapter
|
||||||
|
LMStudioServerURL string
|
||||||
|
LMStudioToken string
|
||||||
|
LMStudioModelID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// LMStudioChatRequest defines the structure of LM Studio chat request
|
||||||
|
type LMStudioChatRequest struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Stream bool `json:"stream"`
|
||||||
|
SystemPrompt string `json:"system_prompt,omitempty"`
|
||||||
|
Input []*LMStudioChatRequestInput `json:"input"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LMStudioChatRequestInput defines the structure of LM Studio chat request message
|
||||||
|
type LMStudioChatRequestInput struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Content string `json:"content,omitempty"`
|
||||||
|
DataUrl string `json:"data_url,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LMStudioChatResponse defines the structure of LM Studio chat response
|
||||||
|
type LMStudioChatResponse struct {
|
||||||
|
Output []*LMStudioChatResponseOutput `json:"output"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LMStudioChatResponseOutput defines the structure of LM Studio chat response message
|
||||||
|
type LMStudioChatResponseOutput struct {
|
||||||
|
Content *string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildTextualRequest returns the http request by LM Studio large language model adapter
|
||||||
|
func (p *LMStudioLargeLanguageModelAdapter) BuildTextualRequest(c core.Context, uid int64, request *data.LargeLanguageModelRequest, responseType data.LargeLanguageModelResponseFormat) (*http.Request, error) {
|
||||||
|
requestBody, err := p.buildJsonRequestBody(c, uid, request, responseType)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
httpRequest, err := http.NewRequest("POST", p.getLMStudioRequestUrl(), bytes.NewReader(requestBody))
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.LMStudioToken != "" {
|
||||||
|
httpRequest.Header.Set("Authorization", "Bearer "+p.LMStudioToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
httpRequest.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
return httpRequest, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseTextualResponse returns the textual response by LM Studio large language model adapter
|
||||||
|
func (p *LMStudioLargeLanguageModelAdapter) ParseTextualResponse(c core.Context, uid int64, body []byte, responseType data.LargeLanguageModelResponseFormat) (*data.LargeLanguageModelTextualResponse, error) {
|
||||||
|
chatResponse := &LMStudioChatResponse{}
|
||||||
|
err := json.Unmarshal(body, &chatResponse)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf(c, "[lm_studio_large_language_model_adapter.ParseTextualResponse] failed to parse chat response for user \"uid:%d\", because %s", uid, err.Error())
|
||||||
|
return nil, errs.ErrFailedToRequestRemoteApi
|
||||||
|
}
|
||||||
|
|
||||||
|
if chatResponse == nil || len(chatResponse.Output) < 1 || chatResponse.Output[0].Content == nil {
|
||||||
|
log.Errorf(c, "[lm_studio_large_language_model_adapter.ParseTextualResponse] chat response is invalid for user \"uid:%d\"", uid)
|
||||||
|
return nil, errs.ErrFailedToRequestRemoteApi
|
||||||
|
}
|
||||||
|
|
||||||
|
textualResponse := &data.LargeLanguageModelTextualResponse{
|
||||||
|
Content: *chatResponse.Output[0].Content,
|
||||||
|
}
|
||||||
|
|
||||||
|
return textualResponse, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *LMStudioLargeLanguageModelAdapter) buildJsonRequestBody(c core.Context, uid int64, request *data.LargeLanguageModelRequest, responseType data.LargeLanguageModelResponseFormat) ([]byte, error) {
|
||||||
|
if p.LMStudioModelID == "" {
|
||||||
|
return nil, errs.ErrInvalidLLMModelId
|
||||||
|
}
|
||||||
|
|
||||||
|
chatRequest := &LMStudioChatRequest{
|
||||||
|
Model: p.LMStudioModelID,
|
||||||
|
Stream: request.Stream,
|
||||||
|
Input: make([]*LMStudioChatRequestInput, 0, 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
if request.SystemPrompt != "" {
|
||||||
|
chatRequest.SystemPrompt = request.SystemPrompt
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(request.UserPrompt) > 0 {
|
||||||
|
if request.UserPromptType == data.LARGE_LANGUAGE_MODEL_REQUEST_PROMPT_TYPE_IMAGE_URL {
|
||||||
|
imageBase64Data := "data:" + request.UserPromptContentType + ";base64," + base64.StdEncoding.EncodeToString(request.UserPrompt)
|
||||||
|
chatRequest.Input = append(chatRequest.Input, &LMStudioChatRequestInput{
|
||||||
|
Type: "image",
|
||||||
|
DataUrl: imageBase64Data,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
chatRequest.Input = append(chatRequest.Input, &LMStudioChatRequestInput{
|
||||||
|
Type: "text",
|
||||||
|
Content: string(request.UserPrompt),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
requestBodyBytes, err := json.Marshal(chatRequest)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf(c, "[lm_studio_large_language_model_adapter.buildJsonRequestBody] failed to marshal request body for user \"uid:%d\", because %s", uid, err.Error())
|
||||||
|
return nil, errs.ErrOperationFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debugf(c, "[lm_studio_large_language_model_adapter.buildJsonRequestBody] request body is %s", requestBodyBytes)
|
||||||
|
return requestBodyBytes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *LMStudioLargeLanguageModelAdapter) getLMStudioRequestUrl() string {
|
||||||
|
url := p.LMStudioServerURL
|
||||||
|
|
||||||
|
if url[len(url)-1] != '/' {
|
||||||
|
url += "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
url += lmStudioChatPath
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLMStudioLargeLanguageModelProvider creates a new LM Studio large language model provider instance
|
||||||
|
func NewLMStudioLargeLanguageModelProvider(llmConfig *settings.LLMConfig, enableResponseLog bool) provider.LargeLanguageModelProvider {
|
||||||
|
return common.NewCommonHttpLargeLanguageModelProvider(llmConfig, enableResponseLog, &LMStudioLargeLanguageModelAdapter{
|
||||||
|
LMStudioServerURL: llmConfig.LMStudioServerURL,
|
||||||
|
LMStudioToken: llmConfig.LMStudioToken,
|
||||||
|
LMStudioModelID: llmConfig.LMStudioModelID,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
package lmstudio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/llm/data"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLMStudioLargeLanguageModelAdapter_buildJsonRequestBody_TextualUserPrompt(t *testing.T) {
|
||||||
|
adapter := &LMStudioLargeLanguageModelAdapter{
|
||||||
|
LMStudioModelID: "test",
|
||||||
|
}
|
||||||
|
|
||||||
|
request := &data.LargeLanguageModelRequest{
|
||||||
|
SystemPrompt: "You are a helpful assistant.",
|
||||||
|
UserPrompt: []byte("Hello, how are you?"),
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyBytes, err := adapter.buildJsonRequestBody(core.NewNullContext(), 0, request, data.LARGE_LANGUAGE_MODEL_RESPONSE_FORMAT_JSON)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
var body map[string]interface{}
|
||||||
|
err = json.Unmarshal(bodyBytes, &body)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, "{\"model\":\"test\",\"stream\":false,\"system_prompt\":\"You are a helpful assistant.\",\"input\":[{\"type\":\"text\",\"content\":\"Hello, how are you?\"}]}", string(bodyBytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLMStudioLargeLanguageModelAdapter_buildJsonRequestBody_ImageUserPrompt(t *testing.T) {
|
||||||
|
adapter := &LMStudioLargeLanguageModelAdapter{
|
||||||
|
LMStudioModelID: "test",
|
||||||
|
}
|
||||||
|
|
||||||
|
request := &data.LargeLanguageModelRequest{
|
||||||
|
SystemPrompt: "What's in this image?",
|
||||||
|
UserPrompt: []byte("fakedata"),
|
||||||
|
UserPromptType: data.LARGE_LANGUAGE_MODEL_REQUEST_PROMPT_TYPE_IMAGE_URL,
|
||||||
|
UserPromptContentType: "image/png",
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyBytes, err := adapter.buildJsonRequestBody(core.NewNullContext(), 0, request, data.LARGE_LANGUAGE_MODEL_RESPONSE_FORMAT_JSON)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
var body map[string]interface{}
|
||||||
|
err = json.Unmarshal(bodyBytes, &body)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, "{\"model\":\"test\",\"stream\":false,\"system_prompt\":\"What's in this image?\",\"input\":[{\"type\":\"image\",\"data_url\":\"data:image/png;base64,ZmFrZWRhdGE=\"}]}", string(bodyBytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLMStudioLargeLanguageModelAdapter_ParseTextualResponse_ValidJsonResponse(t *testing.T) {
|
||||||
|
adapter := &LMStudioLargeLanguageModelAdapter{}
|
||||||
|
|
||||||
|
response := `{
|
||||||
|
"model_instance_id": "test",
|
||||||
|
"output": [
|
||||||
|
{
|
||||||
|
"type": "message",
|
||||||
|
"content": "This is a test response"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`
|
||||||
|
|
||||||
|
result, err := adapter.ParseTextualResponse(core.NewNullContext(), 0, []byte(response), data.LARGE_LANGUAGE_MODEL_RESPONSE_FORMAT_JSON)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.Equal(t, "This is a test response", result.Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLMStudioLargeLanguageModelAdapter_ParseTextualResponse_EmptyOutputContent(t *testing.T) {
|
||||||
|
adapter := &LMStudioLargeLanguageModelAdapter{}
|
||||||
|
|
||||||
|
response := `{
|
||||||
|
"model_instance_id": "test",
|
||||||
|
"output": [
|
||||||
|
{
|
||||||
|
"type": "message",
|
||||||
|
"content": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`
|
||||||
|
|
||||||
|
result, err := adapter.ParseTextualResponse(core.NewNullContext(), 0, []byte(response), data.LARGE_LANGUAGE_MODEL_RESPONSE_FORMAT_JSON)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.Equal(t, "", result.Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLMStudioLargeLanguageModelAdapter_ParseTextualResponse_EmptyOutput(t *testing.T) {
|
||||||
|
adapter := &LMStudioLargeLanguageModelAdapter{}
|
||||||
|
|
||||||
|
response := `{
|
||||||
|
"model_instance_id": "test",
|
||||||
|
"output": []
|
||||||
|
}`
|
||||||
|
|
||||||
|
_, err := adapter.ParseTextualResponse(core.NewNullContext(), 0, []byte(response), data.LARGE_LANGUAGE_MODEL_RESPONSE_FORMAT_JSON)
|
||||||
|
assert.EqualError(t, err, "failed to request third party api")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLMStudioLargeLanguageModelAdapter_ParseTextualResponse_NoContentFieldInOutput(t *testing.T) {
|
||||||
|
adapter := &LMStudioLargeLanguageModelAdapter{}
|
||||||
|
|
||||||
|
response := `{
|
||||||
|
"model_instance_id": "test",
|
||||||
|
"output": [
|
||||||
|
{
|
||||||
|
"type": "message"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`
|
||||||
|
|
||||||
|
_, err := adapter.ParseTextualResponse(core.NewNullContext(), 0, []byte(response), data.LARGE_LANGUAGE_MODEL_RESPONSE_FORMAT_JSON)
|
||||||
|
assert.EqualError(t, err, "failed to request third party api")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLMStudioLargeLanguageModelAdapter_ParseTextualResponse_InvalidJson(t *testing.T) {
|
||||||
|
adapter := &LMStudioLargeLanguageModelAdapter{}
|
||||||
|
|
||||||
|
response := "error"
|
||||||
|
|
||||||
|
_, err := adapter.ParseTextualResponse(core.NewNullContext(), 0, []byte(response), data.LARGE_LANGUAGE_MODEL_RESPONSE_FORMAT_JSON)
|
||||||
|
assert.EqualError(t, err, "failed to request third party api")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLMStudioLargeLanguageModelAdapter_GetOllamaRequestUrl(t *testing.T) {
|
||||||
|
adapter := &LMStudioLargeLanguageModelAdapter{
|
||||||
|
LMStudioServerURL: "http://localhost:1234/",
|
||||||
|
}
|
||||||
|
url := adapter.getLMStudioRequestUrl()
|
||||||
|
assert.Equal(t, "http://localhost:1234/api/v1/chat", url)
|
||||||
|
|
||||||
|
adapter = &LMStudioLargeLanguageModelAdapter{
|
||||||
|
LMStudioServerURL: "http://localhost:1234",
|
||||||
|
}
|
||||||
|
url = adapter.getLMStudioRequestUrl()
|
||||||
|
assert.Equal(t, "http://localhost:1234/api/v1/chat", url)
|
||||||
|
|
||||||
|
adapter = &LMStudioLargeLanguageModelAdapter{
|
||||||
|
LMStudioServerURL: "http://example.com/lmstudio/",
|
||||||
|
}
|
||||||
|
url = adapter.getLMStudioRequestUrl()
|
||||||
|
assert.Equal(t, "http://example.com/lmstudio/api/v1/chat", url)
|
||||||
|
}
|
||||||
@@ -10,24 +10,24 @@ var ptBR = &LocaleTextItems{
|
|||||||
},
|
},
|
||||||
DefaultTypes: &DefaultTypes{
|
DefaultTypes: &DefaultTypes{
|
||||||
DecimalSeparator: core.DECIMAL_SEPARATOR_COMMA,
|
DecimalSeparator: core.DECIMAL_SEPARATOR_COMMA,
|
||||||
DigitGroupingSymbol: core.DIGIT_GROUPING_SYMBOL_SPACE,
|
DigitGroupingSymbol: core.DIGIT_GROUPING_SYMBOL_DOT,
|
||||||
},
|
},
|
||||||
DataConverterTextItems: &DataConverterTextItems{
|
DataConverterTextItems: &DataConverterTextItems{
|
||||||
Alipay: "Alipay",
|
Alipay: "Alipay",
|
||||||
WeChatWallet: "Wallet",
|
WeChatWallet: "Wallet",
|
||||||
},
|
},
|
||||||
VerifyEmailTextItems: &VerifyEmailTextItems{
|
VerifyEmailTextItems: &VerifyEmailTextItems{
|
||||||
Title: "Verificar Email",
|
Title: "Verifique seu e-mail",
|
||||||
SalutationFormat: "Olá %s,",
|
SalutationFormat: "Olá %s,",
|
||||||
DescriptionAboveBtn: "Por favor, clique no link abaixo para confirmar o seu endereço de e-mail.",
|
DescriptionAboveBtn: "Clique no link abaixo para confirmar seu endereço de e-mail.",
|
||||||
VerifyEmail: "Verificar Email",
|
VerifyEmail: "Verificar e-mail",
|
||||||
DescriptionBelowBtnFormat: "Se você não se registrou para uma conta %s, basta ignorar este e-mail. Se não conseguir clicar no link acima, copie a URL acima e cole no seu navegador. O link para verificação de e-mail expirará após %v minutos.",
|
DescriptionBelowBtnFormat: "Se você não criou uma conta no %s, ignore este e-mail. Se não conseguir clicar no link acima, copie a URL e cole no navegador. O link de verificação de e-mail expira em %v minutos.",
|
||||||
},
|
},
|
||||||
ForgetPasswordMailTextItems: &ForgetPasswordMailTextItems{
|
ForgetPasswordMailTextItems: &ForgetPasswordMailTextItems{
|
||||||
Title: "Redefinir Sua Senha",
|
Title: "Redefina sua senha",
|
||||||
SalutationFormat: "Olá %s,",
|
SalutationFormat: "Olá %s,",
|
||||||
DescriptionAboveBtn: "Recebemos recentemente uma solicitação para redefinir a sua senha. Você pode clicar no link abaixo para redefinir sua senha.",
|
DescriptionAboveBtn: "Recebemos recentemente uma solicitação para redefinir sua senha. Clique no link abaixo para redefini-la.",
|
||||||
ResetPassword: "Redefinir Senha",
|
ResetPassword: "Redefinir senha",
|
||||||
DescriptionBelowBtnFormat: "Se você não solicitou a redefinição de senha, basta ignorar este e-mail. Se não conseguir clicar no link acima, copie a URL acima e cole no seu navegador. O link de redefinição de senha expirará após %v minutos.",
|
DescriptionBelowBtnFormat: "Se você não solicitou a redefinição da senha, ignore este e-mail. Se não conseguir clicar no link acima, copie a URL e cole no navegador. O link de redefinição de senha expira em %v minutos.",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package middlewares
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||||
|
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
// APITokenIpLimit limits API token access based on IP address
|
||||||
|
func APITokenIpLimit(config *settings.Config) core.MiddlewareHandlerFunc {
|
||||||
|
return func(c *core.WebContext) {
|
||||||
|
claims := c.GetTokenClaims()
|
||||||
|
|
||||||
|
if claims == nil {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if claims.Type != core.USER_TOKEN_TYPE_API {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(config.APITokenAllowedRemoteIPs) < 1 {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < len(config.APITokenAllowedRemoteIPs); i++ {
|
||||||
|
if config.APITokenAllowedRemoteIPs[i].Match(c.ClientIP()) {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.PrintJsonErrorResult(c, errs.ErrIPForbidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
-1
@@ -90,7 +90,8 @@ type Account struct {
|
|||||||
|
|
||||||
// AccountExtend represents account extend data stored in database
|
// AccountExtend represents account extend data stored in database
|
||||||
type AccountExtend struct {
|
type AccountExtend struct {
|
||||||
CreditCardStatementDate *int `json:"creditCardStatementDate"`
|
CreditCardStatementDate *int `json:"creditCardStatementDate"`
|
||||||
|
CreditLimit *int64 `json:"creditLimit"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AccountCreateRequest represents all parameters of account creation request
|
// AccountCreateRequest represents all parameters of account creation request
|
||||||
@@ -105,6 +106,7 @@ type AccountCreateRequest struct {
|
|||||||
BalanceTime int64 `json:"balanceTime"`
|
BalanceTime int64 `json:"balanceTime"`
|
||||||
Comment string `json:"comment" binding:"max=255"`
|
Comment string `json:"comment" binding:"max=255"`
|
||||||
CreditCardStatementDate int `json:"creditCardStatementDate" binding:"min=0,max=28"`
|
CreditCardStatementDate int `json:"creditCardStatementDate" binding:"min=0,max=28"`
|
||||||
|
CreditLimit int64 `json:"creditLimit" binding:"min=0"`
|
||||||
SubAccounts []*AccountCreateRequest `json:"subAccounts" binding:"omitempty"`
|
SubAccounts []*AccountCreateRequest `json:"subAccounts" binding:"omitempty"`
|
||||||
ClientSessionId string `json:"clientSessionId"`
|
ClientSessionId string `json:"clientSessionId"`
|
||||||
}
|
}
|
||||||
@@ -121,6 +123,7 @@ type AccountModifyRequest struct {
|
|||||||
BalanceTime *int64 `json:"balanceTime" binding:"omitempty"`
|
BalanceTime *int64 `json:"balanceTime" binding:"omitempty"`
|
||||||
Comment string `json:"comment" binding:"max=255"`
|
Comment string `json:"comment" binding:"max=255"`
|
||||||
CreditCardStatementDate int `json:"creditCardStatementDate" binding:"min=0,max=28"`
|
CreditCardStatementDate int `json:"creditCardStatementDate" binding:"min=0,max=28"`
|
||||||
|
CreditLimit int64 `json:"creditLimit" binding:"min=0"`
|
||||||
Hidden bool `json:"hidden"`
|
Hidden bool `json:"hidden"`
|
||||||
SubAccounts []*AccountModifyRequest `json:"subAccounts" binding:"omitempty"`
|
SubAccounts []*AccountModifyRequest `json:"subAccounts" binding:"omitempty"`
|
||||||
ClientSessionId string `json:"clientSessionId"`
|
ClientSessionId string `json:"clientSessionId"`
|
||||||
@@ -171,6 +174,7 @@ type AccountInfoResponse struct {
|
|||||||
Balance int64 `json:"balance"`
|
Balance int64 `json:"balance"`
|
||||||
Comment string `json:"comment"`
|
Comment string `json:"comment"`
|
||||||
CreditCardStatementDate *int `json:"creditCardStatementDate,omitempty"`
|
CreditCardStatementDate *int `json:"creditCardStatementDate,omitempty"`
|
||||||
|
CreditLimit *int64 `json:"creditLimit,omitempty"`
|
||||||
DisplayOrder int32 `json:"displayOrder"`
|
DisplayOrder int32 `json:"displayOrder"`
|
||||||
IsAsset bool `json:"isAsset,omitempty"`
|
IsAsset bool `json:"isAsset,omitempty"`
|
||||||
IsLiability bool `json:"isLiability,omitempty"`
|
IsLiability bool `json:"isLiability,omitempty"`
|
||||||
@@ -181,10 +185,14 @@ type AccountInfoResponse struct {
|
|||||||
// ToAccountInfoResponse returns a view-object according to database model
|
// ToAccountInfoResponse returns a view-object according to database model
|
||||||
func (a *Account) ToAccountInfoResponse() *AccountInfoResponse {
|
func (a *Account) ToAccountInfoResponse() *AccountInfoResponse {
|
||||||
var creditCardStatementDate *int
|
var creditCardStatementDate *int
|
||||||
|
var creditLimit *int64
|
||||||
|
|
||||||
if a.ParentAccountId == LevelOneAccountParentId && a.Category == ACCOUNT_CATEGORY_CREDIT_CARD {
|
if a.ParentAccountId == LevelOneAccountParentId && a.Category == ACCOUNT_CATEGORY_CREDIT_CARD {
|
||||||
if a.Extend != nil {
|
if a.Extend != nil {
|
||||||
creditCardStatementDate = a.Extend.CreditCardStatementDate
|
creditCardStatementDate = a.Extend.CreditCardStatementDate
|
||||||
|
if a.Extend.CreditLimit != nil && *a.Extend.CreditLimit > 0 {
|
||||||
|
creditLimit = a.Extend.CreditLimit
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
creditCardStatementDate = &defaultCreditCardAccountStatementDate
|
creditCardStatementDate = &defaultCreditCardAccountStatementDate
|
||||||
}
|
}
|
||||||
@@ -202,6 +210,7 @@ func (a *Account) ToAccountInfoResponse() *AccountInfoResponse {
|
|||||||
Balance: a.Balance,
|
Balance: a.Balance,
|
||||||
Comment: a.Comment,
|
Comment: a.Comment,
|
||||||
CreditCardStatementDate: creditCardStatementDate,
|
CreditCardStatementDate: creditCardStatementDate,
|
||||||
|
CreditLimit: creditLimit,
|
||||||
DisplayOrder: a.DisplayOrder,
|
DisplayOrder: a.DisplayOrder,
|
||||||
IsAsset: assetAccountCategory[a.Category],
|
IsAsset: assetAccountCategory[a.Category],
|
||||||
IsLiability: liabilityAccountCategory[a.Category],
|
IsLiability: liabilityAccountCategory[a.Category],
|
||||||
|
|||||||
@@ -544,7 +544,9 @@ func (t *Transaction) ToTransactionInfoResponse(tagIds []int64, editable bool) *
|
|||||||
destinationAccountId := int64(0)
|
destinationAccountId := int64(0)
|
||||||
destinationAmount := int64(0)
|
destinationAmount := int64(0)
|
||||||
|
|
||||||
if t.Type == TRANSACTION_DB_TYPE_TRANSFER_OUT {
|
if t.Type == TRANSACTION_DB_TYPE_MODIFY_BALANCE {
|
||||||
|
sourceAmount = t.RelatedAccountAmount // always return delta
|
||||||
|
} else if t.Type == TRANSACTION_DB_TYPE_TRANSFER_OUT {
|
||||||
destinationAccountId = t.RelatedAccountId
|
destinationAccountId = t.RelatedAccountId
|
||||||
destinationAmount = t.RelatedAccountAmount
|
destinationAmount = t.RelatedAccountAmount
|
||||||
} else if t.Type == TRANSACTION_DB_TYPE_TRANSFER_IN {
|
} else if t.Type == TRANSACTION_DB_TYPE_TRANSFER_IN {
|
||||||
|
|||||||
@@ -15,7 +15,10 @@ const (
|
|||||||
|
|
||||||
var ALL_ALLOWED_CLOUD_SYNC_APP_SETTING_KEY_TYPES = map[string]UserApplicationCloudSettingType{
|
var ALL_ALLOWED_CLOUD_SYNC_APP_SETTING_KEY_TYPES = map[string]UserApplicationCloudSettingType{
|
||||||
// Basic Settings
|
// Basic Settings
|
||||||
"showAccountBalance": USER_APPLICATION_CLOUD_SETTING_TYPE_BOOLEAN,
|
"showAccountBalance": USER_APPLICATION_CLOUD_SETTING_TYPE_BOOLEAN,
|
||||||
|
"autoUpdateExchangeRatesData": USER_APPLICATION_CLOUD_SETTING_TYPE_BOOLEAN,
|
||||||
|
// Navigation Bar
|
||||||
|
"showAddTransactionButtonInDesktopNavbar": USER_APPLICATION_CLOUD_SETTING_TYPE_BOOLEAN,
|
||||||
// Overview Page
|
// Overview Page
|
||||||
"showAmountInHomePage": USER_APPLICATION_CLOUD_SETTING_TYPE_BOOLEAN,
|
"showAmountInHomePage": USER_APPLICATION_CLOUD_SETTING_TYPE_BOOLEAN,
|
||||||
"timezoneUsedForStatisticsInHomePage": USER_APPLICATION_CLOUD_SETTING_TYPE_NUMBER,
|
"timezoneUsedForStatisticsInHomePage": USER_APPLICATION_CLOUD_SETTING_TYPE_NUMBER,
|
||||||
@@ -26,6 +29,8 @@ var ALL_ALLOWED_CLOUD_SYNC_APP_SETTING_KEY_TYPES = map[string]UserApplicationClo
|
|||||||
"showTotalAmountInTransactionListPage": USER_APPLICATION_CLOUD_SETTING_TYPE_BOOLEAN,
|
"showTotalAmountInTransactionListPage": USER_APPLICATION_CLOUD_SETTING_TYPE_BOOLEAN,
|
||||||
"showTagInTransactionListPage": USER_APPLICATION_CLOUD_SETTING_TYPE_BOOLEAN,
|
"showTagInTransactionListPage": USER_APPLICATION_CLOUD_SETTING_TYPE_BOOLEAN,
|
||||||
// Transaction Edit Page
|
// Transaction Edit Page
|
||||||
|
"quickSaveButtonStyleInMobileTransactionListPage": USER_APPLICATION_CLOUD_SETTING_TYPE_NUMBER,
|
||||||
|
"quickAddButtonActionInMobileTransactionEditPage": USER_APPLICATION_CLOUD_SETTING_TYPE_NUMBER,
|
||||||
"autoSaveTransactionDraft": USER_APPLICATION_CLOUD_SETTING_TYPE_STRING,
|
"autoSaveTransactionDraft": USER_APPLICATION_CLOUD_SETTING_TYPE_STRING,
|
||||||
"autoGetCurrentGeoLocation": USER_APPLICATION_CLOUD_SETTING_TYPE_BOOLEAN,
|
"autoGetCurrentGeoLocation": USER_APPLICATION_CLOUD_SETTING_TYPE_BOOLEAN,
|
||||||
"alwaysShowTransactionPicturesInMobileTransactionEditPage": USER_APPLICATION_CLOUD_SETTING_TYPE_BOOLEAN,
|
"alwaysShowTransactionPicturesInMobileTransactionEditPage": USER_APPLICATION_CLOUD_SETTING_TYPE_BOOLEAN,
|
||||||
@@ -41,6 +46,9 @@ var ALL_ALLOWED_CLOUD_SYNC_APP_SETTING_KEY_TYPES = map[string]UserApplicationClo
|
|||||||
"hideCategoriesWithoutAccounts": USER_APPLICATION_CLOUD_SETTING_TYPE_BOOLEAN,
|
"hideCategoriesWithoutAccounts": USER_APPLICATION_CLOUD_SETTING_TYPE_BOOLEAN,
|
||||||
// Exchange Rates Data Page
|
// Exchange Rates Data Page
|
||||||
"currencySortByInExchangeRatesPage": USER_APPLICATION_CLOUD_SETTING_TYPE_NUMBER,
|
"currencySortByInExchangeRatesPage": USER_APPLICATION_CLOUD_SETTING_TYPE_NUMBER,
|
||||||
|
// Browser Cache Management
|
||||||
|
"mapCacheExpiration": USER_APPLICATION_CLOUD_SETTING_TYPE_NUMBER,
|
||||||
|
"exchangeRatesDataCacheExpiration": USER_APPLICATION_CLOUD_SETTING_TYPE_NUMBER,
|
||||||
// Statistics Settings
|
// Statistics Settings
|
||||||
"statistics.defaultChartDataType": USER_APPLICATION_CLOUD_SETTING_TYPE_NUMBER,
|
"statistics.defaultChartDataType": USER_APPLICATION_CLOUD_SETTING_TYPE_NUMBER,
|
||||||
"statistics.defaultTimezoneType": USER_APPLICATION_CLOUD_SETTING_TYPE_NUMBER,
|
"statistics.defaultTimezoneType": USER_APPLICATION_CLOUD_SETTING_TYPE_NUMBER,
|
||||||
|
|||||||
+2
-11
@@ -20,15 +20,6 @@ import (
|
|||||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TokenUserAgentCreatedViaCli is the user agent of token created via cli
|
|
||||||
const TokenUserAgentCreatedViaCli = core.ApplicationName + " Cli"
|
|
||||||
|
|
||||||
// TokenUserAgentForAPI is the user agent for API token
|
|
||||||
const TokenUserAgentForAPI = core.ApplicationName + " API"
|
|
||||||
|
|
||||||
// TokenUserAgentForMCP is the user agent for MCP token
|
|
||||||
const TokenUserAgentForMCP = core.ApplicationName + " MCP"
|
|
||||||
|
|
||||||
const tokenMaxExpiredAtUnixTime = int64(253402300799) // 9999-12-31 23:59:59 UTC
|
const tokenMaxExpiredAtUnixTime = int64(253402300799) // 9999-12-31 23:59:59 UTC
|
||||||
|
|
||||||
// TokenService represents user token service
|
// TokenService represents user token service
|
||||||
@@ -140,7 +131,7 @@ func (s *TokenService) CreateAPITokenViaCli(c *core.CliContext, user *models.Use
|
|||||||
tokenExpiredTimeDuration = time.Unix(tokenMaxExpiredAtUnixTime, 0).Sub(time.Now())
|
tokenExpiredTimeDuration = time.Unix(tokenMaxExpiredAtUnixTime, 0).Sub(time.Now())
|
||||||
}
|
}
|
||||||
|
|
||||||
token, _, tokenRecord, err := s.createToken(c, user, core.USER_TOKEN_TYPE_API, TokenUserAgentCreatedViaCli, "", tokenExpiredTimeDuration)
|
token, _, tokenRecord, err := s.createToken(c, user, core.USER_TOKEN_TYPE_API, core.TokenUserAgentCreatedViaCli, "", tokenExpiredTimeDuration)
|
||||||
return token, tokenRecord, err
|
return token, tokenRecord, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,7 +159,7 @@ func (s *TokenService) CreateMCPTokenViaCli(c *core.CliContext, user *models.Use
|
|||||||
tokenExpiredTimeDuration = time.Unix(tokenMaxExpiredAtUnixTime, 0).Sub(time.Now())
|
tokenExpiredTimeDuration = time.Unix(tokenMaxExpiredAtUnixTime, 0).Sub(time.Now())
|
||||||
}
|
}
|
||||||
|
|
||||||
token, _, tokenRecord, err := s.createToken(c, user, core.USER_TOKEN_TYPE_MCP, TokenUserAgentCreatedViaCli, "", tokenExpiredTimeDuration)
|
token, _, tokenRecord, err := s.createToken(c, user, core.USER_TOKEN_TYPE_MCP, core.TokenUserAgentCreatedViaCli, "", tokenExpiredTimeDuration)
|
||||||
return token, tokenRecord, err
|
return token, tokenRecord, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -132,6 +132,23 @@ func (s *TransactionPictureService) GetPictureInfosByTransactionIds(c core.Conte
|
|||||||
return pictureInfoMap, err
|
return pictureInfoMap, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetAllPictureInfosOfAllTransactions returns all transaction picture info models
|
||||||
|
func (s *TransactionPictureService) GetAllPictureInfosOfAllTransactions(c core.Context, uid int64) (map[int64][]*models.TransactionPictureInfo, error) {
|
||||||
|
if uid <= 0 {
|
||||||
|
return nil, errs.ErrUserIdInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
var pictureInfos []*models.TransactionPictureInfo
|
||||||
|
err := s.UserDataDB(uid).NewSession(c).Where("uid=? AND deleted=?", uid, false).OrderBy("picture_id asc").Find(&pictureInfos)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
pictureInfoMap := s.GetPictureInfoListMapByList(pictureInfos)
|
||||||
|
return pictureInfoMap, err
|
||||||
|
}
|
||||||
|
|
||||||
// GetPictureByPictureId returns the transaction picture data according to transaction picture id
|
// GetPictureByPictureId returns the transaction picture data according to transaction picture id
|
||||||
func (s *TransactionPictureService) GetPictureByPictureId(c core.Context, uid int64, pictureId int64, fileExtension string) ([]byte, error) {
|
func (s *TransactionPictureService) GetPictureByPictureId(c core.Context, uid int64, pictureId int64, fileExtension string) ([]byte, error) {
|
||||||
if uid <= 0 {
|
if uid <= 0 {
|
||||||
|
|||||||
@@ -954,7 +954,7 @@ func (s *TransactionService) ModifyTransaction(c core.Context, transaction *mode
|
|||||||
|
|
||||||
if transaction.Amount != oldTransaction.Amount {
|
if transaction.Amount != oldTransaction.Amount {
|
||||||
if oldTransaction.Type == models.TRANSACTION_DB_TYPE_MODIFY_BALANCE {
|
if oldTransaction.Type == models.TRANSACTION_DB_TYPE_MODIFY_BALANCE {
|
||||||
transaction.RelatedAccountAmount = oldTransaction.RelatedAccountAmount + transaction.Amount - oldTransaction.Amount
|
transaction.RelatedAccountAmount = transaction.Amount // Amount IS the new delta
|
||||||
updateCols = append(updateCols, "related_account_amount")
|
updateCols = append(updateCols, "related_account_amount")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2251,17 +2251,8 @@ func (s *TransactionService) doCreateTransaction(c core.Context, database *datas
|
|||||||
|
|
||||||
// Verify balance modification transaction and calculate real amount
|
// Verify balance modification transaction and calculate real amount
|
||||||
if transaction.Type == models.TRANSACTION_DB_TYPE_MODIFY_BALANCE {
|
if transaction.Type == models.TRANSACTION_DB_TYPE_MODIFY_BALANCE {
|
||||||
otherTransactionExists, err := sess.Cols("uid", "deleted", "account_id").Where("uid=? AND deleted=? AND account_id=?", transaction.Uid, false, sourceAccount.AccountId).Limit(1).Exist(&models.Transaction{})
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf(c, "[transactions.doCreateTransaction] failed to get whether other transactions exist, because %s", err.Error())
|
|
||||||
return err
|
|
||||||
} else if otherTransactionExists {
|
|
||||||
return errs.ErrBalanceModificationTransactionCannotAddWhenNotEmpty
|
|
||||||
}
|
|
||||||
|
|
||||||
transaction.RelatedAccountId = transaction.AccountId
|
transaction.RelatedAccountId = transaction.AccountId
|
||||||
transaction.RelatedAccountAmount = transaction.Amount - sourceAccount.Balance
|
transaction.RelatedAccountAmount = transaction.Amount // Amount IS the delta
|
||||||
} else { // Not allow to add transaction before balance modification transaction
|
} else { // Not allow to add transaction before balance modification transaction
|
||||||
otherTransactionExists := false
|
otherTransactionExists := false
|
||||||
|
|
||||||
|
|||||||
@@ -379,6 +379,32 @@ func (s *UserService) UpdateUser(c core.Context, user *models.User, modifyUserLa
|
|||||||
return keyProfileUpdated, emailSetToUnverified, nil
|
return keyProfileUpdated, emailSetToUnverified, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateUserPassword updates the password of specified user
|
||||||
|
func (s *UserService) UpdateUserPassword(c core.Context, user *models.User) error {
|
||||||
|
if user.Uid <= 0 {
|
||||||
|
return errs.ErrUserIdInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.Password == "" {
|
||||||
|
return errs.ErrPasswordIsEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
user.Password = utils.EncodePassword(user.Password, user.Salt)
|
||||||
|
user.UpdatedUnixTime = time.Now().Unix()
|
||||||
|
|
||||||
|
return s.UserDB().DoTransaction(c, func(sess *xorm.Session) error {
|
||||||
|
updatedRows, err := sess.ID(user.Uid).Cols("password", "updated_unix_time").Where("deleted=?", false).Update(user)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
} else if updatedRows < 1 {
|
||||||
|
return errs.ErrUserNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// UpdateUserAvatar updates the custom avatar type of specified user
|
// UpdateUserAvatar updates the custom avatar type of specified user
|
||||||
func (s *UserService) UpdateUserAvatar(c core.Context, uid int64, avatarFile multipart.File, fileExtension string, oldFileExtension string) error {
|
func (s *UserService) UpdateUserAvatar(c core.Context, uid int64, avatarFile multipart.File, fileExtension string, oldFileExtension string) error {
|
||||||
if uid <= 0 {
|
if uid <= 0 {
|
||||||
|
|||||||
+84
-31
@@ -69,11 +69,14 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
OpenAILLMProvider string = "openai"
|
OpenAILLMProvider string = "openai"
|
||||||
OpenAICompatibleLLMProvider string = "openai_compatible"
|
OpenAICompatibleLLMProvider string = "openai_compatible"
|
||||||
OpenRouterLLMProvider string = "openrouter"
|
AnthropicLLMProvider string = "anthropic"
|
||||||
OllamaLLMProvider string = "ollama"
|
AnthropicCompatibleLLMProvider string = "anthropic_compatible"
|
||||||
GoogleAILLMProvider string = "google_ai"
|
OpenRouterLLMProvider string = "openrouter"
|
||||||
|
OllamaLLMProvider string = "ollama"
|
||||||
|
LMStudioLLMProvider string = "lm_studio"
|
||||||
|
GoogleAILLMProvider string = "google_ai"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Uuid generator types
|
// Uuid generator types
|
||||||
@@ -125,7 +128,6 @@ const (
|
|||||||
|
|
||||||
// Exchange rates data source types
|
// Exchange rates data source types
|
||||||
const (
|
const (
|
||||||
ReserveBankOfAustraliaDataSource string = "reserve_bank_of_australia"
|
|
||||||
BankOfCanadaDataSource string = "bank_of_canada"
|
BankOfCanadaDataSource string = "bank_of_canada"
|
||||||
CzechNationalBankDataSource string = "czech_national_bank"
|
CzechNationalBankDataSource string = "czech_national_bank"
|
||||||
DanmarksNationalbankDataSource string = "danmarks_national_bank"
|
DanmarksNationalbankDataSource string = "danmarks_national_bank"
|
||||||
@@ -161,8 +163,9 @@ const (
|
|||||||
|
|
||||||
defaultWebDAVRequestTimeout uint32 = 10000 // 10 seconds
|
defaultWebDAVRequestTimeout uint32 = 10000 // 10 seconds
|
||||||
|
|
||||||
defaultAIRecognitionPictureMaxSize uint32 = 10485760 // 10MB
|
defaultAIRecognitionPictureMaxSize uint32 = 10485760 // 10MB
|
||||||
defaultLargeLanguageModelAPIRequestTimeout uint32 = 60000 // 60 seconds
|
defaultAnthropicLargeLanguageModelAPIMaximumTokens uint32 = 1024
|
||||||
|
defaultLargeLanguageModelAPIRequestTimeout uint32 = 60000 // 60 seconds
|
||||||
|
|
||||||
defaultInMemoryDuplicateCheckerCleanupInterval uint32 = 60 // 1 minutes
|
defaultInMemoryDuplicateCheckerCleanupInterval uint32 = 60 // 1 minutes
|
||||||
defaultDuplicateSubmissionsInterval uint32 = 300 // 5 minutes
|
defaultDuplicateSubmissionsInterval uint32 = 300 // 5 minutes
|
||||||
@@ -244,10 +247,21 @@ type LLMConfig struct {
|
|||||||
OpenAICompatibleBaseURL string
|
OpenAICompatibleBaseURL string
|
||||||
OpenAICompatibleAPIKey string
|
OpenAICompatibleAPIKey string
|
||||||
OpenAICompatibleModelID string
|
OpenAICompatibleModelID string
|
||||||
|
AnthropicAPIKey string
|
||||||
|
AnthropicModelID string
|
||||||
|
AnthropicMaxTokens uint32
|
||||||
|
AnthropicCompatibleBaseURL string
|
||||||
|
AnthropicCompatibleAPIVersion string
|
||||||
|
AnthropicCompatibleAPIKey string
|
||||||
|
AnthropicCompatibleModelID string
|
||||||
|
AnthropicCompatibleMaxTokens uint32
|
||||||
OpenRouterAPIKey string
|
OpenRouterAPIKey string
|
||||||
OpenRouterModelID string
|
OpenRouterModelID string
|
||||||
OllamaServerURL string
|
OllamaServerURL string
|
||||||
OllamaModelID string
|
OllamaModelID string
|
||||||
|
LMStudioServerURL string
|
||||||
|
LMStudioToken string
|
||||||
|
LMStudioModelID string
|
||||||
GoogleAIAPIKey string
|
GoogleAIAPIKey string
|
||||||
GoogleAIModelID string
|
GoogleAIModelID string
|
||||||
LargeLanguageModelAPIRequestTimeout uint32
|
LargeLanguageModelAPIRequestTimeout uint32
|
||||||
@@ -356,6 +370,7 @@ type Config struct {
|
|||||||
PasswordResetTokenExpiredTime uint32
|
PasswordResetTokenExpiredTime uint32
|
||||||
PasswordResetTokenExpiredTimeDuration time.Duration
|
PasswordResetTokenExpiredTimeDuration time.Duration
|
||||||
EnableAPIToken bool
|
EnableAPIToken bool
|
||||||
|
APITokenAllowedRemoteIPs []*core.IPPattern
|
||||||
MaxFailuresPerIpPerMinute uint32
|
MaxFailuresPerIpPerMinute uint32
|
||||||
MaxFailuresPerUserPerMinute uint32
|
MaxFailuresPerUserPerMinute uint32
|
||||||
|
|
||||||
@@ -653,29 +668,13 @@ func loadServerConfiguration(config *Config, configFile *ini.File, sectionName s
|
|||||||
}
|
}
|
||||||
|
|
||||||
func loadMCPServerConfiguration(config *Config, configFile *ini.File, sectionName string) error {
|
func loadMCPServerConfiguration(config *Config, configFile *ini.File, sectionName string) error {
|
||||||
|
var err error
|
||||||
|
|
||||||
config.EnableMCPServer = getConfigItemBoolValue(configFile, sectionName, "enable_mcp", false)
|
config.EnableMCPServer = getConfigItemBoolValue(configFile, sectionName, "enable_mcp", false)
|
||||||
mcpAllowedRemoteIps := getConfigItemStringValue(configFile, sectionName, "mcp_allowed_remote_ips", "")
|
config.MCPAllowedRemoteIPs, err = getIPPatterns(configFile, sectionName, "mcp_allowed_remote_ips", "")
|
||||||
|
|
||||||
if mcpAllowedRemoteIps != "" {
|
if err != nil {
|
||||||
remoteIPs := strings.Split(mcpAllowedRemoteIps, ",")
|
return err
|
||||||
config.MCPAllowedRemoteIPs = make([]*core.IPPattern, 0, len(remoteIPs))
|
|
||||||
|
|
||||||
for i := 0; i < len(remoteIPs); i++ {
|
|
||||||
ip := strings.TrimSpace(remoteIPs[i])
|
|
||||||
pattern, err := core.ParseIPPattern(ip)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if pattern == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
config.MCPAllowedRemoteIPs = append(config.MCPAllowedRemoteIPs, pattern)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
config.MCPAllowedRemoteIPs = nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -860,10 +859,16 @@ func loadLLMConfiguration(configFile *ini.File, sectionName string) (*LLMConfig,
|
|||||||
llmConfig.LLMProvider = OpenAILLMProvider
|
llmConfig.LLMProvider = OpenAILLMProvider
|
||||||
} else if llmProvider == OpenAICompatibleLLMProvider {
|
} else if llmProvider == OpenAICompatibleLLMProvider {
|
||||||
llmConfig.LLMProvider = OpenAICompatibleLLMProvider
|
llmConfig.LLMProvider = OpenAICompatibleLLMProvider
|
||||||
|
} else if llmProvider == AnthropicLLMProvider {
|
||||||
|
llmConfig.LLMProvider = AnthropicLLMProvider
|
||||||
|
} else if llmProvider == AnthropicCompatibleLLMProvider {
|
||||||
|
llmConfig.LLMProvider = AnthropicCompatibleLLMProvider
|
||||||
} else if llmProvider == OpenRouterLLMProvider {
|
} else if llmProvider == OpenRouterLLMProvider {
|
||||||
llmConfig.LLMProvider = OpenRouterLLMProvider
|
llmConfig.LLMProvider = OpenRouterLLMProvider
|
||||||
} else if llmProvider == OllamaLLMProvider {
|
} else if llmProvider == OllamaLLMProvider {
|
||||||
llmConfig.LLMProvider = OllamaLLMProvider
|
llmConfig.LLMProvider = OllamaLLMProvider
|
||||||
|
} else if llmProvider == LMStudioLLMProvider {
|
||||||
|
llmConfig.LLMProvider = LMStudioLLMProvider
|
||||||
} else if llmProvider == GoogleAILLMProvider {
|
} else if llmProvider == GoogleAILLMProvider {
|
||||||
llmConfig.LLMProvider = GoogleAILLMProvider
|
llmConfig.LLMProvider = GoogleAILLMProvider
|
||||||
} else {
|
} else {
|
||||||
@@ -877,12 +882,26 @@ func loadLLMConfiguration(configFile *ini.File, sectionName string) (*LLMConfig,
|
|||||||
llmConfig.OpenAICompatibleAPIKey = getConfigItemStringValue(configFile, sectionName, "openai_compatible_api_key")
|
llmConfig.OpenAICompatibleAPIKey = getConfigItemStringValue(configFile, sectionName, "openai_compatible_api_key")
|
||||||
llmConfig.OpenAICompatibleModelID = getConfigItemStringValue(configFile, sectionName, "openai_compatible_model_id")
|
llmConfig.OpenAICompatibleModelID = getConfigItemStringValue(configFile, sectionName, "openai_compatible_model_id")
|
||||||
|
|
||||||
|
llmConfig.AnthropicAPIKey = getConfigItemStringValue(configFile, sectionName, "anthropic_api_key")
|
||||||
|
llmConfig.AnthropicModelID = getConfigItemStringValue(configFile, sectionName, "anthropic_model_id")
|
||||||
|
llmConfig.AnthropicMaxTokens = getConfigItemUint32Value(configFile, sectionName, "anthropic_max_tokens", defaultAnthropicLargeLanguageModelAPIMaximumTokens)
|
||||||
|
|
||||||
|
llmConfig.AnthropicCompatibleBaseURL = getConfigItemStringValue(configFile, sectionName, "anthropic_compatible_base_url")
|
||||||
|
llmConfig.AnthropicCompatibleAPIVersion = getConfigItemStringValue(configFile, sectionName, "anthropic_compatible_api_version")
|
||||||
|
llmConfig.AnthropicCompatibleAPIKey = getConfigItemStringValue(configFile, sectionName, "anthropic_compatible_api_key")
|
||||||
|
llmConfig.AnthropicCompatibleModelID = getConfigItemStringValue(configFile, sectionName, "anthropic_compatible_model_id")
|
||||||
|
llmConfig.AnthropicCompatibleMaxTokens = getConfigItemUint32Value(configFile, sectionName, "anthropic_compatible_max_tokens", defaultAnthropicLargeLanguageModelAPIMaximumTokens)
|
||||||
|
|
||||||
llmConfig.OpenRouterAPIKey = getConfigItemStringValue(configFile, sectionName, "openrouter_api_key")
|
llmConfig.OpenRouterAPIKey = getConfigItemStringValue(configFile, sectionName, "openrouter_api_key")
|
||||||
llmConfig.OpenRouterModelID = getConfigItemStringValue(configFile, sectionName, "openrouter_model_id")
|
llmConfig.OpenRouterModelID = getConfigItemStringValue(configFile, sectionName, "openrouter_model_id")
|
||||||
|
|
||||||
llmConfig.OllamaServerURL = getConfigItemStringValue(configFile, sectionName, "ollama_server_url")
|
llmConfig.OllamaServerURL = getConfigItemStringValue(configFile, sectionName, "ollama_server_url")
|
||||||
llmConfig.OllamaModelID = getConfigItemStringValue(configFile, sectionName, "ollama_model_id")
|
llmConfig.OllamaModelID = getConfigItemStringValue(configFile, sectionName, "ollama_model_id")
|
||||||
|
|
||||||
|
llmConfig.LMStudioServerURL = getConfigItemStringValue(configFile, sectionName, "lm_studio_server_url")
|
||||||
|
llmConfig.LMStudioToken = getConfigItemStringValue(configFile, sectionName, "lm_studio_token")
|
||||||
|
llmConfig.LMStudioModelID = getConfigItemStringValue(configFile, sectionName, "lm_studio_model_id")
|
||||||
|
|
||||||
llmConfig.GoogleAIAPIKey = getConfigItemStringValue(configFile, sectionName, "google_ai_api_key")
|
llmConfig.GoogleAIAPIKey = getConfigItemStringValue(configFile, sectionName, "google_ai_api_key")
|
||||||
llmConfig.GoogleAIModelID = getConfigItemStringValue(configFile, sectionName, "google_ai_model_id")
|
llmConfig.GoogleAIModelID = getConfigItemStringValue(configFile, sectionName, "google_ai_model_id")
|
||||||
|
|
||||||
@@ -942,6 +961,8 @@ func loadCronConfiguration(config *Config, configFile *ini.File, sectionName str
|
|||||||
}
|
}
|
||||||
|
|
||||||
func loadSecurityConfiguration(config *Config, configFile *ini.File, sectionName string) error {
|
func loadSecurityConfiguration(config *Config, configFile *ini.File, sectionName string) error {
|
||||||
|
var err error
|
||||||
|
|
||||||
config.SecretKeyNoSet = !getConfigItemIsSet(configFile, sectionName, "secret_key")
|
config.SecretKeyNoSet = !getConfigItemIsSet(configFile, sectionName, "secret_key")
|
||||||
config.SecretKey = getConfigItemStringValue(configFile, sectionName, "secret_key", defaultSecretKey)
|
config.SecretKey = getConfigItemStringValue(configFile, sectionName, "secret_key", defaultSecretKey)
|
||||||
|
|
||||||
@@ -984,6 +1005,11 @@ func loadSecurityConfiguration(config *Config, configFile *ini.File, sectionName
|
|||||||
config.PasswordResetTokenExpiredTimeDuration = time.Duration(config.PasswordResetTokenExpiredTime) * time.Second
|
config.PasswordResetTokenExpiredTimeDuration = time.Duration(config.PasswordResetTokenExpiredTime) * time.Second
|
||||||
|
|
||||||
config.EnableAPIToken = getConfigItemBoolValue(configFile, sectionName, "enable_api_token", false)
|
config.EnableAPIToken = getConfigItemBoolValue(configFile, sectionName, "enable_api_token", false)
|
||||||
|
config.APITokenAllowedRemoteIPs, err = getIPPatterns(configFile, sectionName, "api_token_allowed_remote_ips", "")
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
config.MaxFailuresPerIpPerMinute = getConfigItemUint32Value(configFile, sectionName, "max_failures_per_ip_per_minute", defaultMaxFailuresPerIpPerMinute)
|
config.MaxFailuresPerIpPerMinute = getConfigItemUint32Value(configFile, sectionName, "max_failures_per_ip_per_minute", defaultMaxFailuresPerIpPerMinute)
|
||||||
config.MaxFailuresPerUserPerMinute = getConfigItemUint32Value(configFile, sectionName, "max_failures_per_user_per_minute", defaultMaxFailuresPerUserPerMinute)
|
config.MaxFailuresPerUserPerMinute = getConfigItemUint32Value(configFile, sectionName, "max_failures_per_user_per_minute", defaultMaxFailuresPerUserPerMinute)
|
||||||
@@ -1163,8 +1189,7 @@ func loadMapConfiguration(config *Config, configFile *ini.File, sectionName stri
|
|||||||
func loadExchangeRatesConfiguration(config *Config, configFile *ini.File, sectionName string) error {
|
func loadExchangeRatesConfiguration(config *Config, configFile *ini.File, sectionName string) error {
|
||||||
dataSource := getConfigItemStringValue(configFile, sectionName, "data_source")
|
dataSource := getConfigItemStringValue(configFile, sectionName, "data_source")
|
||||||
|
|
||||||
if dataSource == ReserveBankOfAustraliaDataSource ||
|
if dataSource == BankOfCanadaDataSource ||
|
||||||
dataSource == BankOfCanadaDataSource ||
|
|
||||||
dataSource == CzechNationalBankDataSource ||
|
dataSource == CzechNationalBankDataSource ||
|
||||||
dataSource == DanmarksNationalbankDataSource ||
|
dataSource == DanmarksNationalbankDataSource ||
|
||||||
dataSource == EuroCentralBankDataSource ||
|
dataSource == EuroCentralBankDataSource ||
|
||||||
@@ -1227,6 +1252,34 @@ func getFinalPath(workingPath, p string) (string, error) {
|
|||||||
return p, err
|
return p, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getIPPatterns(configFile *ini.File, sectionName string, itemName string, defaultValue string) ([]*core.IPPattern, error) {
|
||||||
|
configValue := getConfigItemStringValue(configFile, sectionName, itemName, defaultValue)
|
||||||
|
|
||||||
|
if configValue == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
remoteIPs := strings.Split(configValue, ",")
|
||||||
|
ipPatterns := make([]*core.IPPattern, 0, len(remoteIPs))
|
||||||
|
|
||||||
|
for i := 0; i < len(remoteIPs); i++ {
|
||||||
|
ip := strings.TrimSpace(remoteIPs[i])
|
||||||
|
pattern, err := core.ParseIPPattern(ip)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if pattern == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
ipPatterns = append(ipPatterns, pattern)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ipPatterns, nil
|
||||||
|
}
|
||||||
|
|
||||||
func getMultiLanguageContentConfig(configFile *ini.File, sectionName string, enableKey string, contentKey string) MultiLanguageContentConfig {
|
func getMultiLanguageContentConfig(configFile *ini.File, sectionName string, enableKey string, contentKey string) MultiLanguageContentConfig {
|
||||||
config := MultiLanguageContentConfig{
|
config := MultiLanguageContentConfig{
|
||||||
Enabled: getConfigItemBoolValue(configFile, sectionName, enableKey, false),
|
Enabled: getConfigItemBoolValue(configFile, sectionName, enableKey, false),
|
||||||
|
|||||||
@@ -1,11 +1,5 @@
|
|||||||
package settings
|
package settings
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ConfigContainer contains the current setting config
|
// ConfigContainer contains the current setting config
|
||||||
type ConfigContainer struct {
|
type ConfigContainer struct {
|
||||||
current *Config
|
current *Config
|
||||||
@@ -13,10 +7,7 @@ type ConfigContainer struct {
|
|||||||
|
|
||||||
// Initialize a config container singleton instance
|
// Initialize a config container singleton instance
|
||||||
var (
|
var (
|
||||||
Version string
|
Container = &ConfigContainer{}
|
||||||
CommitHash string
|
|
||||||
BuildTime string
|
|
||||||
Container = &ConfigContainer{}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// SetCurrentConfig sets the current config by a given config
|
// SetCurrentConfig sets the current config by a given config
|
||||||
@@ -28,11 +19,3 @@ func SetCurrentConfig(config *Config) {
|
|||||||
func (c *ConfigContainer) GetCurrentConfig() *Config {
|
func (c *ConfigContainer) GetCurrentConfig() *Config {
|
||||||
return c.current
|
return c.current
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetUserAgent() string {
|
|
||||||
if Version == "" {
|
|
||||||
return core.ApplicationName
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Sprintf("%s/%s", core.ApplicationName, Version)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ func NewWebDAVObjectStorage(config *settings.Config, pathPrefix string) (*WebDAV
|
|||||||
webDavConfig := config.WebDAVConfig
|
webDavConfig := config.WebDAVConfig
|
||||||
|
|
||||||
storage := &WebDAVObjectStorage{
|
storage := &WebDAVObjectStorage{
|
||||||
httpClient: httpclient.NewHttpClient(webDavConfig.RequestTimeout, webDavConfig.Proxy, webDavConfig.SkipTLSVerify, settings.GetUserAgent(), false),
|
httpClient: httpclient.NewHttpClient(webDavConfig.RequestTimeout, webDavConfig.Proxy, webDavConfig.SkipTLSVerify, core.GetOutgoingUserAgent(), false),
|
||||||
webDavConfig: webDavConfig,
|
webDavConfig: webDavConfig,
|
||||||
rootPath: webDavConfig.RootPath,
|
rootPath: webDavConfig.RootPath,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
---
|
||||||
|
name: ezbookkeeping
|
||||||
|
description: Use ezBookkeeping API Tools script to record new transactions, query transactions, retrieve account information, retrieve categories, retrieve tags, and retrieve exchange rate data in the self hosted personal finance application ezBookkeeping.
|
||||||
|
---
|
||||||
|
|
||||||
|
# ezBookkeeping API Tools
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### List all supported commands
|
||||||
|
|
||||||
|
Linux / macOS
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sh scripts/ebktools.sh list
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
scripts\ebktools.ps1 list
|
||||||
|
```
|
||||||
|
|
||||||
|
### Show help for a specific command
|
||||||
|
|
||||||
|
Linux / macOS
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sh scripts/ebktools.sh help <command>
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
scripts\ebktools.ps1 help <command>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Call API
|
||||||
|
|
||||||
|
Linux / macOS
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sh scripts/ebktools.sh [global-options] <command> [command-options]
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
scripts\ebktools.ps1 [global-options] <command> [command-options]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
If the script reports that the environment variable `EBKTOOL_SERVER_BASEURL` or `EBKTOOL_TOKEN` is not set, user can define them as system environment variables, or create a `.env` file in the user home directory that contains these two variables and place it there.
|
||||||
|
|
||||||
|
The meanings of these environment variables are as follows:
|
||||||
|
|
||||||
|
| Variable | Required | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `EBKTOOL_SERVER_BASEURL` | Required | ezBookkeeping server base URL (e.g., `http://localhost:8080`) |
|
||||||
|
| `EBKTOOL_TOKEN` | Required | ezBookkeeping API token |
|
||||||
|
|
||||||
|
## Reference
|
||||||
|
|
||||||
|
ezBookkeeping: [https://ezbookkeeping.mayswind.net](https://ezbookkeeping.mayswind.net)
|
||||||
Executable
+1529
File diff suppressed because it is too large
Load Diff
Executable
+1266
File diff suppressed because it is too large
Load Diff
+5
-5
@@ -34,6 +34,7 @@ import { ThemeType } from '@/core/theme.ts';
|
|||||||
import { isProduction } from '@/lib/version.ts';
|
import { isProduction } from '@/lib/version.ts';
|
||||||
import { initMapProvider } from '@/lib/map/index.ts';
|
import { initMapProvider } from '@/lib/map/index.ts';
|
||||||
import { isUserLogined, isUserUnlocked } from '@/lib/userstate.ts';
|
import { isUserLogined, isUserUnlocked } from '@/lib/userstate.ts';
|
||||||
|
import { updateMapCacheExpiration } from '@/lib/cache.ts';
|
||||||
import { getSystemTheme, setExpenseAndIncomeAmountColor } from '@/lib/ui/common.ts';
|
import { getSystemTheme, setExpenseAndIncomeAmountColor } from '@/lib/ui/common.ts';
|
||||||
|
|
||||||
const { tt, getCurrentLanguageInfo, setLanguage, initLocale } = useI18n();
|
const { tt, getCurrentLanguageInfo, setLanguage, initLocale } = useI18n();
|
||||||
@@ -120,12 +121,11 @@ if (isUserLogined() && initialRoutePath !== '/verify_email' && initialRoutePath
|
|||||||
rootStore.setNotificationContent(response.notificationContent);
|
rootStore.setNotificationContent(response.notificationContent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
// auto refresh exchange rates data
|
updateMapCacheExpiration(settingsStore.appSettings.mapCacheExpiration);
|
||||||
if (settingsStore.appSettings.autoUpdateExchangeRatesData) {
|
exchangeRatesStore.removeExpiredExchangeRates(true);
|
||||||
exchangeRatesStore.getLatestExchangeRates({ silent: true, force: false });
|
exchangeRatesStore.autoUpdateExchangeRatesData();
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+14
-6
@@ -22,10 +22,13 @@ import { useExchangeRatesStore } from '@/stores/exchangeRates.ts';
|
|||||||
|
|
||||||
import { APPLICATION_LOGO_PATH } from '@/consts/asset.ts';
|
import { APPLICATION_LOGO_PATH } from '@/consts/asset.ts';
|
||||||
import { ThemeType } from '@/core/theme.ts';
|
import { ThemeType } from '@/core/theme.ts';
|
||||||
|
|
||||||
|
import { isFunction } from '@/lib/common.ts';
|
||||||
import { isProduction } from '@/lib/version.ts';
|
import { isProduction } from '@/lib/version.ts';
|
||||||
import { getTheme, isEnableSwipeBack, isEnableAnimate } from '@/lib/settings.ts';
|
import { getTheme, isEnableSwipeBack, isEnableAnimate } from '@/lib/settings.ts';
|
||||||
import { initMapProvider } from '@/lib/map/index.ts';
|
import { initMapProvider } from '@/lib/map/index.ts';
|
||||||
import { isUserLogined, isUserUnlocked } from '@/lib/userstate.ts';
|
import { isUserLogined, isUserUnlocked } from '@/lib/userstate.ts';
|
||||||
|
import { updateMapCacheExpiration } from '@/lib/cache.ts';
|
||||||
import { setExpenseAndIncomeAmountColor } from '@/lib/ui/common.ts';
|
import { setExpenseAndIncomeAmountColor } from '@/lib/ui/common.ts';
|
||||||
import { isiOSHomeScreenMode, isModalShowing, setAppFontSize } from '@/lib/ui/mobile.ts';
|
import { isiOSHomeScreenMode, isModalShowing, setAppFontSize } from '@/lib/ui/mobile.ts';
|
||||||
|
|
||||||
@@ -160,7 +163,7 @@ onMounted(() => {
|
|||||||
f7.on('sheetOpen', (sheet: Sheet.Sheet) => onBackdropChanged(sheet));
|
f7.on('sheetOpen', (sheet: Sheet.Sheet) => onBackdropChanged(sheet));
|
||||||
f7.on('sheetClose', (sheet: Sheet.Sheet) => onBackdropChanged(sheet));
|
f7.on('sheetClose', (sheet: Sheet.Sheet) => onBackdropChanged(sheet));
|
||||||
|
|
||||||
f7.on('pageBeforeOut', () => {
|
f7.on('pageBeforeOut', () => {
|
||||||
if (isModalShowing()) {
|
if (isModalShowing()) {
|
||||||
f7.actions.close('.actions-modal.modal-in', false);
|
f7.actions.close('.actions-modal.modal-in', false);
|
||||||
f7.dialog.close('.dialog.modal-in', false);
|
f7.dialog.close('.dialog.modal-in', false);
|
||||||
@@ -180,6 +183,12 @@ onMounted(() => {
|
|||||||
const languageInfo = getCurrentLanguageInfo();
|
const languageInfo = getCurrentLanguageInfo();
|
||||||
initMapProvider(languageInfo?.alternativeLanguageTag);
|
initMapProvider(languageInfo?.alternativeLanguageTag);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.addEventListener('dragstart', (e) => {
|
||||||
|
if (!e.target || !('closest' in e.target) || !isFunction(e.target.closest) || !e.target.closest('.dragenabled')) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(currentNotificationContent, (newValue) => {
|
watch(currentNotificationContent, (newValue) => {
|
||||||
@@ -227,12 +236,11 @@ if (isUserLogined()) {
|
|||||||
rootStore.setNotificationContent(response.notificationContent);
|
rootStore.setNotificationContent(response.notificationContent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
// auto refresh exchange rates data
|
updateMapCacheExpiration(settingsStore.appSettings.mapCacheExpiration);
|
||||||
if (settingsStore.appSettings.autoUpdateExchangeRatesData) {
|
exchangeRatesStore.removeExpiredExchangeRates(true);
|
||||||
exchangeRatesStore.getLatestExchangeRates({ silent: true, force: false });
|
exchangeRatesStore.autoUpdateExchangeRatesData();
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ export interface AccountBalanceTrendsChartItem {
|
|||||||
maximumBalance: number;
|
maximumBalance: number;
|
||||||
medianBalance: number;
|
medianBalance: number;
|
||||||
averageBalance: number;
|
averageBalance: number;
|
||||||
|
q1Balance: number;
|
||||||
|
q3Balance: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CommonAccountBalanceTrendsChartProps {
|
export interface CommonAccountBalanceTrendsChartProps {
|
||||||
@@ -162,6 +164,8 @@ export function useAccountBalanceTrendsChartBase(props: CommonAccountBalanceTren
|
|||||||
let lastMaximumBalance = lastClosingBalance;
|
let lastMaximumBalance = lastClosingBalance;
|
||||||
let lastMedianBalance = lastClosingBalance;
|
let lastMedianBalance = lastClosingBalance;
|
||||||
let lastAverageBalance = lastClosingBalance;
|
let lastAverageBalance = lastClosingBalance;
|
||||||
|
let lastQ1Balance = lastClosingBalance;
|
||||||
|
let lastQ3Balance = lastClosingBalance;
|
||||||
|
|
||||||
for (const dateRange of allDateRanges.value) {
|
for (const dateRange of allDateRanges.value) {
|
||||||
const minDateTime = parseDateTimeFromUnixTime(dateRange.minUnixTime);
|
const minDateTime = parseDateTimeFromUnixTime(dateRange.minUnixTime);
|
||||||
@@ -205,6 +209,8 @@ export function useAccountBalanceTrendsChartBase(props: CommonAccountBalanceTren
|
|||||||
const maximumBalance = Math.max(...dataItems.map(item => item.accountClosingBalance));
|
const maximumBalance = Math.max(...dataItems.map(item => item.accountClosingBalance));
|
||||||
const medianBalance = allDataItemsSortedByClosingBalance[Math.floor(allDataItemsSortedByClosingBalance.length / 2)]!.accountClosingBalance;
|
const medianBalance = allDataItemsSortedByClosingBalance[Math.floor(allDataItemsSortedByClosingBalance.length / 2)]!.accountClosingBalance;
|
||||||
const averageBalance = Math.trunc(sumAmounts(dataItems.map(item => item.accountClosingBalance)) / dataItems.length);
|
const averageBalance = Math.trunc(sumAmounts(dataItems.map(item => item.accountClosingBalance)) / dataItems.length);
|
||||||
|
const q1Balance = allDataItemsSortedByClosingBalance[Math.floor(allDataItemsSortedByClosingBalance.length / 4)]!.accountClosingBalance;
|
||||||
|
const q3Balance = allDataItemsSortedByClosingBalance[Math.floor(allDataItemsSortedByClosingBalance.length * 3 / 4)]!.accountClosingBalance;
|
||||||
|
|
||||||
if (props.account.isAsset) {
|
if (props.account.isAsset) {
|
||||||
lastOpeningBalance = openingBalance;
|
lastOpeningBalance = openingBalance;
|
||||||
@@ -213,6 +219,8 @@ export function useAccountBalanceTrendsChartBase(props: CommonAccountBalanceTren
|
|||||||
lastMaximumBalance = maximumBalance;
|
lastMaximumBalance = maximumBalance;
|
||||||
lastMedianBalance = medianBalance;
|
lastMedianBalance = medianBalance;
|
||||||
lastAverageBalance = averageBalance;
|
lastAverageBalance = averageBalance;
|
||||||
|
lastQ1Balance = q1Balance;
|
||||||
|
lastQ3Balance = q3Balance;
|
||||||
} else if (props.account.isLiability) {
|
} else if (props.account.isLiability) {
|
||||||
lastOpeningBalance = -openingBalance;
|
lastOpeningBalance = -openingBalance;
|
||||||
lastClosingBalance = -closingBalance;
|
lastClosingBalance = -closingBalance;
|
||||||
@@ -220,6 +228,8 @@ export function useAccountBalanceTrendsChartBase(props: CommonAccountBalanceTren
|
|||||||
lastMaximumBalance = -maximumBalance;
|
lastMaximumBalance = -maximumBalance;
|
||||||
lastMedianBalance = -medianBalance;
|
lastMedianBalance = -medianBalance;
|
||||||
lastAverageBalance = -averageBalance;
|
lastAverageBalance = -averageBalance;
|
||||||
|
lastQ1Balance = -q1Balance;
|
||||||
|
lastQ3Balance = -q3Balance;
|
||||||
} else {
|
} else {
|
||||||
lastOpeningBalance = openingBalance;
|
lastOpeningBalance = openingBalance;
|
||||||
lastClosingBalance = closingBalance;
|
lastClosingBalance = closingBalance;
|
||||||
@@ -227,6 +237,8 @@ export function useAccountBalanceTrendsChartBase(props: CommonAccountBalanceTren
|
|||||||
lastMaximumBalance = maximumBalance;
|
lastMaximumBalance = maximumBalance;
|
||||||
lastMedianBalance = medianBalance;
|
lastMedianBalance = medianBalance;
|
||||||
lastAverageBalance = averageBalance;
|
lastAverageBalance = averageBalance;
|
||||||
|
lastQ1Balance = q1Balance;
|
||||||
|
lastQ3Balance = q3Balance;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,7 +249,9 @@ export function useAccountBalanceTrendsChartBase(props: CommonAccountBalanceTren
|
|||||||
minimumBalance: lastMinimumBalance,
|
minimumBalance: lastMinimumBalance,
|
||||||
maximumBalance: lastMaximumBalance,
|
maximumBalance: lastMaximumBalance,
|
||||||
medianBalance: lastMedianBalance,
|
medianBalance: lastMedianBalance,
|
||||||
averageBalance: lastAverageBalance
|
averageBalance: lastAverageBalance,
|
||||||
|
q1Balance: lastQ1Balance,
|
||||||
|
q3Balance: lastQ3Balance
|
||||||
});
|
});
|
||||||
|
|
||||||
lastOpeningBalance = lastClosingBalance;
|
lastOpeningBalance = lastClosingBalance;
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ export function usePieChartBase(props: CommonPieChartProps) {
|
|||||||
|
|
||||||
accumulatedPaintPercent += finalItem.paintPercent;
|
accumulatedPaintPercent += finalItem.paintPercent;
|
||||||
finalItem.displayPercent = formatPercentToLocalizedNumerals(finalItem.percent, 2, '<0.01');
|
finalItem.displayPercent = formatPercentToLocalizedNumerals(finalItem.percent, 2, '<0.01');
|
||||||
finalItem.displayValue = props.amountValue ? formatAmountToLocalizedNumeralsWithCurrency(value, props.defaultCurrency) : formatNumberToLocalizedNumerals(value);
|
finalItem.displayValue = props.amountValue ? formatAmountToLocalizedNumeralsWithCurrency(value, props.defaultCurrency) : formatNumberToLocalizedNumerals(value, 2);
|
||||||
|
|
||||||
validItems.push(finalItem);
|
validItems.push(finalItem);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,6 +78,9 @@ const allSeries = computed<AccountBalanceTrendsChartDataItem[]>(() => {
|
|||||||
series.areaStyle = {};
|
series.areaStyle = {};
|
||||||
} else if (props.type === AccountBalanceTrendChartType.Column.type) {
|
} else if (props.type === AccountBalanceTrendChartType.Column.type) {
|
||||||
series.type = 'bar';
|
series.type = 'bar';
|
||||||
|
} else if (props.type === AccountBalanceTrendChartType.Boxplot.type) {
|
||||||
|
series.type = 'boxplot';
|
||||||
|
series.itemStyle.borderColor = series.itemStyle.color;
|
||||||
} else if (props.type === AccountBalanceTrendChartType.Candlestick.type) {
|
} else if (props.type === AccountBalanceTrendChartType.Candlestick.type) {
|
||||||
const expenseIncomeAmountColor = getExpenseAndIncomeAmountColor(userStore.currentUserExpenseAmountColor, userStore.currentUserIncomeAmountColor, isDarkMode.value);
|
const expenseIncomeAmountColor = getExpenseAndIncomeAmountColor(userStore.currentUserExpenseAmountColor, userStore.currentUserIncomeAmountColor, isDarkMode.value);
|
||||||
series.type = 'candlestick';
|
series.type = 'candlestick';
|
||||||
@@ -88,7 +91,15 @@ const allSeries = computed<AccountBalanceTrendsChartDataItem[]>(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const item of allDataItems.value) {
|
for (const item of allDataItems.value) {
|
||||||
if (props.type === AccountBalanceTrendChartType.Candlestick.type) {
|
if (props.type === AccountBalanceTrendChartType.Boxplot.type) {
|
||||||
|
series.data.push([
|
||||||
|
item.minimumBalance,
|
||||||
|
item.q1Balance,
|
||||||
|
item.medianBalance,
|
||||||
|
item.q3Balance,
|
||||||
|
item.maximumBalance
|
||||||
|
]);
|
||||||
|
} else if (props.type === AccountBalanceTrendChartType.Candlestick.type) {
|
||||||
series.data.push([
|
series.data.push([
|
||||||
item.openingBalance,
|
item.openingBalance,
|
||||||
item.closingBalance,
|
item.closingBalance,
|
||||||
@@ -114,20 +125,26 @@ const yAxisWidth = computed<number>(() => {
|
|||||||
|
|
||||||
for (const series of allSeries.value) {
|
for (const series of allSeries.value) {
|
||||||
for (const data of series.data) {
|
for (const data of series.data) {
|
||||||
let value: number;
|
let currentMinValue: number;
|
||||||
|
let currentMaxValue: number;
|
||||||
|
|
||||||
if (isArray(data)) {
|
if (isArray(data) && props.type === AccountBalanceTrendChartType.Boxplot.type) {
|
||||||
value = data[1] as number; // for candlestick, use closing balance
|
currentMinValue = data[0] as number;
|
||||||
|
currentMaxValue = data[4] as number;
|
||||||
|
} else if (isArray(data) && props.type === AccountBalanceTrendChartType.Candlestick.type) {
|
||||||
|
currentMinValue = data[2] as number;
|
||||||
|
currentMaxValue = data[3] as number;
|
||||||
} else {
|
} else {
|
||||||
value = data as number; // for line or bar chart
|
currentMinValue = data as number;
|
||||||
|
currentMaxValue = data as number;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (value > maxValue) {
|
if (currentMaxValue > maxValue) {
|
||||||
maxValue = value;
|
maxValue = currentMaxValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (value < minValue) {
|
if (currentMinValue < minValue) {
|
||||||
minValue = value;
|
minValue = currentMinValue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -172,7 +189,54 @@ const chartOptions = computed<object>(() => {
|
|||||||
color: isDarkMode.value ? '#eee' : '#333'
|
color: isDarkMode.value ? '#eee' : '#333'
|
||||||
},
|
},
|
||||||
formatter: (params: CallbackDataParams[]) => {
|
formatter: (params: CallbackDataParams[]) => {
|
||||||
if (props.type === AccountBalanceTrendChartType.Candlestick.type) {
|
if (props.type === AccountBalanceTrendChartType.Boxplot.type) {
|
||||||
|
const dataIndex = params[0]!.dataIndex;
|
||||||
|
const dataItem = allDataItems.value[dataIndex] as AccountBalanceTrendsChartItem;
|
||||||
|
const displayItems: NameValue[] = [
|
||||||
|
{
|
||||||
|
name: tt('Minimum Balance'),
|
||||||
|
value: formatAmountToLocalizedNumeralsWithCurrency(dataItem.minimumBalance, props.account.currency)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: tt('Q1 Balance (First Quartile)'),
|
||||||
|
value: formatAmountToLocalizedNumeralsWithCurrency(dataItem.q1Balance, props.account.currency)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: tt('Median Balance'),
|
||||||
|
value: formatAmountToLocalizedNumeralsWithCurrency(dataItem.medianBalance, props.account.currency)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: tt('Q3 Balance (Third Quartile)'),
|
||||||
|
value: formatAmountToLocalizedNumeralsWithCurrency(dataItem.q3Balance, props.account.currency)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: tt('Maximum Balance'),
|
||||||
|
value: formatAmountToLocalizedNumeralsWithCurrency(dataItem.maximumBalance, props.account.currency)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: tt('Opening Balance'),
|
||||||
|
value: formatAmountToLocalizedNumeralsWithCurrency(dataItem.openingBalance, props.account.currency)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: tt('Closing Balance'),
|
||||||
|
value: formatAmountToLocalizedNumeralsWithCurrency(dataItem.closingBalance, props.account.currency)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
let tooltip = `${params[0]!.name} ${props.legendName}<br/>`;
|
||||||
|
|
||||||
|
for (const [displayItem, index] of itemAndIndex(displayItems)) {
|
||||||
|
if (index === 5) {
|
||||||
|
tooltip += '<div style="border-bottom: ' + (isDarkMode.value ? '#eee' : '#333') + ' dashed 1px"></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
tooltip += `<div><span class="chart-pointer" style="background-color: #${DEFAULT_CHART_COLORS[index]}"></span>`
|
||||||
|
+ `<span>${displayItem.name}</span><span class="ms-5" style="float: inline-end">${displayItem.value}</span>`
|
||||||
|
+ `</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return tooltip;
|
||||||
|
} else if (props.type === AccountBalanceTrendChartType.Candlestick.type) {
|
||||||
const dataIndex = params[0]!.dataIndex;
|
const dataIndex = params[0]!.dataIndex;
|
||||||
const dataItem = allDataItems.value[dataIndex] as AccountBalanceTrendsChartItem;
|
const dataItem = allDataItems.value[dataIndex] as AccountBalanceTrendsChartItem;
|
||||||
const displayItems: NameValue[] = [
|
const displayItems: NameValue[] = [
|
||||||
@@ -205,8 +269,12 @@ const chartOptions = computed<object>(() => {
|
|||||||
let tooltip = `${params[0]!.name} ${props.legendName}<br/>`;
|
let tooltip = `${params[0]!.name} ${props.legendName}<br/>`;
|
||||||
|
|
||||||
for (const [displayItem, index] of itemAndIndex(displayItems)) {
|
for (const [displayItem, index] of itemAndIndex(displayItems)) {
|
||||||
|
if (index === 4) {
|
||||||
|
tooltip += '<div style="border-bottom: ' + (isDarkMode.value ? '#eee' : '#333') + ' dashed 1px"></div>';
|
||||||
|
}
|
||||||
|
|
||||||
tooltip += `<div><span class="chart-pointer" style="background-color: #${DEFAULT_CHART_COLORS[index]}"></span>`
|
tooltip += `<div><span class="chart-pointer" style="background-color: #${DEFAULT_CHART_COLORS[index]}"></span>`
|
||||||
+ `<span>${displayItem.name}</span><span class="ms-5" style="float: inline-end">${displayItem.value}</span><br/>`
|
+ `<span>${displayItem.name}</span><span class="ms-5" style="float: inline-end">${displayItem.value}</span>`
|
||||||
+ `</div>`;
|
+ `</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,7 +285,7 @@ const chartOptions = computed<object>(() => {
|
|||||||
|
|
||||||
return `${params[0]!.name}<br/>`
|
return `${params[0]!.name}<br/>`
|
||||||
+ '<div><span class="chart-pointer" style="background-color: #' + DEFAULT_CHART_COLORS[0] + '"></span>'
|
+ '<div><span class="chart-pointer" style="background-color: #' + DEFAULT_CHART_COLORS[0] + '"></span>'
|
||||||
+ `<span>${props.legendName}</span><span class="ms-5" style="float: inline-end">${value}</span><br/>`
|
+ `<span>${props.legendName}</span><span class="ms-5" style="float: inline-end">${value}</span>`
|
||||||
+ '</div>';
|
+ '</div>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ interface AxisChartDataItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface AxisChartTooltipItem extends SortableTransactionStatisticDataItem {
|
interface AxisChartTooltipItem extends SortableTransactionStatisticDataItem {
|
||||||
|
readonly id: string;
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
readonly color: unknown;
|
readonly color: unknown;
|
||||||
readonly displayOrders: number[];
|
readonly displayOrders: number[];
|
||||||
@@ -71,6 +72,9 @@ const props = defineProps<{
|
|||||||
amountValue?: boolean;
|
amountValue?: boolean;
|
||||||
defaultCurrency?: string;
|
defaultCurrency?: string;
|
||||||
enableClickItem?: boolean;
|
enableClickItem?: boolean;
|
||||||
|
tooltipExtraColumnNames?: string[];
|
||||||
|
tooltipExtraColumnTotalValues?: (categoryIndex: number, totalValue: number, visibleSeriesIds: string[]) => string[];
|
||||||
|
tooltipExtraColumnValues?: (seriesId: string, categoryIndex: number, currentValue: number) => string[];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -85,6 +89,7 @@ const {
|
|||||||
formatAmountToWesternArabicNumeralsWithoutDigitGrouping,
|
formatAmountToWesternArabicNumeralsWithoutDigitGrouping,
|
||||||
formatAmountToLocalizedNumeralsWithCurrency,
|
formatAmountToLocalizedNumeralsWithCurrency,
|
||||||
formatNumberToLocalizedNumerals,
|
formatNumberToLocalizedNumerals,
|
||||||
|
formatNumberToWesternArabicNumerals,
|
||||||
formatPercentToLocalizedNumerals
|
formatPercentToLocalizedNumerals
|
||||||
} = useI18n();
|
} = useI18n();
|
||||||
|
|
||||||
@@ -289,6 +294,8 @@ const chartOptions = computed<object>(() => {
|
|||||||
let totalAmount = 0;
|
let totalAmount = 0;
|
||||||
let actualDisplayItemCount = 0;
|
let actualDisplayItemCount = 0;
|
||||||
const displayItems: AxisChartTooltipItem[] = [];
|
const displayItems: AxisChartTooltipItem[] = [];
|
||||||
|
const categoryIndex = params.length > 0 && params[0] ? (params[0].dataIndex ?? 0) : 0;
|
||||||
|
const visibleSeriesIds: string[] = [];
|
||||||
|
|
||||||
for (const param of params) {
|
for (const param of params) {
|
||||||
const id = param.seriesId as string;
|
const id = param.seriesId as string;
|
||||||
@@ -298,37 +305,111 @@ const chartOptions = computed<object>(() => {
|
|||||||
const amount = param.data as number;
|
const amount = param.data as number;
|
||||||
|
|
||||||
displayItems.push({
|
displayItems.push({
|
||||||
|
id: id,
|
||||||
name: name,
|
name: name,
|
||||||
color: color,
|
color: color,
|
||||||
displayOrders: displayOrders,
|
displayOrders: displayOrders,
|
||||||
totalAmount: amount
|
totalAmount: amount
|
||||||
});
|
});
|
||||||
|
|
||||||
|
visibleSeriesIds.push(id);
|
||||||
totalAmount += amount;
|
totalAmount += amount;
|
||||||
}
|
}
|
||||||
|
|
||||||
sortStatisticsItems(displayItems, props.sortingType);
|
sortStatisticsItems(displayItems, props.sortingType);
|
||||||
|
|
||||||
for (const item of displayItems) {
|
const extraColumnValuesMap: Record<number, string[]> = {};
|
||||||
|
const extraColumnTotalValues: string[] = [];
|
||||||
|
const hasExtraColumnIndexes: Record<number, boolean> = {};
|
||||||
|
|
||||||
|
if (props.tooltipExtraColumnNames) {
|
||||||
|
if (props.tooltipExtraColumnValues) {
|
||||||
|
for (const [item, index] of itemAndIndex(displayItems)) {
|
||||||
|
const values = props.tooltipExtraColumnValues(item.id, categoryIndex, item.totalAmount);
|
||||||
|
extraColumnValuesMap[index] = values;
|
||||||
|
|
||||||
|
for (const [value, columnIndex] of itemAndIndex(values)) {
|
||||||
|
if (value && value !== '-') {
|
||||||
|
hasExtraColumnIndexes[columnIndex] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.tooltipExtraColumnTotalValues) {
|
||||||
|
const values = props.tooltipExtraColumnTotalValues(categoryIndex, totalAmount, visibleSeriesIds);
|
||||||
|
extraColumnTotalValues.push(...values);
|
||||||
|
|
||||||
|
for (const [value, columnIndex] of itemAndIndex(values)) {
|
||||||
|
if (value && value !== '-') {
|
||||||
|
hasExtraColumnIndexes[columnIndex] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [item, index] of itemAndIndex(displayItems)) {
|
||||||
if (displayItems.length === 1 || item.totalAmount !== 0) {
|
if (displayItems.length === 1 || item.totalAmount !== 0) {
|
||||||
const value = getDisplayValue(item.totalAmount);
|
const value = getDisplayValue(item.totalAmount);
|
||||||
tooltip += '<div><span class="chart-pointer" style="background-color: ' + item.color + '"></span>';
|
tooltip += '<tr><td><span class="chart-pointer" style="background-color: ' + item.color + '"></span>';
|
||||||
tooltip += `<span>${item.name}</span><span class="ms-5" style="float: inline-end">${value}</span><br/>`;
|
tooltip += `<span>${item.name}</span></td><td><span class="ms-5" style="float: inline-end">${value}</span></td>`;
|
||||||
tooltip += '</div>';
|
|
||||||
|
if (props.tooltipExtraColumnNames) {
|
||||||
|
const values = extraColumnValuesMap[index] ?? [];
|
||||||
|
|
||||||
|
for (let i = 0; i < props.tooltipExtraColumnNames.length; i++) {
|
||||||
|
if (!hasExtraColumnIndexes[i]) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = values[i] ?? '-';
|
||||||
|
tooltip += `<td><span class="ms-5" style="float: inline-end">${value}</span></td>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tooltip += '</tr>';
|
||||||
actualDisplayItemCount++;
|
actualDisplayItemCount++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (props.showTotalAmountInTooltip && !props.oneHundredPercentStacked) {
|
if (props.showTotalAmountInTooltip && !props.oneHundredPercentStacked) {
|
||||||
const displayTotalAmount = getDisplayValue(totalAmount);
|
const displayTotalAmount = getDisplayValue(totalAmount);
|
||||||
tooltip = (actualDisplayItemCount > 0 ? '<div style="border-bottom: ' + (isDarkMode.value ? '#eee' : '#333') + ' dashed 1px">' : '<div></div>')
|
let totalColumnCount = 2;
|
||||||
+ '<span class="chart-pointer" style="background-color: ' + (isDarkMode.value ? '#eee' : '#333') + '"></span>'
|
|
||||||
+ `<span>${props.totalNameInTooltip}</span><span class="ms-5" style="float: inline-end">${displayTotalAmount}</span><br/>`
|
let totalTooltip = `<tr><td><span class="chart-pointer" style="background-color: ${isDarkMode.value ? '#eee' : '#333'}"></span>`
|
||||||
+ '</div>' + tooltip;
|
+ `<span>${props.totalNameInTooltip}</span></td><td><span class="ms-5" style="float: inline-end">${displayTotalAmount}</span></td>`;
|
||||||
|
|
||||||
|
if (props.tooltipExtraColumnNames) {
|
||||||
|
for (let i = 0; i < props.tooltipExtraColumnNames.length; i++) {
|
||||||
|
if (!hasExtraColumnIndexes[i]) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = extraColumnTotalValues[i] ?? '-';
|
||||||
|
totalTooltip += `<td><span class="ms-5" style="float: inline-end">${value}</span></td>`;
|
||||||
|
totalColumnCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
totalTooltip += '</tr>';
|
||||||
|
totalTooltip += `<tr><td colspan="${totalColumnCount}" ${actualDisplayItemCount > 0 ? 'style="border-bottom: ' + (isDarkMode.value ? '#eee' : '#333') + ' dashed 1px"' : ''}></td></tr>`;
|
||||||
|
tooltip = totalTooltip + tooltip;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (params.length && params[0] && params[0].name) {
|
if (params.length && params[0] && params[0].name) {
|
||||||
tooltip = `${params[0].name}<br/>` + tooltip;
|
let tooltipHeader = `<td>${params[0].name}</td><td></td>`;
|
||||||
|
|
||||||
|
if (props.tooltipExtraColumnNames) {
|
||||||
|
for (const [columnName, columnIndex] of itemAndIndex(props.tooltipExtraColumnNames)) {
|
||||||
|
if (!hasExtraColumnIndexes[columnIndex]) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
tooltipHeader += `<td><span class="ms-5" style="float: inline-end">${columnName}</span></td>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tooltip = `<table class="chart-tooltip-table"><tbody><tr>${tooltipHeader}</tr>${tooltip}</tbody></table>`
|
||||||
}
|
}
|
||||||
|
|
||||||
return tooltip;
|
return tooltip;
|
||||||
@@ -404,7 +485,7 @@ function getDisplayValue(value: number): string {
|
|||||||
return formatAmountToLocalizedNumeralsWithCurrency(value, props.defaultCurrency);
|
return formatAmountToLocalizedNumeralsWithCurrency(value, props.defaultCurrency);
|
||||||
}
|
}
|
||||||
|
|
||||||
return formatNumberToLocalizedNumerals(value);
|
return formatNumberToLocalizedNumerals(value, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
function clickItem(e: ECElementEvent): void {
|
function clickItem(e: ECElementEvent): void {
|
||||||
@@ -439,7 +520,13 @@ function exportData(): { headers: string[], data: string[][] } {
|
|||||||
for (const [categoryName, index] of itemAndIndex(props.allCategoryNames)) {
|
for (const [categoryName, index] of itemAndIndex(props.allCategoryNames)) {
|
||||||
const row: string[] = [];
|
const row: string[] = [];
|
||||||
row.push(categoryName);
|
row.push(categoryName);
|
||||||
row.push(...allSeries.value.map(item => formatAmountToWesternArabicNumeralsWithoutDigitGrouping(item.data[index] ?? 0)));
|
row.push(...allSeries.value.map(item => {
|
||||||
|
if (props.oneHundredPercentStacked) {
|
||||||
|
return formatNumberToWesternArabicNumerals(item.data[index] ?? 0);
|
||||||
|
} else {
|
||||||
|
return formatAmountToWesternArabicNumeralsWithoutDigitGrouping(item.data[index] ?? 0);
|
||||||
|
}
|
||||||
|
}));
|
||||||
data.push(row);
|
data.push(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ const radarData = computed<RadarChartData>(() => {
|
|||||||
|
|
||||||
const finalPercent = (isNumber(percent) && percent >= 0) ? percent : (value > 0 ? value / totalValidValue * 100 : 0);
|
const finalPercent = (isNumber(percent) && percent >= 0) ? percent : (value > 0 ? value / totalValidValue * 100 : 0);
|
||||||
const displayPercent = formatPercentToLocalizedNumerals(finalPercent, 2, '<0.01');
|
const displayPercent = formatPercentToLocalizedNumerals(finalPercent, 2, '<0.01');
|
||||||
const displayValue = props.amountValue ? formatAmountToLocalizedNumeralsWithCurrency(value, props.defaultCurrency) : formatNumberToLocalizedNumerals(value);
|
const displayValue = props.amountValue ? formatAmountToLocalizedNumeralsWithCurrency(value, props.defaultCurrency) : formatNumberToLocalizedNumerals(value, 2);
|
||||||
|
|
||||||
indicators.push({
|
indicators.push({
|
||||||
name: name,
|
name: name,
|
||||||
|
|||||||
@@ -9,6 +9,9 @@
|
|||||||
:translate-name="translateName"
|
:translate-name="translateName"
|
||||||
:amount-value="true" :default-currency="defaultCurrency"
|
:amount-value="true" :default-currency="defaultCurrency"
|
||||||
:enable-click-item="enableClickItem"
|
:enable-click-item="enableClickItem"
|
||||||
|
:tooltip-extra-column-names="allTooltipExtraColumnNames"
|
||||||
|
:tooltip-extra-column-total-values="showYearOverYear || showPeriodOverPeriod ? getTooltipExtraColumnTotalValues : undefined"
|
||||||
|
:tooltip-extra-column-values="showYearOverYear || showPeriodOverPeriod ? getTooltipExtraColumnValues : undefined"
|
||||||
@click="clickItem"
|
@click="clickItem"
|
||||||
v-if="chartDisplayType"
|
v-if="chartDisplayType"
|
||||||
/>
|
/>
|
||||||
@@ -29,18 +32,31 @@ import {
|
|||||||
|
|
||||||
import { useUserStore } from '@/stores/user.ts';
|
import { useUserStore } from '@/stores/user.ts';
|
||||||
|
|
||||||
|
import {
|
||||||
|
itemAndIndex
|
||||||
|
} from '@/core/base.ts';
|
||||||
import {
|
import {
|
||||||
type Year1BasedMonth,
|
type Year1BasedMonth,
|
||||||
type YearMonthDay,
|
type YearMonthDay,
|
||||||
|
type YearUnixTime,
|
||||||
|
type YearQuarterUnixTime,
|
||||||
|
type YearMonthUnixTime,
|
||||||
|
type YearMonthDayUnixTime,
|
||||||
DateRangeScene
|
DateRangeScene
|
||||||
} from '@/core/datetime.ts';
|
} from '@/core/datetime.ts';
|
||||||
|
import {
|
||||||
|
type FiscalYearUnixTime
|
||||||
|
} from '@/core/fiscalyear.ts';
|
||||||
import {
|
import {
|
||||||
ChartDataAggregationType,
|
ChartDataAggregationType,
|
||||||
TrendChartType,
|
TrendChartType,
|
||||||
ChartDateAggregationType
|
ChartDateAggregationType
|
||||||
} from '@/core/statistics.ts';
|
} from '@/core/statistics.ts';
|
||||||
|
|
||||||
import { isArray, isNumber } from '@/lib/common.ts';
|
import {
|
||||||
|
isArray,
|
||||||
|
isNumber
|
||||||
|
} from '@/lib/common.ts';
|
||||||
import {
|
import {
|
||||||
parseDateTimeFromUnixTime,
|
parseDateTimeFromUnixTime,
|
||||||
getYearMonthFirstUnixTime,
|
getYearMonthFirstUnixTime,
|
||||||
@@ -56,6 +72,8 @@ interface DesktopTrendsChartProps<T extends TrendsChartDateType> extends CommonT
|
|||||||
type?: number;
|
type?: number;
|
||||||
showValue?: boolean;
|
showValue?: boolean;
|
||||||
showTotalAmountInTooltip?: boolean;
|
showTotalAmountInTooltip?: boolean;
|
||||||
|
showYearOverYear?: boolean;
|
||||||
|
showPeriodOverPeriod?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<DesktopTrendsChartProps<TrendsChartDateType>>();
|
const props = defineProps<DesktopTrendsChartProps<TrendsChartDateType>>();
|
||||||
@@ -70,7 +88,8 @@ const {
|
|||||||
formatDateTimeToGregorianLikeShortYear,
|
formatDateTimeToGregorianLikeShortYear,
|
||||||
formatDateTimeToGregorianLikeShortYearMonth,
|
formatDateTimeToGregorianLikeShortYearMonth,
|
||||||
formatYearQuarterToGregorianLikeYearQuarter,
|
formatYearQuarterToGregorianLikeYearQuarter,
|
||||||
formatDateTimeToGregorianLikeFiscalYear
|
formatDateTimeToGregorianLikeFiscalYear,
|
||||||
|
formatPercentToLocalizedNumerals
|
||||||
} = useI18n();
|
} = useI18n();
|
||||||
|
|
||||||
const { allDateRanges } = useTrendsChartBase(props);
|
const { allDateRanges } = useTrendsChartBase(props);
|
||||||
@@ -91,6 +110,20 @@ const chartDisplayType = computed<AxisChartDisplayType | undefined>(() => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const allTooltipExtraColumnNames = computed<string[]>(() => {
|
||||||
|
const extraColumnNames: string[] = [];
|
||||||
|
|
||||||
|
if (props.showYearOverYear) {
|
||||||
|
extraColumnNames.push(tt('Year-over-Year'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.showPeriodOverPeriod) {
|
||||||
|
extraColumnNames.push(tt('Period-over-Period'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return extraColumnNames;
|
||||||
|
});
|
||||||
|
|
||||||
const allDisplayDateRanges = computed<string[]>(() => {
|
const allDisplayDateRanges = computed<string[]>(() => {
|
||||||
const allDisplayDateRanges: string[] = [];
|
const allDisplayDateRanges: string[] = [];
|
||||||
|
|
||||||
@@ -188,22 +221,9 @@ const allSeriesData = computed<Record<string, unknown>[]>(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const dateRange of allDateRanges.value) {
|
for (const dateRange of allDateRanges.value) {
|
||||||
let dateRangeKey = '';
|
const dateRangeKey = getDateRangeKey(dateRange) ?? '';
|
||||||
|
|
||||||
if (props.dateAggregationType === ChartDateAggregationType.Year.type) {
|
|
||||||
dateRangeKey = dateRange.year.toString();
|
|
||||||
} else if (props.dateAggregationType === ChartDateAggregationType.FiscalYear.type && 'year' in dateRange) {
|
|
||||||
dateRangeKey = dateRange.year.toString();
|
|
||||||
} else if (props.dateAggregationType === ChartDateAggregationType.Quarter.type && 'quarter' in dateRange) {
|
|
||||||
dateRangeKey = `${dateRange.year}-${dateRange.quarter}`;
|
|
||||||
} else if (props.dateAggregationType === ChartDateAggregationType.Month.type && 'month0base' in dateRange) {
|
|
||||||
dateRangeKey = `${dateRange.year}-${dateRange.month0base + 1}`;
|
|
||||||
} else if (props.dateAggregationType === ChartDateAggregationType.Day.type && 'day' in dateRange && props.chartMode === 'daily') {
|
|
||||||
dateRangeKey = `${dateRange.year}-${dateRange.month}-${dateRange.day}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
let amount = 0;
|
|
||||||
const dataItems = dateRangeAmountMap[dateRangeKey];
|
const dataItems = dateRangeAmountMap[dateRangeKey];
|
||||||
|
let amount = 0;
|
||||||
|
|
||||||
if (isArray(dataItems)) {
|
if (isArray(dataItems)) {
|
||||||
for (const dataItem of dataItems) {
|
for (const dataItem of dataItems) {
|
||||||
@@ -229,6 +249,172 @@ const allSeriesData = computed<Record<string, unknown>[]>(() => {
|
|||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const seriesIdValuesMap = computed<Record<string, number[]>>(() => {
|
||||||
|
const result: Record<string, number[]> = {};
|
||||||
|
|
||||||
|
for (const item of allSeriesData.value) {
|
||||||
|
const id = getSeriesId(item);
|
||||||
|
const values = item['values'] as number[];
|
||||||
|
|
||||||
|
if (id && values) {
|
||||||
|
result[id] = values;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
const yoyIndexMap = computed<Record<number, number>>(() => {
|
||||||
|
const result: Record<number, number> = {};
|
||||||
|
const dateKeyToIndex: Record<string, number> = {};
|
||||||
|
|
||||||
|
for (const [dateRange, index] of itemAndIndex(allDateRanges.value)) {
|
||||||
|
const key = getDateRangeKey(dateRange);
|
||||||
|
|
||||||
|
if (key) {
|
||||||
|
dateKeyToIndex[key] = index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [dateRange, index] of itemAndIndex(allDateRanges.value)) {
|
||||||
|
const yoyKey = getDateRangeKey(dateRange, -1);
|
||||||
|
|
||||||
|
if (yoyKey && isNumber(dateKeyToIndex[yoyKey])) {
|
||||||
|
result[index] = dateKeyToIndex[yoyKey];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
function getSeriesId(item: Record<string, unknown>): string {
|
||||||
|
if (props.idField && item[props.idField]) {
|
||||||
|
return item[props.idField] as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = item[props.nameField] as string;
|
||||||
|
return props.translateName ? tt(name) : name;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDateRangeKey(dateRange: YearUnixTime | FiscalYearUnixTime | YearQuarterUnixTime | YearMonthUnixTime | YearMonthDayUnixTime, yearOffset?: number): string | undefined {
|
||||||
|
if (props.dateAggregationType === ChartDateAggregationType.Year.type) {
|
||||||
|
return (dateRange.year + (yearOffset ?? 0)).toString();
|
||||||
|
} else if (props.dateAggregationType === ChartDateAggregationType.FiscalYear.type && 'year' in dateRange) {
|
||||||
|
return (dateRange.year + (yearOffset ?? 0)).toString();
|
||||||
|
} else if (props.dateAggregationType === ChartDateAggregationType.Quarter.type && 'quarter' in dateRange) {
|
||||||
|
return `${dateRange.year + (yearOffset ?? 0)}-${dateRange.quarter}`;
|
||||||
|
} else if (props.dateAggregationType === ChartDateAggregationType.Month.type && 'month0base' in dateRange) {
|
||||||
|
return `${dateRange.year + (yearOffset ?? 0)}-${dateRange.month0base + 1}`;
|
||||||
|
} else if (props.dateAggregationType === ChartDateAggregationType.Day.type && 'day' in dateRange && props.chartMode === 'daily') {
|
||||||
|
return `${dateRange.year + (yearOffset ?? 0)}-${dateRange.month}-${dateRange.day}`;
|
||||||
|
} else {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDisplayChangeRate(current: number, reference: number): string {
|
||||||
|
if (reference === 0 && current === 0) {
|
||||||
|
return formatPercentToLocalizedNumerals(0, 2, '<0.01');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reference === 0) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
const rate = (current - reference) / reference * 100;
|
||||||
|
return formatPercentToLocalizedNumerals(rate, 2, '<0.01');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTooltipExtraColumnTotalValues(categoryIndex: number, totalValue: number, visibleSeriesIds: string[]): string[] {
|
||||||
|
const extraColumnValues: string[] = [];
|
||||||
|
|
||||||
|
if (!props.showYearOverYear && !props.showPeriodOverPeriod) {
|
||||||
|
return extraColumnValues;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.showYearOverYear) {
|
||||||
|
const yoyReferenceIndex = yoyIndexMap.value[categoryIndex];
|
||||||
|
let displayChangeRate = '-';
|
||||||
|
|
||||||
|
if (isNumber(yoyReferenceIndex)) {
|
||||||
|
let referenceTotalValue = 0;
|
||||||
|
|
||||||
|
for (const seriesId of visibleSeriesIds) {
|
||||||
|
const values = seriesIdValuesMap.value[seriesId];
|
||||||
|
|
||||||
|
if (values) {
|
||||||
|
referenceTotalValue += values[yoyReferenceIndex] ?? 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
displayChangeRate = formatDisplayChangeRate(totalValue, referenceTotalValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
extraColumnValues.push(displayChangeRate);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.showPeriodOverPeriod) {
|
||||||
|
const popReferenceIndex = categoryIndex - 1;
|
||||||
|
let displayChangeRate = '-';
|
||||||
|
|
||||||
|
if (popReferenceIndex >= 0) {
|
||||||
|
let referenceTotalValue = 0;
|
||||||
|
|
||||||
|
for (const seriesId of visibleSeriesIds) {
|
||||||
|
const values = seriesIdValuesMap.value[seriesId];
|
||||||
|
|
||||||
|
if (values) {
|
||||||
|
referenceTotalValue += values[popReferenceIndex] ?? 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
displayChangeRate = formatDisplayChangeRate(totalValue, referenceTotalValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
extraColumnValues.push(displayChangeRate);
|
||||||
|
}
|
||||||
|
|
||||||
|
return extraColumnValues;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTooltipExtraColumnValues(seriesId: string, categoryIndex: number, currentValue: number): string[] {
|
||||||
|
const extraColumnValues: string[] = [];
|
||||||
|
|
||||||
|
if (!props.showYearOverYear && !props.showPeriodOverPeriod) {
|
||||||
|
return extraColumnValues;
|
||||||
|
}
|
||||||
|
|
||||||
|
const values = seriesIdValuesMap.value[seriesId];
|
||||||
|
|
||||||
|
if (!values) {
|
||||||
|
return extraColumnValues;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.showYearOverYear) {
|
||||||
|
const yoyReferenceIndex = yoyIndexMap.value[categoryIndex];
|
||||||
|
let displayChangeRate = '-';
|
||||||
|
|
||||||
|
if (isNumber(yoyReferenceIndex) && yoyReferenceIndex >= 0 && yoyReferenceIndex < values.length) {
|
||||||
|
displayChangeRate = formatDisplayChangeRate(currentValue, values[yoyReferenceIndex] ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
extraColumnValues.push(displayChangeRate);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.showPeriodOverPeriod) {
|
||||||
|
const popReferenceIndex = categoryIndex - 1;
|
||||||
|
let displayChangeRate = '-';
|
||||||
|
|
||||||
|
if (popReferenceIndex >= 0 && popReferenceIndex < values.length) {
|
||||||
|
displayChangeRate = formatDisplayChangeRate(currentValue, values[popReferenceIndex] ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
extraColumnValues.push(displayChangeRate);
|
||||||
|
}
|
||||||
|
|
||||||
|
return extraColumnValues;
|
||||||
|
}
|
||||||
|
|
||||||
function clickItem(itemId: string, categoryIndex: number): void {
|
function clickItem(itemId: string, categoryIndex: number): void {
|
||||||
const dateRange = allDateRanges.value[categoryIndex];
|
const dateRange = allDateRanges.value[categoryIndex];
|
||||||
|
|
||||||
|
|||||||
@@ -70,12 +70,12 @@ const cancelRecognizingUuid = ref<string | undefined>(undefined);
|
|||||||
const imageFile = ref<File | null>(null);
|
const imageFile = ref<File | null>(null);
|
||||||
const imageSrc = ref<string | undefined>(undefined);
|
const imageSrc = ref<string | undefined>(undefined);
|
||||||
|
|
||||||
function loadImage(file: File): void {
|
function loadImage(image: Blob): void {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
imageFile.value = null;
|
imageFile.value = null;
|
||||||
imageSrc.value = undefined;
|
imageSrc.value = undefined;
|
||||||
|
|
||||||
compressJpgImage(file, 1280, 1280, 0.8).then(blob => {
|
compressJpgImage(image, 1280, 1280, 0.8).then(blob => {
|
||||||
imageFile.value = KnownFileType.JPG.createFileFromBlob(blob, "image");
|
imageFile.value = KnownFileType.JPG.createFileFromBlob(blob, "image");
|
||||||
imageSrc.value = URL.createObjectURL(blob);
|
imageSrc.value = URL.createObjectURL(blob);
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
@@ -184,6 +184,10 @@ function onSheetOpen(): void {
|
|||||||
function onSheetClosed(): void {
|
function onSheetClosed(): void {
|
||||||
close();
|
close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
loadImage
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -103,6 +103,8 @@ const allVirtualListItems = computed<MobileAccountBalanceTrendsChartItem[]>(() =
|
|||||||
averageBalance: dataItem.averageBalance,
|
averageBalance: dataItem.averageBalance,
|
||||||
minimumBalance: dataItem.minimumBalance,
|
minimumBalance: dataItem.minimumBalance,
|
||||||
maximumBalance: dataItem.maximumBalance,
|
maximumBalance: dataItem.maximumBalance,
|
||||||
|
q1Balance: dataItem.q1Balance,
|
||||||
|
q3Balance: dataItem.q3Balance,
|
||||||
color: `#${DEFAULT_CHART_COLORS[0] as string}`,
|
color: `#${DEFAULT_CHART_COLORS[0] as string}`,
|
||||||
percent: 0.0
|
percent: 0.0
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,8 +6,15 @@
|
|||||||
<div class="margin-top padding-horizontal" v-if="hint">
|
<div class="margin-top padding-horizontal" v-if="hint">
|
||||||
<span>{{ hint }}</span>
|
<span>{{ hint }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="numpad-values" @click="onDisplayValueClick">
|
<div class="numpad-values">
|
||||||
<span id="numpad-value" class="numpad-value" :class="currentDisplayNumClass">{{ currentDisplay }}</span>
|
<span id="numpad-value" class="numpad-value" :class="currentDisplayNumClass" @click="onDisplayValueClick">{{ currentDisplay }}</span>
|
||||||
|
<f7-button class="numpad-backspace-button"
|
||||||
|
@pointerdown="onBackspacePointerDown"
|
||||||
|
@pointerup="onBackspacePointerEnd"
|
||||||
|
@pointercancel="onBackspacePointerEnd"
|
||||||
|
@pointerleave="onBackspacePointerEnd">
|
||||||
|
<f7-icon class="icon-with-direction" f7="delete_left"></f7-icon>
|
||||||
|
</f7-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<f7-popover class="numpad-paste-popover" target-el="#numpad-value"
|
<f7-popover class="numpad-paste-popover" target-el="#numpad-value"
|
||||||
@@ -18,57 +25,55 @@
|
|||||||
</f7-popover>
|
</f7-popover>
|
||||||
|
|
||||||
<div class="numpad-buttons">
|
<div class="numpad-buttons">
|
||||||
<f7-button class="numpad-button numpad-button-num" @click="inputNum(7)">
|
<f7-button class="numpad-button numpad-button-num" @pointerdown.left="inputNum(7)">
|
||||||
<span class="numpad-button-text numpad-button-text-normal">{{ digits[7] }}</span>
|
<span class="numpad-button-text numpad-button-text-normal">{{ digits[7] }}</span>
|
||||||
</f7-button>
|
</f7-button>
|
||||||
<f7-button class="numpad-button numpad-button-num" @click="inputNum(8)">
|
<f7-button class="numpad-button numpad-button-num" @pointerdown.left="inputNum(8)">
|
||||||
<span class="numpad-button-text numpad-button-text-normal">{{ digits[8] }}</span>
|
<span class="numpad-button-text numpad-button-text-normal">{{ digits[8] }}</span>
|
||||||
</f7-button>
|
</f7-button>
|
||||||
<f7-button class="numpad-button numpad-button-num" @click="inputNum(9)">
|
<f7-button class="numpad-button numpad-button-num" @pointerdown.left="inputNum(9)">
|
||||||
<span class="numpad-button-text numpad-button-text-normal">{{ digits[9] }}</span>
|
<span class="numpad-button-text numpad-button-text-normal">{{ digits[9] }}</span>
|
||||||
</f7-button>
|
</f7-button>
|
||||||
<f7-button class="numpad-button numpad-button-function no-right-border" @click="setSymbol('×')">
|
<f7-button class="numpad-button numpad-button-function no-right-border" @pointerdown.left="setSymbol('×')">
|
||||||
<span class="numpad-button-text numpad-button-text-normal">×</span>
|
<span class="numpad-button-text numpad-button-text-normal">×</span>
|
||||||
</f7-button>
|
</f7-button>
|
||||||
<f7-button class="numpad-button numpad-button-num" @click="inputNum(4)">
|
<f7-button class="numpad-button numpad-button-num" @pointerdown.left="inputNum(4)">
|
||||||
<span class="numpad-button-text numpad-button-text-normal">{{ digits[4] }}</span>
|
<span class="numpad-button-text numpad-button-text-normal">{{ digits[4] }}</span>
|
||||||
</f7-button>
|
</f7-button>
|
||||||
<f7-button class="numpad-button numpad-button-num" @click="inputNum(5)">
|
<f7-button class="numpad-button numpad-button-num" @pointerdown.left="inputNum(5)">
|
||||||
<span class="numpad-button-text numpad-button-text-normal">{{ digits[5] }}</span>
|
<span class="numpad-button-text numpad-button-text-normal">{{ digits[5] }}</span>
|
||||||
</f7-button>
|
</f7-button>
|
||||||
<f7-button class="numpad-button numpad-button-num" @click="inputNum(6)">
|
<f7-button class="numpad-button numpad-button-num" @pointerdown.left="inputNum(6)">
|
||||||
<span class="numpad-button-text numpad-button-text-normal">{{ digits[6] }}</span>
|
<span class="numpad-button-text numpad-button-text-normal">{{ digits[6] }}</span>
|
||||||
</f7-button>
|
</f7-button>
|
||||||
<f7-button class="numpad-button numpad-button-function no-right-border" @click="setSymbol('−')">
|
<f7-button class="numpad-button numpad-button-function no-right-border" @pointerdown.left="setSymbol('−')">
|
||||||
<span class="numpad-button-text numpad-button-text-normal">−</span>
|
<span class="numpad-button-text numpad-button-text-normal">−</span>
|
||||||
</f7-button>
|
</f7-button>
|
||||||
<f7-button class="numpad-button numpad-button-num" @click="inputNum(1)">
|
<f7-button class="numpad-button numpad-button-num" @pointerdown.left="inputNum(1)">
|
||||||
<span class="numpad-button-text numpad-button-text-normal">{{ digits[1] }}</span>
|
<span class="numpad-button-text numpad-button-text-normal">{{ digits[1] }}</span>
|
||||||
</f7-button>
|
</f7-button>
|
||||||
<f7-button class="numpad-button numpad-button-num" @click="inputNum(2)">
|
<f7-button class="numpad-button numpad-button-num" @pointerdown.left="inputNum(2)">
|
||||||
<span class="numpad-button-text numpad-button-text-normal">{{ digits[2] }}</span>
|
<span class="numpad-button-text numpad-button-text-normal">{{ digits[2] }}</span>
|
||||||
</f7-button>
|
</f7-button>
|
||||||
<f7-button class="numpad-button numpad-button-num" @click="inputNum(3)">
|
<f7-button class="numpad-button numpad-button-num" @pointerdown.left="inputNum(3)">
|
||||||
<span class="numpad-button-text numpad-button-text-normal">{{ digits[3] }}</span>
|
<span class="numpad-button-text numpad-button-text-normal">{{ digits[3] }}</span>
|
||||||
</f7-button>
|
</f7-button>
|
||||||
<f7-button class="numpad-button numpad-button-function no-right-border" @click="setSymbol('+')">
|
<f7-button class="numpad-button numpad-button-function no-right-border" @pointerdown.left="setSymbol('+')">
|
||||||
<span class="numpad-button-text numpad-button-text-normal">+</span>
|
<span class="numpad-button-text numpad-button-text-normal">+</span>
|
||||||
</f7-button>
|
</f7-button>
|
||||||
<f7-button class="numpad-button numpad-button-num" v-if="supportDecimalSeparator" @click="inputDecimalSeparator()">
|
<f7-button class="numpad-button numpad-button-num" @pointerdown.left="clear()">
|
||||||
<span class="numpad-button-text numpad-button-text-normal">{{ decimalSeparator }}</span>
|
<span class="numpad-button-text numpad-button-text-normal">C</span>
|
||||||
</f7-button>
|
</f7-button>
|
||||||
<f7-button class="numpad-button numpad-button-num" v-if="!supportDecimalSeparator" @click="inputDoubleNum(0)">
|
<f7-button class="numpad-button numpad-button-num" @pointerdown.left="inputNum(0)">
|
||||||
<span class="numpad-button-text numpad-button-text-normal">{{ `${digits[0]}${digits[0]}` }}</span>
|
|
||||||
</f7-button>
|
|
||||||
<f7-button class="numpad-button numpad-button-num" @click="inputNum(0)">
|
|
||||||
<span class="numpad-button-text numpad-button-text-normal">{{ digits[0] }}</span>
|
<span class="numpad-button-text numpad-button-text-normal">{{ digits[0] }}</span>
|
||||||
</f7-button>
|
</f7-button>
|
||||||
<f7-button class="numpad-button numpad-button-num" @click="backspace" @taphold="clear()">
|
<f7-button class="numpad-button numpad-button-num" v-if="supportDecimalSeparator" @pointerdown.left="inputDecimalSeparator()">
|
||||||
<span class="numpad-button-text numpad-button-text-normal">
|
<span class="numpad-button-text numpad-button-text-normal">{{ decimalSeparator }}</span>
|
||||||
<f7-icon class="icon-with-direction" f7="delete_left"></f7-icon>
|
|
||||||
</span>
|
|
||||||
</f7-button>
|
</f7-button>
|
||||||
<f7-button class="numpad-button numpad-button-confirm no-right-border no-bottom-border" fill @click="confirm()">
|
<f7-button class="numpad-button numpad-button-num" v-if="!supportDecimalSeparator" @pointerdown.left="inputDoubleNum(0)">
|
||||||
|
<span class="numpad-button-text numpad-button-text-normal">{{ `${digits[0]}${digits[0]}` }}</span>
|
||||||
|
</f7-button>
|
||||||
|
<f7-button class="numpad-button numpad-button-confirm no-right-border no-bottom-border" fill @pointerdown.left="confirm()">
|
||||||
<span :class="{ 'numpad-button-text': true, 'numpad-button-text-confirm': !currentSymbol }">{{ confirmText }}</span>
|
<span :class="{ 'numpad-button-text': true, 'numpad-button-text-confirm': !currentSymbol }">{{ confirmText }}</span>
|
||||||
</f7-button>
|
</f7-button>
|
||||||
</div>
|
</div>
|
||||||
@@ -325,6 +330,31 @@ function clear(): void {
|
|||||||
currentSymbol.value = '';
|
currentSymbol.value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const BACKSPACE_HOLD_TO_CLEAR_MS = 500;
|
||||||
|
let backspaceClearTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
function onBackspacePointerDown(event: PointerEvent): void {
|
||||||
|
// 按下立刻删一位(消除 F7 taphold 判别期带来的点击延迟)
|
||||||
|
if (event.button !== undefined && event.button !== 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
backspace();
|
||||||
|
if (backspaceClearTimer !== null) {
|
||||||
|
clearTimeout(backspaceClearTimer);
|
||||||
|
}
|
||||||
|
backspaceClearTimer = setTimeout(() => {
|
||||||
|
clear();
|
||||||
|
backspaceClearTimer = null;
|
||||||
|
}, BACKSPACE_HOLD_TO_CLEAR_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBackspacePointerEnd(): void {
|
||||||
|
if (backspaceClearTimer !== null) {
|
||||||
|
clearTimeout(backspaceClearTimer);
|
||||||
|
backspaceClearTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function paste(): void {
|
function paste(): void {
|
||||||
showPastePopover.value = false;
|
showPastePopover.value = false;
|
||||||
|
|
||||||
@@ -441,6 +471,7 @@ function onSheetOpen(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onSheetClosed(): void {
|
function onSheetClosed(): void {
|
||||||
|
onBackspacePointerEnd();
|
||||||
close();
|
close();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -467,12 +498,15 @@ watch(() => props.flipNegative, (newValue) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.numpad-values {
|
.numpad-values {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
border-bottom: 1px solid var(--f7-page-bg-color);
|
border-bottom: 1px solid var(--f7-page-bg-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.numpad-value {
|
.numpad-value {
|
||||||
display: flex;
|
display: flex;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
padding-inline-start: 16px;
|
padding-inline-start: 16px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
height: var(--ebk-numpad-value-height);
|
height: var(--ebk-numpad-value-height);
|
||||||
@@ -481,6 +515,22 @@ watch(() => props.flipNegative, (newValue) => {
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.numpad-backspace-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 20%;
|
||||||
|
height: var(--ebk-numpad-value-height);
|
||||||
|
font-size: 22px;
|
||||||
|
color: var(--f7-color-black);
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-left: 1px solid var(--f7-page-bg-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .numpad-backspace-button {
|
||||||
|
color: var(--f7-color-white);
|
||||||
|
}
|
||||||
|
|
||||||
.numpad-value-small {
|
.numpad-value-small {
|
||||||
font-size: var(--ebk-numpad-value-small-font-size);
|
font-size: var(--ebk-numpad-value-small-font-size);
|
||||||
}
|
}
|
||||||
@@ -511,7 +561,10 @@ watch(() => props.flipNegative, (newValue) => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
touch-action: none;
|
/* 上游设的 touch-action: none 在 F7 tap 处理下让 click 慢一拍(小键盘卡顿
|
||||||
|
的实际根因,不是网络/渲染)。改 manipulation:禁双击缩放 + 消除 300ms
|
||||||
|
老延迟,但保留 click 正常合成。详见 FORK.md #11 */
|
||||||
|
touch-action: manipulation;
|
||||||
}
|
}
|
||||||
|
|
||||||
.numpad-button-num {
|
.numpad-button-num {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
</f7-list>
|
</f7-list>
|
||||||
<f7-treeview class="tree-view-selection-treeview">
|
<f7-treeview class="tree-view-selection-treeview">
|
||||||
<f7-treeview-item item-toggle
|
<f7-treeview-item item-toggle
|
||||||
:opened="isPrimaryItemHasSecondaryValue(item)"
|
:opened="props.defaultExpanded || isPrimaryItemHasSecondaryValue(item)"
|
||||||
:label="ti((primaryTitleField ? item[primaryTitleField] : item) as string, !!primaryTitleI18n)"
|
:label="ti((primaryTitleField ? item[primaryTitleField] : item) as string, !!primaryTitleI18n)"
|
||||||
:key="primaryKeyField ? item[primaryKeyField] : item"
|
:key="primaryKeyField ? item[primaryKeyField] : item"
|
||||||
v-for="item in filteredItems">
|
v-for="item in filteredItems">
|
||||||
@@ -59,6 +59,7 @@ import { type Framework7Dom, scrollSheetToTop } from '@/lib/ui/mobile.ts';
|
|||||||
|
|
||||||
interface MobileTwoLevelItemSelectionBaseProps extends TwoLevelItemSelectionBaseProps {
|
interface MobileTwoLevelItemSelectionBaseProps extends TwoLevelItemSelectionBaseProps {
|
||||||
show: boolean;
|
show: boolean;
|
||||||
|
defaultExpanded?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<MobileTwoLevelItemSelectionBaseProps>();
|
const props = defineProps<MobileTwoLevelItemSelectionBaseProps>();
|
||||||
|
|||||||
+1
-1
@@ -78,7 +78,7 @@ export const SPECIFIED_API_NOT_FOUND_ERRORS: Record<string, SpecifiedApiError> =
|
|||||||
'/api/v1/users/2fa/recovery/regenerate.json': {
|
'/api/v1/users/2fa/recovery/regenerate.json': {
|
||||||
message: 'Two-factor authentication is disabled'
|
message: 'Two-factor authentication is disabled'
|
||||||
},
|
},
|
||||||
'/api/v1/transactions/parse_dsv_file.json': {
|
'/api/v1/transactions/parse_custom_file.json': {
|
||||||
message: 'Transaction importing is disabled'
|
message: 'Transaction importing is disabled'
|
||||||
},
|
},
|
||||||
'/api/v1/transactions/parse_import.json': {
|
'/api/v1/transactions/parse_import.json': {
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export const SW_PRECACHE_CACHE_NAME_PREFIX: string = 'workbox-precache-v2-';
|
||||||
|
export const SW_RUNTIME_CACHE_NAME_PREFIX: string = 'workbox-runtime-';
|
||||||
|
export const SW_ASSETS_CACHE_NAME: string = 'ezbookkeeping-assets-cache';
|
||||||
|
export const SW_CODE_CACHE_NAME: string = 'ezbookkeeping-code-cache';
|
||||||
|
export const SW_MAP_CACHE_NAME: string = 'ezbookkeeping-map-cache';
|
||||||
|
export const SW_SHARE_CACHE_NAME: string = 'ezbookkeeping-share-cache';
|
||||||
|
|
||||||
|
export const SW_MESSAGE_TYPE_UPDATE_MAP_CACHE_CONFIG: string = 'UPDATE_MAP_CACHE_CONFIG';
|
||||||
|
export const SW_MESSAGE_TYPE_UPDATE_MAP_CACHE_CONFIG_RESPONSE: string = 'UPDATE_MAP_CACHE_CONFIG_RESPONSE';
|
||||||
|
|
||||||
|
export const MAP_CACHE_MAX_ENTRIES: number = 1000;
|
||||||
+26
-6
@@ -13,11 +13,10 @@ export const UTF_8 = 'utf-8';
|
|||||||
|
|
||||||
export const SUPPORTED_FILE_ENCODINGS: string[] = [
|
export const SUPPORTED_FILE_ENCODINGS: string[] = [
|
||||||
UTF_8, // UTF-8
|
UTF_8, // UTF-8
|
||||||
'utf-8-bom', // UTF-8 with BOM
|
|
||||||
'utf-16le', // UTF-16 Little Endian
|
'utf-16le', // UTF-16 Little Endian
|
||||||
'utf-16be', // UTF-16 Big Endian
|
'utf-16be', // UTF-16 Big Endian
|
||||||
'utf-16le-bom', // UTF-16 Little Endian with BOM
|
'utf-32le', // UTF-32 Little Endian
|
||||||
'utf-16be-bom', // UTF-16 Big Endian with BOM
|
'utf-32be', // UTF-32 Big Endian
|
||||||
'cp437', // OEM United States (CP-437)
|
'cp437', // OEM United States (CP-437)
|
||||||
'cp863', // OEM Canadian French (CP-863)
|
'cp863', // OEM Canadian French (CP-863)
|
||||||
'cp037', // IBM EBCDIC US/Canada (CP-037)
|
'cp037', // IBM EBCDIC US/Canada (CP-037)
|
||||||
@@ -70,8 +69,8 @@ export const CHARDET_ENCODING_NAME_MAPPING: Record<string, string> = {
|
|||||||
'UTF-8': UTF_8,
|
'UTF-8': UTF_8,
|
||||||
'UTF-16LE': 'utf-16le',
|
'UTF-16LE': 'utf-16le',
|
||||||
'UTF-16BE': 'utf-16be',
|
'UTF-16BE': 'utf-16be',
|
||||||
// 'UTF-32 LE': '', // not supported
|
'UTF-32LE': 'utf-32le',
|
||||||
// 'UTF-32 BE': '', // not supported
|
'UTF-32BE': 'utf-32be',
|
||||||
'ISO-2022-JP': 'iso-2022-jp',
|
'ISO-2022-JP': 'iso-2022-jp',
|
||||||
// 'ISO-2022-KR': '', // not supported
|
// 'ISO-2022-KR': '', // not supported
|
||||||
// 'ISO-2022-CN': '', // not supported
|
// 'ISO-2022-CN': '', // not supported
|
||||||
@@ -180,7 +179,28 @@ export const SUPPORTED_IMPORT_FILE_CATEGORY_AND_TYPES: ImportFileCategoryAndType
|
|||||||
supportMultiLanguages: true,
|
supportMultiLanguages: true,
|
||||||
anchor: 'how-to-import-delimiter-separated-values-dsv-file-or-data'
|
anchor: 'how-to-import-delimiter-separated-values-dsv-file-or-data'
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
{
|
||||||
|
type: 'excel',
|
||||||
|
name: 'Excel Workbook File',
|
||||||
|
extensions: '.xlsx,.xls',
|
||||||
|
subTypes: [
|
||||||
|
{
|
||||||
|
type: 'custom_xlsx',
|
||||||
|
name: 'Excel Workbook File (.xlsx)',
|
||||||
|
extensions: '.xlsx',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'custom_xls',
|
||||||
|
name: 'Excel 97-2003 Workbook File (.xls)',
|
||||||
|
extensions: '.xls',
|
||||||
|
}
|
||||||
|
],
|
||||||
|
document: {
|
||||||
|
supportMultiLanguages: true,
|
||||||
|
anchor: 'how-to-import-delimiter-separated-values-dsv-file-or-data'
|
||||||
|
}
|
||||||
|
},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -158,6 +158,9 @@ export const ALL_ACCOUNT_ICONS: Record<string, IconInfo> = {
|
|||||||
},
|
},
|
||||||
'8302': {
|
'8302': {
|
||||||
icon: 'lab la-weixin'
|
icon: 'lab la-weixin'
|
||||||
|
},
|
||||||
|
'8303': {
|
||||||
|
icon: 'lab la-line'
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -257,6 +260,12 @@ export const ALL_CATEGORY_ICONS: Record<string, IconInfo> = {
|
|||||||
'200': {
|
'200': {
|
||||||
icon: 'las la-home'
|
icon: 'las la-home'
|
||||||
},
|
},
|
||||||
|
'201': {
|
||||||
|
icon: 'las la-store'
|
||||||
|
},
|
||||||
|
'202': {
|
||||||
|
icon: 'las la-store-alt'
|
||||||
|
},
|
||||||
'210': {
|
'210': {
|
||||||
icon: 'las la-toilet-paper'
|
icon: 'las la-toilet-paper'
|
||||||
},
|
},
|
||||||
@@ -305,6 +314,9 @@ export const ALL_CATEGORY_ICONS: Record<string, IconInfo> = {
|
|||||||
'252': {
|
'252': {
|
||||||
icon: 'las la-toolbox'
|
icon: 'las la-toolbox'
|
||||||
},
|
},
|
||||||
|
'253': {
|
||||||
|
icon: 'las la-paint-roller'
|
||||||
|
},
|
||||||
'260': {
|
'260': {
|
||||||
icon: 'las la-broom'
|
icon: 'las la-broom'
|
||||||
},
|
},
|
||||||
@@ -400,6 +412,9 @@ export const ALL_CATEGORY_ICONS: Record<string, IconInfo> = {
|
|||||||
'442': {
|
'442': {
|
||||||
icon: 'las la-satellite'
|
icon: 'las la-satellite'
|
||||||
},
|
},
|
||||||
|
'443': {
|
||||||
|
icon: 'las la-ethernet'
|
||||||
|
},
|
||||||
'450': {
|
'450': {
|
||||||
icon: 'las la-tv'
|
icon: 'las la-tv'
|
||||||
},
|
},
|
||||||
@@ -418,6 +433,9 @@ export const ALL_CATEGORY_ICONS: Record<string, IconInfo> = {
|
|||||||
'480': {
|
'480': {
|
||||||
icon: 'las la-shipping-fast'
|
icon: 'las la-shipping-fast'
|
||||||
},
|
},
|
||||||
|
'490': {
|
||||||
|
icon: 'las la-language'
|
||||||
|
},
|
||||||
// 500 - 599 : Expense - Entertainment
|
// 500 - 599 : Expense - Entertainment
|
||||||
'500': {
|
'500': {
|
||||||
icon: 'las la-heart'
|
icon: 'las la-heart'
|
||||||
@@ -449,6 +467,9 @@ export const ALL_CATEGORY_ICONS: Record<string, IconInfo> = {
|
|||||||
'518': {
|
'518': {
|
||||||
icon: 'las la-hiking'
|
icon: 'las la-hiking'
|
||||||
},
|
},
|
||||||
|
'519': {
|
||||||
|
icon: 'las la-compass'
|
||||||
|
},
|
||||||
'520': {
|
'520': {
|
||||||
icon: 'las la-futbol'
|
icon: 'las la-futbol'
|
||||||
},
|
},
|
||||||
@@ -533,6 +554,9 @@ export const ALL_CATEGORY_ICONS: Record<string, IconInfo> = {
|
|||||||
'582': {
|
'582': {
|
||||||
icon: 'las la-crow'
|
icon: 'las la-crow'
|
||||||
},
|
},
|
||||||
|
'583': {
|
||||||
|
icon: 'las la-cat'
|
||||||
|
},
|
||||||
'589': {
|
'589': {
|
||||||
icon: 'las la-bone'
|
icon: 'las la-bone'
|
||||||
},
|
},
|
||||||
@@ -644,6 +668,9 @@ export const ALL_CATEGORY_ICONS: Record<string, IconInfo> = {
|
|||||||
'880': {
|
'880': {
|
||||||
icon: 'las la-glasses'
|
icon: 'las la-glasses'
|
||||||
},
|
},
|
||||||
|
'881': {
|
||||||
|
icon: 'las la-wheelchair'
|
||||||
|
},
|
||||||
'890': {
|
'890': {
|
||||||
icon: 'las la-thermometer'
|
icon: 'las la-thermometer'
|
||||||
},
|
},
|
||||||
@@ -694,6 +721,33 @@ export const ALL_CATEGORY_ICONS: Record<string, IconInfo> = {
|
|||||||
'1010': {
|
'1010': {
|
||||||
icon: 'las la-minus-circle'
|
icon: 'las la-minus-circle'
|
||||||
},
|
},
|
||||||
|
'1020': {
|
||||||
|
icon: 'lab la-dev'
|
||||||
|
},
|
||||||
|
'1021': {
|
||||||
|
icon: 'las la-laptop-code'
|
||||||
|
},
|
||||||
|
'1022': {
|
||||||
|
icon: 'las la-server'
|
||||||
|
},
|
||||||
|
'1023': {
|
||||||
|
icon: 'las la-hdd'
|
||||||
|
},
|
||||||
|
'1024': {
|
||||||
|
icon: 'las la-memory'
|
||||||
|
},
|
||||||
|
'1025': {
|
||||||
|
icon: 'las la-microchip'
|
||||||
|
},
|
||||||
|
'1026': {
|
||||||
|
icon: 'las la-robot'
|
||||||
|
},
|
||||||
|
'1027': {
|
||||||
|
icon: 'las la-link'
|
||||||
|
},
|
||||||
|
'1100': {
|
||||||
|
icon: 'las la-seedling'
|
||||||
|
},
|
||||||
// 2000 - 2099 : Income - Occupational Earnings
|
// 2000 - 2099 : Income - Occupational Earnings
|
||||||
'2000': {
|
'2000': {
|
||||||
icon: 'las la-suitcase'
|
icon: 'las la-suitcase'
|
||||||
@@ -769,6 +823,9 @@ export const ALL_CATEGORY_ICONS: Record<string, IconInfo> = {
|
|||||||
'6001': {
|
'6001': {
|
||||||
icon: 'lab la-ebay'
|
icon: 'lab la-ebay'
|
||||||
},
|
},
|
||||||
|
'6010': {
|
||||||
|
icon: 'lab la-line'
|
||||||
|
},
|
||||||
'6100': {
|
'6100': {
|
||||||
icon: 'lab la-app-store'
|
icon: 'lab la-app-store'
|
||||||
},
|
},
|
||||||
@@ -784,6 +841,9 @@ export const ALL_CATEGORY_ICONS: Record<string, IconInfo> = {
|
|||||||
'6400': {
|
'6400': {
|
||||||
icon: 'lab la-uber'
|
icon: 'lab la-uber'
|
||||||
},
|
},
|
||||||
|
'6410': {
|
||||||
|
icon: 'lab la-airbnb'
|
||||||
|
},
|
||||||
'6500': {
|
'6500': {
|
||||||
icon: 'lab la-fedex'
|
icon: 'lab la-fedex'
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import { entries } from '@/core/base.ts';
|
||||||
|
import { TransactionType } from '@/core/transaction.ts';
|
||||||
|
import { ImportTransactionColumnType } from '@/core/import_transaction.ts';
|
||||||
|
|
||||||
|
export const KNOWN_COLUMN_NAME_MAPPING: Record<string, ImportTransactionColumnType> = ((mappings: Record<string, ImportTransactionColumnType>[]) => {
|
||||||
|
const result: Record<string, ImportTransactionColumnType> = {};
|
||||||
|
|
||||||
|
for (const mapping of mappings) {
|
||||||
|
for (const [key, value] of entries(mapping)) {
|
||||||
|
const normalizedKey = key.toLowerCase().replaceAll(' ', '').replaceAll('_', '').replaceAll('-', '');
|
||||||
|
|
||||||
|
if (result[normalizedKey]) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
result[normalizedKey] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
})([
|
||||||
|
// Columns of ezbookkeeping Data Export File
|
||||||
|
{
|
||||||
|
['Time']: ImportTransactionColumnType.TransactionTime,
|
||||||
|
['Timezone']: ImportTransactionColumnType.TransactionTimezone,
|
||||||
|
['Type']: ImportTransactionColumnType.TransactionType,
|
||||||
|
['Category']: ImportTransactionColumnType.Category,
|
||||||
|
['Sub Category']: ImportTransactionColumnType.SubCategory,
|
||||||
|
['Account']: ImportTransactionColumnType.AccountName,
|
||||||
|
['Account Currency']: ImportTransactionColumnType.AccountCurrency,
|
||||||
|
['Amount']: ImportTransactionColumnType.Amount,
|
||||||
|
['Account2']: ImportTransactionColumnType.RelatedAccountName,
|
||||||
|
['Account2 Currency']: ImportTransactionColumnType.RelatedAccountCurrency,
|
||||||
|
['Account2 Amount']: ImportTransactionColumnType.RelatedAmount,
|
||||||
|
['Geographic Location']: ImportTransactionColumnType.GeographicLocation,
|
||||||
|
['Tags']: ImportTransactionColumnType.Tags,
|
||||||
|
['Description']: ImportTransactionColumnType.Description
|
||||||
|
},
|
||||||
|
// Other common columns of transaction time
|
||||||
|
{
|
||||||
|
// en
|
||||||
|
['Date']: ImportTransactionColumnType.TransactionTime,
|
||||||
|
['Datetime']: ImportTransactionColumnType.TransactionTime,
|
||||||
|
['Timestamp']: ImportTransactionColumnType.TransactionTime,
|
||||||
|
// zh-Hans
|
||||||
|
['日期']: ImportTransactionColumnType.TransactionTime,
|
||||||
|
['时间']: ImportTransactionColumnType.TransactionTime,
|
||||||
|
['交易日期']: ImportTransactionColumnType.TransactionTime,
|
||||||
|
['交易时间']: ImportTransactionColumnType.TransactionTime,
|
||||||
|
},
|
||||||
|
// Other common columns of transaction timezone
|
||||||
|
{
|
||||||
|
|
||||||
|
},
|
||||||
|
// Other common columns of transaction type
|
||||||
|
{
|
||||||
|
// en
|
||||||
|
['Transaction Type']: ImportTransactionColumnType.TransactionType,
|
||||||
|
// zh-Hans
|
||||||
|
['交易类型']: ImportTransactionColumnType.TransactionType,
|
||||||
|
['类型']: ImportTransactionColumnType.TransactionType,
|
||||||
|
['收/支']: ImportTransactionColumnType.TransactionType,
|
||||||
|
},
|
||||||
|
// Other common columns of category
|
||||||
|
{
|
||||||
|
// en
|
||||||
|
['Category Name']: ImportTransactionColumnType.Category,
|
||||||
|
// zh-Hans
|
||||||
|
['交易分类']: ImportTransactionColumnType.Category,
|
||||||
|
['类别']: ImportTransactionColumnType.Category,
|
||||||
|
['分类']: ImportTransactionColumnType.Category,
|
||||||
|
},
|
||||||
|
// Other common columns of sub category
|
||||||
|
{
|
||||||
|
// zh-Hans
|
||||||
|
['子类别']: ImportTransactionColumnType.SubCategory,
|
||||||
|
['子分类']: ImportTransactionColumnType.SubCategory,
|
||||||
|
['二级分类']: ImportTransactionColumnType.SubCategory,
|
||||||
|
},
|
||||||
|
// Other common columns of account name
|
||||||
|
{
|
||||||
|
// en
|
||||||
|
['Account Name']: ImportTransactionColumnType.AccountName,
|
||||||
|
['Source Name']: ImportTransactionColumnType.AccountName,
|
||||||
|
// zh-Hans
|
||||||
|
['账户']: ImportTransactionColumnType.AccountName,
|
||||||
|
['账户1']: ImportTransactionColumnType.AccountName,
|
||||||
|
},
|
||||||
|
// Other common columns of account currency
|
||||||
|
{
|
||||||
|
// en
|
||||||
|
['Currency']: ImportTransactionColumnType.AccountCurrency,
|
||||||
|
['Currency Code']: ImportTransactionColumnType.AccountCurrency,
|
||||||
|
// zh-Hans
|
||||||
|
['账户币种']: ImportTransactionColumnType.AccountCurrency,
|
||||||
|
['币种']: ImportTransactionColumnType.AccountCurrency,
|
||||||
|
},
|
||||||
|
// Other common columns of amount
|
||||||
|
{
|
||||||
|
// zh-Hans
|
||||||
|
['金额']: ImportTransactionColumnType.Amount,
|
||||||
|
},
|
||||||
|
// Other common columns of related account name
|
||||||
|
{
|
||||||
|
// en
|
||||||
|
['Destination Name']: ImportTransactionColumnType.RelatedAccountName,
|
||||||
|
// zh-Hans
|
||||||
|
['账户2']: ImportTransactionColumnType.RelatedAccountName,
|
||||||
|
},
|
||||||
|
// Other common columns of related account currency
|
||||||
|
{
|
||||||
|
// en
|
||||||
|
['Foreign Currency']: ImportTransactionColumnType.RelatedAccountCurrency,
|
||||||
|
['Foreign Currency Code']: ImportTransactionColumnType.RelatedAccountCurrency,
|
||||||
|
},
|
||||||
|
// Other common columns of related amount
|
||||||
|
{
|
||||||
|
// en
|
||||||
|
['Foreign Amount']: ImportTransactionColumnType.RelatedAmount,
|
||||||
|
},
|
||||||
|
// Other common columns of geographic location
|
||||||
|
{
|
||||||
|
|
||||||
|
},
|
||||||
|
// Other common columns of tags
|
||||||
|
{
|
||||||
|
// zh-Hans
|
||||||
|
['标签']: ImportTransactionColumnType.Tags,
|
||||||
|
},
|
||||||
|
// Other common columns of description
|
||||||
|
{
|
||||||
|
// en
|
||||||
|
['Comment']: ImportTransactionColumnType.Description,
|
||||||
|
['Note']: ImportTransactionColumnType.Description,
|
||||||
|
['Memo']: ImportTransactionColumnType.Description,
|
||||||
|
// zh-Hans
|
||||||
|
['备注']: ImportTransactionColumnType.Description,
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const KNOWN_TRANSACTION_TYPE_NAME_MAPPING: Record<string, TransactionType> = ((mappings: Record<string, TransactionType>[]) => {
|
||||||
|
const result: Record<string, TransactionType> = {};
|
||||||
|
|
||||||
|
for (const mapping of mappings) {
|
||||||
|
for (const [key, value] of entries(mapping)) {
|
||||||
|
const normalizedKey = key.toLowerCase().replaceAll(' ', '').replaceAll('_', '').replaceAll('-', '');
|
||||||
|
|
||||||
|
if (result[normalizedKey]) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
result[normalizedKey] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
})([
|
||||||
|
// Transaction types of ezbookkeeping Data Export File
|
||||||
|
{
|
||||||
|
['Balance Modification']: TransactionType.ModifyBalance,
|
||||||
|
['Income']: TransactionType.Income,
|
||||||
|
['Expense']: TransactionType.Expense,
|
||||||
|
['Transfer']: TransactionType.Transfer,
|
||||||
|
},
|
||||||
|
// Other common balance modification type
|
||||||
|
{
|
||||||
|
// en
|
||||||
|
['Opening balance']: TransactionType.ModifyBalance,
|
||||||
|
// zh-Hans
|
||||||
|
['余额变更']: TransactionType.ModifyBalance,
|
||||||
|
['负债变更']: TransactionType.ModifyBalance,
|
||||||
|
},
|
||||||
|
// Other common income type
|
||||||
|
{
|
||||||
|
// en
|
||||||
|
['Deposit']: TransactionType.Income,
|
||||||
|
// zh-Hans
|
||||||
|
['收入']: TransactionType.Income,
|
||||||
|
},
|
||||||
|
// Other common expense type
|
||||||
|
{
|
||||||
|
// en
|
||||||
|
['Withdrawal']: TransactionType.Expense,
|
||||||
|
// zh-Hans
|
||||||
|
['支出']: TransactionType.Expense,
|
||||||
|
},
|
||||||
|
// Other common transfer type
|
||||||
|
{
|
||||||
|
// zh-Hans
|
||||||
|
['转账']: TransactionType.Transfer,
|
||||||
|
['还款']: TransactionType.Transfer,
|
||||||
|
['借入']: TransactionType.Transfer,
|
||||||
|
['借出']: TransactionType.Transfer,
|
||||||
|
['收债']: TransactionType.Transfer,
|
||||||
|
['还债']: TransactionType.Transfer,
|
||||||
|
['代付']: TransactionType.Transfer,
|
||||||
|
['报销']: TransactionType.Transfer,
|
||||||
|
['退款']: TransactionType.Transfer,
|
||||||
|
}
|
||||||
|
]);
|
||||||
+11
-2
@@ -65,12 +65,17 @@ export function* values<K extends string | number | symbol, V>(obj: Record<K, V>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NameValue {
|
export interface GenericNameValue<T> {
|
||||||
|
readonly name: string;
|
||||||
|
readonly value: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NameValue extends GenericNameValue<string> {
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
readonly value: string;
|
readonly value: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NameNumeralValue {
|
export interface NameNumeralValue extends GenericNameValue<number> {
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
readonly value: number;
|
readonly value: number;
|
||||||
}
|
}
|
||||||
@@ -85,6 +90,10 @@ export interface TypeAndName {
|
|||||||
readonly name: string;
|
readonly name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TypeAndNameWithAlternativeName extends TypeAndName {
|
||||||
|
readonly alternativeName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TypeAndDisplayName {
|
export interface TypeAndDisplayName {
|
||||||
readonly type: number;
|
readonly type: number;
|
||||||
readonly displayName: string;
|
readonly displayName: string;
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
export interface BrowserCacheStatistics {
|
||||||
|
readonly totalCacheSize: number;
|
||||||
|
readonly codeCacheSize: number;
|
||||||
|
readonly assetsCacheSize: number;
|
||||||
|
readonly mapCacheSize: number;
|
||||||
|
readonly othersCacheSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SWMapCacheConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
patterns: string[];
|
||||||
|
maxEntries: number;
|
||||||
|
maxAgeMilliseconds: number;
|
||||||
|
}
|
||||||
@@ -570,6 +570,9 @@ export class KnownDateTimeFormat {
|
|||||||
|
|
||||||
public static readonly YYYYMMDD = new KnownDateTimeFormat('YYYYMMDD', DateFormatOrder.YMD, /^\d{4}(0[1-9]|1[0-2])(0[1-9]|[1-2][0-9]|3[0-1])$/);
|
public static readonly YYYYMMDD = new KnownDateTimeFormat('YYYYMMDD', DateFormatOrder.YMD, /^\d{4}(0[1-9]|1[0-2])(0[1-9]|[1-2][0-9]|3[0-1])$/);
|
||||||
|
|
||||||
|
public static readonly MMDDYYDash = new KnownDateTimeFormat('MM-DD-YY', DateFormatOrder.MDY, /^(0[1-9]|1[0-2])-(0[1-9]|[1-2][0-9]|3[0-1])-\d{2}$/);
|
||||||
|
public static readonly MMDDYYSlash = new KnownDateTimeFormat('MM/DD/YY', DateFormatOrder.MDY, /^(0[1-9]|1[0-2])\/(0[1-9]|[1-2][0-9]|3[0-1])\/\d{2}$/);
|
||||||
|
|
||||||
public readonly format: string;
|
public readonly format: string;
|
||||||
public readonly type: DateFormatOrder;
|
public readonly type: DateFormatOrder;
|
||||||
private readonly regex: RegExp;
|
private readonly regex: RegExp;
|
||||||
|
|||||||
+47
-11
@@ -4,17 +4,27 @@ import { DateRange } from '@/core/datetime.ts';
|
|||||||
export enum TransactionExplorerConditionRelation {
|
export enum TransactionExplorerConditionRelation {
|
||||||
First = 'first',
|
First = 'first',
|
||||||
And = 'and',
|
And = 'and',
|
||||||
Or = 'or'
|
Or = 'or',
|
||||||
|
AndSub = 'and(',
|
||||||
|
OrSub = 'or(',
|
||||||
|
SubEnd = ')'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TransactionExplorerSubConditionStartRelation = '(';
|
||||||
|
export const TransactionExplorerSubConditionStartRelationPlaceholder: TransactionExplorerSubConditionStartRelation = '(';
|
||||||
|
|
||||||
export const TransactionExplorerConditionRelationPriority: Record<TransactionExplorerConditionRelation, number> = {
|
export const TransactionExplorerConditionRelationPriority: Record<TransactionExplorerConditionRelation, number> = {
|
||||||
[TransactionExplorerConditionRelation.First]: 0,
|
[TransactionExplorerConditionRelation.First]: 0,
|
||||||
[TransactionExplorerConditionRelation.Or]: 1,
|
[TransactionExplorerConditionRelation.Or]: 1,
|
||||||
[TransactionExplorerConditionRelation.And]: 2
|
[TransactionExplorerConditionRelation.And]: 2,
|
||||||
|
[TransactionExplorerConditionRelation.AndSub]: 0,
|
||||||
|
[TransactionExplorerConditionRelation.OrSub]: 0,
|
||||||
|
[TransactionExplorerConditionRelation.SubEnd]: 0
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
export enum TransactionExplorerConditionFieldType {
|
export enum TransactionExplorerConditionFieldType {
|
||||||
|
Undefined = 'undefined',
|
||||||
TransactionType = 'transactionType',
|
TransactionType = 'transactionType',
|
||||||
TransactionCategory = 'transactionCategory',
|
TransactionCategory = 'transactionCategory',
|
||||||
SourceAccount = 'sourceAccount',
|
SourceAccount = 'sourceAccount',
|
||||||
@@ -81,7 +91,11 @@ export enum TransactionExplorerConditionOperatorType {
|
|||||||
StartsWith = 'startsWith',
|
StartsWith = 'startsWith',
|
||||||
NotStartsWith = 'notStartsWith',
|
NotStartsWith = 'notStartsWith',
|
||||||
EndsWith = 'endsWith',
|
EndsWith = 'endsWith',
|
||||||
NotEndsWith = 'notEndsWith'
|
NotEndsWith = 'notEndsWith',
|
||||||
|
LatitudeBetween = 'latitudeBetween',
|
||||||
|
LatitudeNotBetween = 'latitudeNotBetween',
|
||||||
|
LongitudeBetween = 'longitudeBetween',
|
||||||
|
LongitudeNotBetween = 'longitudeNotBetween'
|
||||||
}
|
}
|
||||||
|
|
||||||
export class TransactionExplorerConditionOperator implements NameValue {
|
export class TransactionExplorerConditionOperator implements NameValue {
|
||||||
@@ -107,6 +121,10 @@ export class TransactionExplorerConditionOperator implements NameValue {
|
|||||||
public static readonly NotStartsWith = new TransactionExplorerConditionOperator('Not starts with', TransactionExplorerConditionOperatorType.NotStartsWith);
|
public static readonly NotStartsWith = new TransactionExplorerConditionOperator('Not starts with', TransactionExplorerConditionOperatorType.NotStartsWith);
|
||||||
public static readonly EndsWith = new TransactionExplorerConditionOperator('Ends with', TransactionExplorerConditionOperatorType.EndsWith);
|
public static readonly EndsWith = new TransactionExplorerConditionOperator('Ends with', TransactionExplorerConditionOperatorType.EndsWith);
|
||||||
public static readonly NotEndsWith = new TransactionExplorerConditionOperator('Not ends with', TransactionExplorerConditionOperatorType.NotEndsWith);
|
public static readonly NotEndsWith = new TransactionExplorerConditionOperator('Not ends with', TransactionExplorerConditionOperatorType.NotEndsWith);
|
||||||
|
public static readonly LatitudeBetween = new TransactionExplorerConditionOperator('Latitude between', TransactionExplorerConditionOperatorType.LatitudeBetween);
|
||||||
|
public static readonly LatitudeNotBetween = new TransactionExplorerConditionOperator('Latitude not between', TransactionExplorerConditionOperatorType.LatitudeNotBetween);
|
||||||
|
public static readonly LongitudeBetween = new TransactionExplorerConditionOperator('Longitude between', TransactionExplorerConditionOperatorType.LongitudeBetween);
|
||||||
|
public static readonly LongitudeNotBetween = new TransactionExplorerConditionOperator('Longitude not between', TransactionExplorerConditionOperatorType.LongitudeNotBetween);
|
||||||
|
|
||||||
public readonly name: string;
|
public readonly name: string;
|
||||||
public readonly value: TransactionExplorerConditionOperatorType;
|
public readonly value: TransactionExplorerConditionOperatorType;
|
||||||
@@ -191,6 +209,8 @@ export enum TransactionExplorerDataDimensionType {
|
|||||||
DateTimeByDayOfMonth = 'dateTimeByDayOfMonth',
|
DateTimeByDayOfMonth = 'dateTimeByDayOfMonth',
|
||||||
DateTimeByMonthOfYear = 'dateTimeByMonthOfYear',
|
DateTimeByMonthOfYear = 'dateTimeByMonthOfYear',
|
||||||
DateTimeByQuarterOfYear = 'dateTimeByQuarterOfYear',
|
DateTimeByQuarterOfYear = 'dateTimeByQuarterOfYear',
|
||||||
|
DateTimeByHourOfDay = 'dateTimeByHourOfDay',
|
||||||
|
TimezoneOffset = 'timezoneOffset',
|
||||||
TransactionType = 'transactionType',
|
TransactionType = 'transactionType',
|
||||||
SourceAccount = 'sourceAccount',
|
SourceAccount = 'sourceAccount',
|
||||||
SourceAccountCategory = 'sourceAccountCategory',
|
SourceAccountCategory = 'sourceAccountCategory',
|
||||||
@@ -220,6 +240,8 @@ export class TransactionExplorerDataDimension implements NameValue {
|
|||||||
public static readonly DateTimeByDayOfMonth = new TransactionExplorerDataDimension('Transaction Day of Month', TransactionExplorerDataDimensionType.DateTimeByDayOfMonth);
|
public static readonly DateTimeByDayOfMonth = new TransactionExplorerDataDimension('Transaction Day of Month', TransactionExplorerDataDimensionType.DateTimeByDayOfMonth);
|
||||||
public static readonly DateTimeByMonthOfYear = new TransactionExplorerDataDimension('Transaction Month of Year', TransactionExplorerDataDimensionType.DateTimeByMonthOfYear);
|
public static readonly DateTimeByMonthOfYear = new TransactionExplorerDataDimension('Transaction Month of Year', TransactionExplorerDataDimensionType.DateTimeByMonthOfYear);
|
||||||
public static readonly DateTimeByQuarterOfYear = new TransactionExplorerDataDimension('Transaction Quarter of Year', TransactionExplorerDataDimensionType.DateTimeByQuarterOfYear);
|
public static readonly DateTimeByQuarterOfYear = new TransactionExplorerDataDimension('Transaction Quarter of Year', TransactionExplorerDataDimensionType.DateTimeByQuarterOfYear);
|
||||||
|
public static readonly DateTimeByHourOfDay = new TransactionExplorerDataDimension('Transaction Hour of Day', TransactionExplorerDataDimensionType.DateTimeByHourOfDay);
|
||||||
|
public static readonly TimezoneOffset = new TransactionExplorerDataDimension('Transaction Timezone', TransactionExplorerDataDimensionType.TimezoneOffset);
|
||||||
public static readonly TransactionType = new TransactionExplorerDataDimension('Transaction Type', TransactionExplorerDataDimensionType.TransactionType);
|
public static readonly TransactionType = new TransactionExplorerDataDimension('Transaction Type', TransactionExplorerDataDimensionType.TransactionType);
|
||||||
public static readonly SourceAccount = new TransactionExplorerDataDimension('Source Account', TransactionExplorerDataDimensionType.SourceAccount);
|
public static readonly SourceAccount = new TransactionExplorerDataDimension('Source Account', TransactionExplorerDataDimensionType.SourceAccount);
|
||||||
public static readonly SourceAccountCategory = new TransactionExplorerDataDimension('Source Account Category', TransactionExplorerDataDimensionType.SourceAccountCategory);
|
public static readonly SourceAccountCategory = new TransactionExplorerDataDimension('Source Account Category', TransactionExplorerDataDimensionType.SourceAccountCategory);
|
||||||
@@ -260,31 +282,45 @@ export enum TransactionExplorerValueMetricType {
|
|||||||
SourceAmountSum = 'sourceAmountSum',
|
SourceAmountSum = 'sourceAmountSum',
|
||||||
SourceAmountAverage = 'sourceAmountAverage',
|
SourceAmountAverage = 'sourceAmountAverage',
|
||||||
SourceAmountMedian = 'sourceAmountMedian',
|
SourceAmountMedian = 'sourceAmountMedian',
|
||||||
|
SourceAmount90thPercentile = 'source90thPercentileAmount',
|
||||||
SourceAmountMinimum = 'sourceAmountMinimum',
|
SourceAmountMinimum = 'sourceAmountMinimum',
|
||||||
SourceAmountMaximum = 'sourceAmountMaximum'
|
SourceAmountMaximum = 'sourceAmountMaximum',
|
||||||
|
SourceAmountRange = 'sourceAmountRange',
|
||||||
|
SourceAmountInterquartileRange = 'sourceAmountInterquartileRange',
|
||||||
|
SourceAmountVariance = 'sourceAmountVariance',
|
||||||
|
SourceAmountStandardDeviation = 'sourceAmountStandardDeviation',
|
||||||
|
SourceAmountCoefficientOfVariation = 'sourceAmountCoefficientOfVariation'
|
||||||
}
|
}
|
||||||
|
|
||||||
export class TransactionExplorerValueMetric implements NameValue {
|
export class TransactionExplorerValueMetric implements NameValue {
|
||||||
private static readonly allInstances: TransactionExplorerValueMetric[] = [];
|
private static readonly allInstances: TransactionExplorerValueMetric[] = [];
|
||||||
private static readonly allInstancesByValue: Record<string, TransactionExplorerValueMetric> = {};
|
private static readonly allInstancesByValue: Record<string, TransactionExplorerValueMetric> = {};
|
||||||
|
|
||||||
public static readonly TransactionCount = new TransactionExplorerValueMetric('Transaction Count', TransactionExplorerValueMetricType.TransactionCount, false);
|
public static readonly TransactionCount = new TransactionExplorerValueMetric('Transaction Count', TransactionExplorerValueMetricType.TransactionCount, false, true);
|
||||||
public static readonly SourceAmountSum = new TransactionExplorerValueMetric('Total Amount', TransactionExplorerValueMetricType.SourceAmountSum, true);
|
public static readonly SourceAmountSum = new TransactionExplorerValueMetric('Total Amount', TransactionExplorerValueMetricType.SourceAmountSum, true, true);
|
||||||
public static readonly SourceAmountAverage = new TransactionExplorerValueMetric('Average Amount', TransactionExplorerValueMetricType.SourceAmountAverage, true);
|
public static readonly SourceAmountAverage = new TransactionExplorerValueMetric('Average Amount', TransactionExplorerValueMetricType.SourceAmountAverage, true, true);
|
||||||
public static readonly SourceAmountMedian = new TransactionExplorerValueMetric('Median Amount', TransactionExplorerValueMetricType.SourceAmountMedian, true);
|
public static readonly SourceAmountMedian = new TransactionExplorerValueMetric('Median Amount', TransactionExplorerValueMetricType.SourceAmountMedian, true, true);
|
||||||
public static readonly SourceAmountMinimum = new TransactionExplorerValueMetric('Minimum Amount', TransactionExplorerValueMetricType.SourceAmountMinimum, true);
|
public static readonly SourceAmount90thPercentile = new TransactionExplorerValueMetric('90th Percentile Amount', TransactionExplorerValueMetricType.SourceAmount90thPercentile, true, true);
|
||||||
public static readonly SourceAmountMaximum = new TransactionExplorerValueMetric('Maximum Amount', TransactionExplorerValueMetricType.SourceAmountMaximum, true);
|
public static readonly SourceAmountMinimum = new TransactionExplorerValueMetric('Minimum Amount', TransactionExplorerValueMetricType.SourceAmountMinimum, true, true);
|
||||||
|
public static readonly SourceAmountMaximum = new TransactionExplorerValueMetric('Maximum Amount', TransactionExplorerValueMetricType.SourceAmountMaximum, true, true);
|
||||||
|
public static readonly SourceAmountRange = new TransactionExplorerValueMetric('Range (Max - Min)', TransactionExplorerValueMetricType.SourceAmountRange, true, true);
|
||||||
|
public static readonly SourceAmountInterquartileRange = new TransactionExplorerValueMetric('Interquartile Range (Q3 - Q1)', TransactionExplorerValueMetricType.SourceAmountInterquartileRange, true, true);
|
||||||
|
public static readonly SourceAmountVariance = new TransactionExplorerValueMetric('Variance', TransactionExplorerValueMetricType.SourceAmountVariance, false, false);
|
||||||
|
public static readonly SourceAmountStandardDeviation = new TransactionExplorerValueMetric('Standard Deviation', TransactionExplorerValueMetricType.SourceAmountStandardDeviation, false, false);
|
||||||
|
public static readonly SourceAmountCoefficientOfVariation = new TransactionExplorerValueMetric('Coefficient of Variation', TransactionExplorerValueMetricType.SourceAmountCoefficientOfVariation, false, false);
|
||||||
|
|
||||||
public static readonly Default = TransactionExplorerValueMetric.SourceAmountSum;
|
public static readonly Default = TransactionExplorerValueMetric.SourceAmountSum;
|
||||||
|
|
||||||
public readonly name: string;
|
public readonly name: string;
|
||||||
public readonly value: TransactionExplorerValueMetricType;
|
public readonly value: TransactionExplorerValueMetricType;
|
||||||
public readonly isAmount: boolean;
|
public readonly isAmount: boolean;
|
||||||
|
public readonly supportSum: boolean;
|
||||||
|
|
||||||
private constructor(name: string, value: TransactionExplorerValueMetricType, isAmount: boolean) {
|
private constructor(name: string, value: TransactionExplorerValueMetricType, isAmount: boolean, supportSum: boolean) {
|
||||||
this.name = name;
|
this.name = name;
|
||||||
this.value = value;
|
this.value = value;
|
||||||
this.isAmount = isAmount;
|
this.isAmount = isAmount;
|
||||||
|
this.supportSum = supportSum;
|
||||||
|
|
||||||
TransactionExplorerValueMetric.allInstances.push(this);
|
TransactionExplorerValueMetric.allInstances.push(this);
|
||||||
TransactionExplorerValueMetric.allInstancesByValue[value] = this;
|
TransactionExplorerValueMetric.allInstancesByValue[value] = this;
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ export class KnownFileType {
|
|||||||
public static readonly JSON = new KnownFileType('json', 'application/json');
|
public static readonly JSON = new KnownFileType('json', 'application/json');
|
||||||
public static readonly CSV = new KnownFileType('csv', 'text/csv');
|
public static readonly CSV = new KnownFileType('csv', 'text/csv');
|
||||||
public static readonly TSV = new KnownFileType('tsv', 'text/tab-separated-values');
|
public static readonly TSV = new KnownFileType('tsv', 'text/tab-separated-values');
|
||||||
|
public static readonly SSV = new KnownFileType('txt', 'text/plain');
|
||||||
public static readonly TXT = new KnownFileType('txt', 'text/plain');
|
public static readonly TXT = new KnownFileType('txt', 'text/plain');
|
||||||
public static readonly MARKDOWN = new KnownFileType('md', 'text/markdown');
|
public static readonly MARKDOWN = new KnownFileType('md', 'text/markdown');
|
||||||
|
public static readonly MERMAID = new KnownFileType('mermaid', 'text/vnd.mermaid');
|
||||||
public static readonly JS = new KnownFileType('js', 'application/javascript');
|
public static readonly JS = new KnownFileType('js', 'application/javascript');
|
||||||
public static readonly JPG = new KnownFileType('jpg', 'image/jpeg');
|
public static readonly JPG = new KnownFileType('jpg', 'image/jpeg');
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { type WeekDayValue, WeekDay } from './datetime.ts';
|
import { type WeekDayValue, WeekDay } from './datetime.ts';
|
||||||
import { TimezoneTypeForStatistics } from './timezone.ts';
|
import { TimezoneTypeForStatistics } from './timezone.ts';
|
||||||
import { CurrencySortingType } from './currency.ts';
|
import { CurrencySortingType } from './currency.ts';
|
||||||
|
import {
|
||||||
|
TransactionQuickSaveButtonStyle,
|
||||||
|
TransactionQuickAddButtonActionType
|
||||||
|
} from './transaction.ts';
|
||||||
import {
|
import {
|
||||||
CategoricalChartType,
|
CategoricalChartType,
|
||||||
TrendChartType,
|
TrendChartType,
|
||||||
@@ -32,6 +36,7 @@ export interface ApplicationSettings extends BaseApplicationSetting {
|
|||||||
showAccountBalance: boolean;
|
showAccountBalance: boolean;
|
||||||
swipeBack: boolean;
|
swipeBack: boolean;
|
||||||
animate: boolean;
|
animate: boolean;
|
||||||
|
expandCategoryTreeByDefault: boolean;
|
||||||
// Application Lock
|
// Application Lock
|
||||||
applicationLock: boolean;
|
applicationLock: boolean;
|
||||||
applicationLockWebAuthn: boolean;
|
applicationLockWebAuthn: boolean;
|
||||||
@@ -43,6 +48,8 @@ export interface ApplicationSettings extends BaseApplicationSetting {
|
|||||||
overviewAccountFilterInHomePage: Record<string, boolean>;
|
overviewAccountFilterInHomePage: Record<string, boolean>;
|
||||||
overviewTransactionCategoryFilterInHomePage: Record<string, boolean>;
|
overviewTransactionCategoryFilterInHomePage: Record<string, boolean>;
|
||||||
// Transaction List Page
|
// Transaction List Page
|
||||||
|
quickSaveButtonStyleInMobileTransactionListPage: number;
|
||||||
|
quickAddButtonActionInMobileTransactionEditPage: number;
|
||||||
itemsCountInTransactionListPage: number;
|
itemsCountInTransactionListPage: number;
|
||||||
showTotalAmountInTransactionListPage: boolean;
|
showTotalAmountInTransactionListPage: boolean;
|
||||||
showTagInTransactionListPage: boolean;
|
showTagInTransactionListPage: boolean;
|
||||||
@@ -62,6 +69,9 @@ export interface ApplicationSettings extends BaseApplicationSetting {
|
|||||||
hideCategoriesWithoutAccounts: boolean;
|
hideCategoriesWithoutAccounts: boolean;
|
||||||
// Exchange Rates Data Page
|
// Exchange Rates Data Page
|
||||||
currencySortByInExchangeRatesPage: number;
|
currencySortByInExchangeRatesPage: number;
|
||||||
|
// Browser Cache Management
|
||||||
|
mapCacheExpiration: number,
|
||||||
|
exchangeRatesDataCacheExpiration: number,
|
||||||
// Statistics Settings
|
// Statistics Settings
|
||||||
statistics: {
|
statistics: {
|
||||||
defaultChartDataType: number;
|
defaultChartDataType: number;
|
||||||
@@ -107,6 +117,9 @@ export interface WebAuthnConfig {
|
|||||||
export const ALL_ALLOWED_CLOUD_SYNC_APP_SETTING_KEY_TYPES: Record<string, UserApplicationCloudSettingType> = {
|
export const ALL_ALLOWED_CLOUD_SYNC_APP_SETTING_KEY_TYPES: Record<string, UserApplicationCloudSettingType> = {
|
||||||
// Basic Settings
|
// Basic Settings
|
||||||
'showAccountBalance': UserApplicationCloudSettingType.Boolean,
|
'showAccountBalance': UserApplicationCloudSettingType.Boolean,
|
||||||
|
'autoUpdateExchangeRatesData': UserApplicationCloudSettingType.Boolean,
|
||||||
|
// Navigation Bar
|
||||||
|
'showAddTransactionButtonInDesktopNavbar': UserApplicationCloudSettingType.Boolean,
|
||||||
// Overview Page
|
// Overview Page
|
||||||
'showAmountInHomePage': UserApplicationCloudSettingType.Boolean,
|
'showAmountInHomePage': UserApplicationCloudSettingType.Boolean,
|
||||||
'timezoneUsedForStatisticsInHomePage': UserApplicationCloudSettingType.Number,
|
'timezoneUsedForStatisticsInHomePage': UserApplicationCloudSettingType.Number,
|
||||||
@@ -117,9 +130,12 @@ export const ALL_ALLOWED_CLOUD_SYNC_APP_SETTING_KEY_TYPES: Record<string, UserAp
|
|||||||
'showTotalAmountInTransactionListPage': UserApplicationCloudSettingType.Boolean,
|
'showTotalAmountInTransactionListPage': UserApplicationCloudSettingType.Boolean,
|
||||||
'showTagInTransactionListPage': UserApplicationCloudSettingType.Boolean,
|
'showTagInTransactionListPage': UserApplicationCloudSettingType.Boolean,
|
||||||
// Transaction Edit Page
|
// Transaction Edit Page
|
||||||
|
'quickSaveButtonStyleInMobileTransactionListPage': UserApplicationCloudSettingType.Number,
|
||||||
|
'quickAddButtonActionInMobileTransactionEditPage': UserApplicationCloudSettingType.Number,
|
||||||
'autoSaveTransactionDraft': UserApplicationCloudSettingType.String,
|
'autoSaveTransactionDraft': UserApplicationCloudSettingType.String,
|
||||||
'autoGetCurrentGeoLocation': UserApplicationCloudSettingType.Boolean,
|
'autoGetCurrentGeoLocation': UserApplicationCloudSettingType.Boolean,
|
||||||
'alwaysShowTransactionPicturesInMobileTransactionEditPage': UserApplicationCloudSettingType.Boolean,
|
'alwaysShowTransactionPicturesInMobileTransactionEditPage': UserApplicationCloudSettingType.Boolean,
|
||||||
|
'expandCategoryTreeByDefault': UserApplicationCloudSettingType.Boolean,
|
||||||
// Import Transaction Dialog
|
// Import Transaction Dialog
|
||||||
'rememberLastSelectedFileTypeInImportTransactionDialog': UserApplicationCloudSettingType.Boolean,
|
'rememberLastSelectedFileTypeInImportTransactionDialog': UserApplicationCloudSettingType.Boolean,
|
||||||
'lastSelectedFileTypeInImportTransactionDialog': UserApplicationCloudSettingType.String,
|
'lastSelectedFileTypeInImportTransactionDialog': UserApplicationCloudSettingType.String,
|
||||||
@@ -132,6 +148,9 @@ export const ALL_ALLOWED_CLOUD_SYNC_APP_SETTING_KEY_TYPES: Record<string, UserAp
|
|||||||
'hideCategoriesWithoutAccounts': UserApplicationCloudSettingType.Boolean,
|
'hideCategoriesWithoutAccounts': UserApplicationCloudSettingType.Boolean,
|
||||||
// Exchange Rates Data Page
|
// Exchange Rates Data Page
|
||||||
'currencySortByInExchangeRatesPage': UserApplicationCloudSettingType.Number,
|
'currencySortByInExchangeRatesPage': UserApplicationCloudSettingType.Number,
|
||||||
|
// Browser Cache Management
|
||||||
|
'mapCacheExpiration': UserApplicationCloudSettingType.Number,
|
||||||
|
'exchangeRatesDataCacheExpiration': UserApplicationCloudSettingType.Number,
|
||||||
// Statistics Settings
|
// Statistics Settings
|
||||||
'statistics.defaultChartDataType': UserApplicationCloudSettingType.Number,
|
'statistics.defaultChartDataType': UserApplicationCloudSettingType.Number,
|
||||||
'statistics.defaultTimezoneType': UserApplicationCloudSettingType.Number,
|
'statistics.defaultTimezoneType': UserApplicationCloudSettingType.Number,
|
||||||
@@ -157,6 +176,7 @@ export const DEFAULT_APPLICATION_SETTINGS: ApplicationSettings = {
|
|||||||
showAccountBalance: true,
|
showAccountBalance: true,
|
||||||
swipeBack: true,
|
swipeBack: true,
|
||||||
animate: true,
|
animate: true,
|
||||||
|
expandCategoryTreeByDefault: false,
|
||||||
// Application Lock
|
// Application Lock
|
||||||
applicationLock: false,
|
applicationLock: false,
|
||||||
applicationLockWebAuthn: false,
|
applicationLockWebAuthn: false,
|
||||||
@@ -172,6 +192,8 @@ export const DEFAULT_APPLICATION_SETTINGS: ApplicationSettings = {
|
|||||||
showTotalAmountInTransactionListPage: true,
|
showTotalAmountInTransactionListPage: true,
|
||||||
showTagInTransactionListPage: true,
|
showTagInTransactionListPage: true,
|
||||||
// Transaction Edit Page
|
// Transaction Edit Page
|
||||||
|
quickSaveButtonStyleInMobileTransactionListPage: TransactionQuickSaveButtonStyle.Default.type,
|
||||||
|
quickAddButtonActionInMobileTransactionEditPage: TransactionQuickAddButtonActionType.Default.type,
|
||||||
autoSaveTransactionDraft: 'disabled',
|
autoSaveTransactionDraft: 'disabled',
|
||||||
autoGetCurrentGeoLocation: false,
|
autoGetCurrentGeoLocation: false,
|
||||||
alwaysShowTransactionPicturesInMobileTransactionEditPage: false,
|
alwaysShowTransactionPicturesInMobileTransactionEditPage: false,
|
||||||
@@ -187,6 +209,9 @@ export const DEFAULT_APPLICATION_SETTINGS: ApplicationSettings = {
|
|||||||
hideCategoriesWithoutAccounts: false,
|
hideCategoriesWithoutAccounts: false,
|
||||||
// Exchange Rates Data Page
|
// Exchange Rates Data Page
|
||||||
currencySortByInExchangeRatesPage: CurrencySortingType.Default.type,
|
currencySortByInExchangeRatesPage: CurrencySortingType.Default.type,
|
||||||
|
// Browser Cache Management
|
||||||
|
mapCacheExpiration: -1,
|
||||||
|
exchangeRatesDataCacheExpiration: 0,
|
||||||
// Statistics Settings
|
// Statistics Settings
|
||||||
statistics: {
|
statistics: {
|
||||||
defaultChartDataType: ChartDataType.Default.type,
|
defaultChartDataType: ChartDataType.Default.type,
|
||||||
|
|||||||
+16
-9
@@ -1,4 +1,4 @@
|
|||||||
import type { TypeAndName } from './base.ts';
|
import type { TypeAndName, TypeAndNameWithAlternativeName } from './base.ts';
|
||||||
import { DateRange } from '@/core/datetime.ts';
|
import { DateRange } from '@/core/datetime.ts';
|
||||||
|
|
||||||
export enum StatisticsAnalysisType {
|
export enum StatisticsAnalysisType {
|
||||||
@@ -88,7 +88,8 @@ export class AccountBalanceTrendChartType implements TypeAndName {
|
|||||||
|
|
||||||
public static readonly Area = new AccountBalanceTrendChartType(0, 'Area Chart');
|
public static readonly Area = new AccountBalanceTrendChartType(0, 'Area Chart');
|
||||||
public static readonly Column = new AccountBalanceTrendChartType(1, 'Column Chart');
|
public static readonly Column = new AccountBalanceTrendChartType(1, 'Column Chart');
|
||||||
public static readonly Candlestick = new AccountBalanceTrendChartType(2, 'Candlestick Chart');
|
public static readonly Boxplot = new AccountBalanceTrendChartType(2, 'Boxplot Chart');
|
||||||
|
public static readonly Candlestick = new AccountBalanceTrendChartType(3, 'Candlestick Chart');
|
||||||
|
|
||||||
public static readonly Default = TrendChartType.Column;
|
public static readonly Default = TrendChartType.Column;
|
||||||
|
|
||||||
@@ -193,24 +194,24 @@ export class ChartDataType implements TypeAndName {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ChartSortingType implements TypeAndName {
|
export class ChartSortingType implements TypeAndNameWithAlternativeName {
|
||||||
private static readonly allInstances: ChartSortingType[] = [];
|
private static readonly allInstances: ChartSortingType[] = [];
|
||||||
private static readonly allInstancesByType: Record<number, ChartSortingType> = {};
|
private static readonly allInstancesByType: Record<number, ChartSortingType> = {};
|
||||||
|
|
||||||
public static readonly Amount = new ChartSortingType(0, 'Amount', 'Sort by Amount');
|
public static readonly Amount = new ChartSortingType(0, 'Amount', 'Value');
|
||||||
public static readonly DisplayOrder = new ChartSortingType(1, 'Display Order', 'Sort by Display Order');
|
public static readonly DisplayOrder = new ChartSortingType(1, 'Display Order');
|
||||||
public static readonly Name = new ChartSortingType(2, 'Name', 'Sort by Name');
|
public static readonly Name = new ChartSortingType(2, 'Name');
|
||||||
|
|
||||||
public static readonly Default = ChartSortingType.Amount;
|
public static readonly Default = ChartSortingType.Amount;
|
||||||
|
|
||||||
public readonly type: number;
|
public readonly type: number;
|
||||||
public readonly name: string;
|
public readonly name: string;
|
||||||
public readonly fullName: string;
|
public readonly alternativeName?: string;
|
||||||
|
|
||||||
private constructor(type: number, name: string, fullName: string) {
|
private constructor(type: number, name: string, alternativeName?: string) {
|
||||||
this.type = type;
|
this.type = type;
|
||||||
this.name = name;
|
this.name = name;
|
||||||
this.fullName = fullName;
|
this.alternativeName = alternativeName;
|
||||||
|
|
||||||
ChartSortingType.allInstances.push(this);
|
ChartSortingType.allInstances.push(this);
|
||||||
ChartSortingType.allInstancesByType[type] = this;
|
ChartSortingType.allInstancesByType[type] = this;
|
||||||
@@ -285,6 +286,12 @@ export class ChartDateAggregationType {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum ExportMermaidChartType {
|
||||||
|
PieChart = 'pieChart',
|
||||||
|
XYChartBar = 'xyChartBar',
|
||||||
|
XYChartLine = 'xyChartLine'
|
||||||
|
}
|
||||||
|
|
||||||
export const DEFAULT_CATEGORICAL_CHART_DATA_RANGE: DateRange = DateRange.ThisMonth;
|
export const DEFAULT_CATEGORICAL_CHART_DATA_RANGE: DateRange = DateRange.ThisMonth;
|
||||||
export const DEFAULT_TREND_CHART_DATA_RANGE: DateRange = DateRange.ThisYear;
|
export const DEFAULT_TREND_CHART_DATA_RANGE: DateRange = DateRange.ThisYear;
|
||||||
export const DEFAULT_ASSET_TRENDS_CHART_DATA_RANGE: DateRange = DateRange.ThisYear;
|
export const DEFAULT_ASSET_TRENDS_CHART_DATA_RANGE: DateRange = DateRange.ThisYear;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user