Compare commits
340 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6787d0591e | |||
| d78dada5ec | |||
| 74844b9a99 | |||
| a29ff0d553 | |||
| 6632dd64b3 | |||
| a8c912c4c2 | |||
| 2a02816127 | |||
| 66b950b4aa | |||
| 639bd9c5cd | |||
| 2bf8c0b501 | |||
| 847d5121aa | |||
| ab6e89594e | |||
| 0662427cec | |||
| d3b283f623 | |||
| 385d97ba15 | |||
| 09574f1c75 | |||
| 34247be52c | |||
| 56b1e1f565 | |||
| 7c6a3081ee | |||
| beeeb1c059 | |||
| 47f70098df | |||
| 4e6b708834 | |||
| a359b07ef3 | |||
| bb0524c559 | |||
| d70ea1987a | |||
| 001de8a1e0 | |||
| f045e8ffcd | |||
| f8be1222d6 | |||
| 8848fe8b33 | |||
| 32524dac56 | |||
| a50ecf4d9c | |||
| 98fda4e5d5 | |||
| f0c111f02a | |||
| bcc36e1533 | |||
| 872639fefa | |||
| e83b959930 | |||
| 3f8de39683 | |||
| 9430f57a0b | |||
| 703ceb44e4 | |||
| 3954db9b99 | |||
| 7abb5972eb | |||
| 377a4899b7 | |||
| d769e833e7 | |||
| 9786f96fe5 | |||
| bd2a672c12 | |||
| 96cb45dd45 | |||
| cf370c083b | |||
| 43e1780dc8 | |||
| 6aac810450 | |||
| f14c283a83 | |||
| e78b2cafb1 | |||
| a9eaf011cd | |||
| 7fca519fd9 | |||
| a9a37b0c97 | |||
| 8f55bd0df1 | |||
| 85e88949c4 | |||
| 80bcebbf66 | |||
| 7cae873830 | |||
| 7a1b27927f | |||
| 306da60752 | |||
| 4274b90b1e | |||
| 0b5721671d | |||
| 30575d15d0 | |||
| 0ca2f8b4a7 | |||
| 2e01e5530c | |||
| 13cc6a2cf0 | |||
| 35ba5dcc9f | |||
| ab58109e5e | |||
| 18a6d25ed6 | |||
| a0e3a269a0 | |||
| 1658d0758c | |||
| 6f0c59bba4 | |||
| 1e98a0df55 | |||
| fa77d3e837 | |||
| a21bc7aad7 | |||
| e665fac956 | |||
| e60c633e56 | |||
| a444526743 | |||
| b65a246fcb | |||
| c5e8a50033 | |||
| 6021c24da9 | |||
| 0e4cd10376 | |||
| f2c043a299 | |||
| 596787b998 | |||
| bb3a0c4444 | |||
| 624b9cb20b | |||
| 5a6c25d616 | |||
| eb178e7bed | |||
| 373d71c124 | |||
| 6618cfeceb | |||
| 58c1382570 | |||
| 721eb122bd | |||
| 95205d2f1d | |||
| b6efa91879 | |||
| 29d7ee09c8 | |||
| 68cb5bc523 | |||
| bd96b2398a | |||
| 1579882475 | |||
| b077b99806 | |||
| 9797e7e58f | |||
| 00f62fd608 | |||
| 833e767e6c | |||
| 3e7b3297aa | |||
| 8e170a69e8 | |||
| 00d6f5d473 | |||
| 3c363788d8 | |||
| cc920cff9a | |||
| 5d78d56f0c | |||
| b9b47c4428 | |||
| a5382e9fdd | |||
| 376f5b2650 | |||
| 61c5f75006 | |||
| 9a6148fe6e | |||
| ff6a558e96 | |||
| 3ad45bebb7 | |||
| 6b152bd778 | |||
| aacde2dfde | |||
| 6971eccb22 | |||
| a5e7c483ef | |||
| 319f97bf9e | |||
| da31a67c52 | |||
| af355e5b85 | |||
| ca9fe264b4 | |||
| c07e937702 | |||
| 371b88c6cd | |||
| 782bc11950 | |||
| 50c3fee7dc | |||
| 51c4e06e59 | |||
| 2a84f44f2c | |||
| 4878c7258d | |||
| e2a4e0cb3f | |||
| fd3457af84 | |||
| 8c0a9062a2 | |||
| 10d301aa3c | |||
| d7193847c5 | |||
| 8ea079679c | |||
| 95c7b498ff | |||
| 1156499b05 | |||
| e92aaffc94 | |||
| 8dc38912c9 | |||
| d228bf12bb | |||
| 7dff8a2ed5 | |||
| 814fe02949 | |||
| da702d6316 | |||
| fd909023f9 | |||
| c94c455b8b | |||
| d39b0ee077 | |||
| 99a2f40a4e | |||
| acaad355ed | |||
| ad4b351a32 | |||
| 2902fae1df | |||
| a0b9ca7fae | |||
| 05d8f8b9ab | |||
| d074a9d54a | |||
| d0274013cf | |||
| 1e0169a9b7 | |||
| c619d2ecad | |||
| a27a2556aa | |||
| 8207373a05 | |||
| 986fab9cbf | |||
| 2025551f3c | |||
| 3d934ab018 | |||
| 6a59ed0984 | |||
| 8fce3f2bcc | |||
| eca0574e41 | |||
| fa044f5972 | |||
| 6d758f338b | |||
| 28322bad5e | |||
| a9805b8fff | |||
| eb16b7fbb8 | |||
| 70428b6c96 | |||
| 85557c2879 | |||
| 5bf7f77520 | |||
| 3cdc7c947f | |||
| 84fc6b2ffb | |||
| e7612f6f0c | |||
| 58097331da | |||
| e053528abf | |||
| 568abb6e03 | |||
| 852c7899b5 | |||
| f4998da4cd | |||
| 9d9e6ef9bd | |||
| fb367174b6 | |||
| 87566010be | |||
| 2f7cdfd786 | |||
| 559e8259be | |||
| 929d3febb0 | |||
| 92df98c3fb | |||
| 019c993313 | |||
| 3c370b7ac7 | |||
| 1afd811aa8 | |||
| 0a999d56c7 | |||
| 4f6988d775 | |||
| 9f2bbe527e | |||
| 965be837a3 | |||
| f5f8b9a145 | |||
| f3b6c1266d | |||
| c675057ab1 | |||
| 20e95e35aa | |||
| f22666e756 | |||
| 3567ac170a | |||
| 260bd952d4 | |||
| e294f04b04 | |||
| 27fa5625be | |||
| 62623e6a23 | |||
| 9df436d31e | |||
| c03f74154b | |||
| 749bdfd164 | |||
| 6878d5260d | |||
| 4f21762533 | |||
| adebc96637 | |||
| 3a50e6d2de | |||
| b09b66adc3 | |||
| 6ef42a9303 | |||
| 922c338387 | |||
| dc4310c301 | |||
| 0b7fd647e6 | |||
| 081b270f04 | |||
| 29c09cb10a | |||
| cd2e6c1aae | |||
| d3e6756c22 | |||
| 7d31812055 | |||
| 8ce871e9bb | |||
| 30125f0faa | |||
| f4ea9a85f0 | |||
| 593e123610 | |||
| 4e8eddd868 | |||
| 041dbcb5c7 | |||
| 215a0163b6 | |||
| a38867ce01 | |||
| 9026c526f8 | |||
| c1f94a4499 | |||
| 2be329974e | |||
| abb26ac410 | |||
| c5f03165bc | |||
| 41452ac20b | |||
| a285707b53 | |||
| cc9a5eea36 | |||
| 89d5cc31af | |||
| f0e11e952c | |||
| 0f8431ff5c | |||
| 659b819011 | |||
| 6a62cfdef7 | |||
| f2ebd751d4 | |||
| 1414f54a12 | |||
| c3265c5bf6 | |||
| 13e322bb57 | |||
| 5cacfc8daf | |||
| 9bbe4d2dcf | |||
| b517409229 | |||
| 75a96e871a | |||
| 395f7dfd63 | |||
| b166f6ff56 | |||
| eb2b6d1002 | |||
| 6cb045453a | |||
| ffae9e81a7 | |||
| dc59d3954a | |||
| 7b26eb50bf | |||
| c0ab1ad793 | |||
| c73dcb51e4 | |||
| 8c7fc0fef9 | |||
| 76a2e24d06 | |||
| 3e6a054913 | |||
| 89b233e51b | |||
| 61f26e060e | |||
| 5649bb243d | |||
| ea90e97f92 | |||
| 3c624188d1 | |||
| 04f373e931 | |||
| b2e36a24fd | |||
| 8da3d2aa35 | |||
| 25c8b9baf8 | |||
| 1555052e1d | |||
| b1fbf91d6e | |||
| 5dfac0c085 | |||
| cb142a65f3 | |||
| b0a9b2366e | |||
| ed897d4105 | |||
| 2b71723ba1 | |||
| 60ba3b7977 | |||
| e0198da52c | |||
| 6365805715 | |||
| 5e7e3696bf | |||
| 166fae425d | |||
| f56bef40d8 | |||
| ad1eec7d47 | |||
| 5b241d2547 | |||
| 92a626fb21 | |||
| 5171f23c09 | |||
| 6dc0ebcac6 | |||
| 49f1f3c86b | |||
| 53ab441486 | |||
| 0e422b5a8f | |||
| 4f51480af9 | |||
| fb8fbbcf70 | |||
| 5256eff88d | |||
| 7c40157cba | |||
| 454e97c9f1 | |||
| 41d34af4c7 | |||
| a13f5bfb10 | |||
| da06fe4a7b | |||
| 061ea6aab4 | |||
| a19cc81391 | |||
| 871164b969 | |||
| 16fa77eb09 | |||
| a46399cbaf | |||
| a9e50d29d3 | |||
| e7d7f217a9 | |||
| 000c2b9ab0 | |||
| 37fdb161ea | |||
| 2d923bbdc9 | |||
| 07c55de024 | |||
| 30c463627a | |||
| 5eec635146 | |||
| 229d9c76c3 | |||
| 0119eadc14 | |||
| c1c656ab7e | |||
| e4a50bcd60 | |||
| d18e6df1c0 | |||
| af9aa726f4 | |||
| 27f8c90dae | |||
| b9a3c384d9 | |||
| eed7085756 | |||
| abb0c2ad16 | |||
| 9f7b40381c | |||
| ad9a390b58 | |||
| 5525635df1 | |||
| 863e0205ff | |||
| 2560a70e5e | |||
| b638a73e4d | |||
| 0a9cea4df3 | |||
| 15a98c3eac | |||
| 493c16087d | |||
| dd155a0f63 | |||
| 9ce1c8d397 | |||
| 3040435c06 | |||
| 685f93f05e | |||
| d465d9da1a | |||
| 50c2766014 | |||
| 4e1cbf13c6 |
@@ -0,0 +1,17 @@
|
||||
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
|
||||
@@ -10,11 +10,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ secrets.DOCKER_REPO }}/mayswind/ezbookkeeping
|
||||
@@ -24,10 +24,12 @@ jobs:
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
uses: docker/setup-qemu-action@v3
|
||||
with:
|
||||
image: tonistiigi/binfmt:qemu-v8.1.5
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Set up the environment
|
||||
run: |
|
||||
@@ -44,7 +46,7 @@ jobs:
|
||||
chmod +x docker/custom-frontend-pre-setup.sh
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
file: Dockerfile
|
||||
context: .
|
||||
@@ -52,5 +54,6 @@ jobs:
|
||||
push: true
|
||||
build-args: |
|
||||
RELEASE_BUILD=1
|
||||
SKIP_TESTS=${{ vars.SKIP_TESTS }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
@@ -10,11 +10,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ secrets.DOCKER_REPO }}/mayswind/ezbookkeeping
|
||||
@@ -24,10 +24,12 @@ jobs:
|
||||
type=sha,format=short,prefix=SNAPSHOT-
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
uses: docker/setup-qemu-action@v3
|
||||
with:
|
||||
image: tonistiigi/binfmt:qemu-v8.1.5
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Set up the environment
|
||||
run: |
|
||||
@@ -44,7 +46,7 @@ jobs:
|
||||
chmod +x docker/custom-frontend-pre-setup.sh
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
file: Dockerfile
|
||||
context: .
|
||||
|
||||
@@ -10,11 +10,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ secrets.DOCKER_USERNAME }}/ezbookkeeping
|
||||
@@ -24,19 +24,21 @@ jobs:
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
uses: docker/setup-qemu-action@v3
|
||||
with:
|
||||
image: tonistiigi/binfmt:qemu-v8.1.5
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
file: Dockerfile
|
||||
context: .
|
||||
@@ -48,5 +50,6 @@ jobs:
|
||||
push: true
|
||||
build-args: |
|
||||
RELEASE_BUILD=1
|
||||
SKIP_TESTS=${{ vars.SKIP_TESTS }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
@@ -10,11 +10,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ secrets.DOCKER_USERNAME }}/ezbookkeeping
|
||||
@@ -23,19 +23,21 @@ jobs:
|
||||
type=raw,value=latest-snapshot
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
uses: docker/setup-qemu-action@v3
|
||||
with:
|
||||
image: tonistiigi/binfmt:qemu-v8.1.5
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
file: Dockerfile
|
||||
context: .
|
||||
|
||||
@@ -11,16 +11,16 @@ jobs:
|
||||
steps:
|
||||
-
|
||||
name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v4
|
||||
-
|
||||
name: Login to DockerHub
|
||||
uses: docker/login-action@v1
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
-
|
||||
name: Build
|
||||
uses: docker/build-push-action@v2
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
file: Dockerfile
|
||||
context: .
|
||||
|
||||
+3
-3
@@ -1,5 +1,5 @@
|
||||
# Build backend binary file
|
||||
FROM golang:1.23.4-alpine3.21 AS be-builder
|
||||
FROM golang:1.24.0-alpine3.21 AS be-builder
|
||||
ARG RELEASE_BUILD
|
||||
ARG SKIP_TESTS
|
||||
ENV RELEASE_BUILD=$RELEASE_BUILD
|
||||
@@ -11,7 +11,7 @@ RUN apk add git gcc g++ libc-dev
|
||||
RUN ./build.sh backend
|
||||
|
||||
# Build frontend files
|
||||
FROM --platform=$BUILDPLATFORM node:20.18.1-alpine3.21 AS fe-builder
|
||||
FROM --platform=$BUILDPLATFORM node:22.14.0-alpine3.21 AS fe-builder
|
||||
ARG RELEASE_BUILD
|
||||
ENV RELEASE_BUILD=$RELEASE_BUILD
|
||||
WORKDIR /go/src/github.com/mayswind/ezbookkeeping
|
||||
@@ -21,7 +21,7 @@ RUN apk add git
|
||||
RUN ./build.sh frontend
|
||||
|
||||
# Package docker image
|
||||
FROM alpine:3.21.0
|
||||
FROM alpine:3.21.3
|
||||
LABEL maintainer="MaysWind <i@mayswind.net>"
|
||||
RUN addgroup -S -g 1000 ezbookkeeping && adduser -S -G ezbookkeeping -u 1000 ezbookkeeping
|
||||
RUN apk --no-cache add tzdata
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2020-2024 MaysWind (i@mayswind.net)
|
||||
Copyright (c) 2020-2025 MaysWind (i@mayswind.net)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
[](https://github.com/mayswind/ezbookkeeping/releases)
|
||||
|
||||
## Introduction
|
||||
ezBookkeeping is a lightweight personal bookkeeping app hosted by yourself. It can be deployed on almost all platforms, including Windows, macOS and Linux on x86, amd64 and ARM architectures. You can even deploy it on an raspberry device. It also supports many different databases, including sqlite and mysql. With docker, you can just deploy it via one command without complicated configuration.
|
||||
ezBookkeeping is a lightweight personal bookkeeping app hosted by yourself. It can be deployed on almost all platforms, including Windows, macOS and Linux on x86, amd64 and ARM architectures. You can even deploy it on an raspberry device. It also supports many different databases, including SQLite, MySQL and PostgreSQL. With docker, you can just deploy it via one command without complicated configuration.
|
||||
|
||||
Online Demo: [https://ezbookkeeping-demo.mayswind.net](https://ezbookkeeping-demo.mayswind.net)
|
||||
|
||||
@@ -31,7 +31,7 @@ Online Demo: [https://ezbookkeeping-demo.mayswind.net](https://ezbookkeeping-dem
|
||||
7. Multi-language support
|
||||
8. Two-factor authentication
|
||||
9. Application lock (PIN code / WebAuthn)
|
||||
10. Data import & export
|
||||
10. Data export & import (OFX, QFX, QIF, IIF, CSV, GnuCash, FireFly III, etc.)
|
||||
|
||||
## Screenshots
|
||||
### Desktop Version
|
||||
@@ -88,7 +88,7 @@ You can also build docker image, make sure you have [docker](https://www.docker.
|
||||
|
||||
## Documents
|
||||
1. [English](http://ezbookkeeping.mayswind.net)
|
||||
1. [简体中文 (Simplified Chinese)](http://ezbookkeeping.mayswind.net/zh_Hans)
|
||||
1. [中文 (简体)](http://ezbookkeeping.mayswind.net/zh_Hans)
|
||||
|
||||
## License
|
||||
[MIT](https://github.com/mayswind/ezbookkeeping/blob/master/LICENSE)
|
||||
|
||||
+5
-1
@@ -792,7 +792,11 @@ func exportUserTransaction(c *core.CliContext) error {
|
||||
filePath := c.String("file")
|
||||
fileType := c.String("type")
|
||||
|
||||
if fileType != "" && fileType != "csv" && fileType != "tsv" {
|
||||
if fileType == "" {
|
||||
fileType = "csv"
|
||||
}
|
||||
|
||||
if fileType != "csv" && fileType != "tsv" {
|
||||
log.CliErrorf(c, "[user_data.exportUserTransaction] export file type is not supported")
|
||||
return errs.ErrNotSupported
|
||||
}
|
||||
|
||||
@@ -116,8 +116,13 @@ func startWebServer(c *core.CliContext) error {
|
||||
router.StaticFile("favicon.png", filepath.Join(config.StaticRootPath, "favicon.png"))
|
||||
router.StaticFile("touchicon.png", filepath.Join(config.StaticRootPath, "touchicon.png"))
|
||||
router.StaticFile("manifest.json", filepath.Join(config.StaticRootPath, "manifest.json"))
|
||||
router.StaticFile("sw.js", filepath.Join(config.StaticRootPath, "sw.js"))
|
||||
router.GET("/server_settings.js", bindCachedJs(api.ServerSettings.ServerSettingsJavascriptHandler, serverSettingsCacheStore))
|
||||
|
||||
for i := 0; i < len(workboxFileNames); i++ {
|
||||
router.StaticFile("/"+workboxFileNames[i], filepath.Join(config.StaticRootPath, workboxFileNames[i]))
|
||||
}
|
||||
|
||||
router.StaticFile("/mobile", filepath.Join(config.StaticRootPath, "mobile.html"))
|
||||
router.Static("/mobile/js", filepath.Join(config.StaticRootPath, "js"))
|
||||
router.Static("/mobile/css", filepath.Join(config.StaticRootPath, "css"))
|
||||
@@ -310,6 +315,7 @@ func startWebServer(c *core.CliContext) error {
|
||||
apiV1Route.POST("/transactions/delete.json", bindApi(api.Transactions.TransactionDeleteHandler))
|
||||
|
||||
if config.EnableDataImport {
|
||||
apiV1Route.POST("/transactions/parse_dsv_file.json", bindApi(api.Transactions.TransactionParseImportDsvFileDataHandler))
|
||||
apiV1Route.POST("/transactions/parse_import.json", bindApi(api.Transactions.TransactionParseImportFileHandler))
|
||||
apiV1Route.POST("/transactions/import.json", bindApi(api.Transactions.TransactionImportHandler))
|
||||
}
|
||||
|
||||
@@ -180,6 +180,12 @@ email_verify_token_expired_time = 3600
|
||||
# Password reset token expired seconds (60 - 4294967295), default is 3600 (60 minutes)
|
||||
password_reset_token_expired_time = 3600
|
||||
|
||||
# Maximum count of password / token check failures (0 - 4294967295) per IP per minute (use the above duplicate checker), default is 5, set to 0 to disable
|
||||
max_failures_per_ip_per_minute = 5
|
||||
|
||||
# Maximum count of password / token check failures (0 - 4294967295) per user per minute (use the above duplicate checker), default is 5, set to 0 to disable
|
||||
max_failures_per_user_per_minute = 5
|
||||
|
||||
# Add X-Request-Id header to response to track user request or error, default is true
|
||||
request_id_header = true
|
||||
|
||||
@@ -248,7 +254,7 @@ enable_tips_in_login_page = false
|
||||
|
||||
# The custom tips displayed in login page, it supports multi-language configuration
|
||||
# Add an underscore and a language tag after the setting key to configure the notification content in that language, the same below
|
||||
# For example, login_page_tips_content_zh_hans means the notification content in Simplified Chinese
|
||||
# For example, login_page_tips_content_zh_hans means the notification content in Chinese (Simplified)
|
||||
login_page_tips_content =
|
||||
|
||||
[notification]
|
||||
@@ -257,7 +263,7 @@ enable_notification_after_register = false
|
||||
|
||||
# The notification content displayed each time users register, it supports multi-language configuration
|
||||
# Add an underscore and a language tag after the setting key to configure the notification content in that language, the same below
|
||||
# For example, after_login_notification_content_zh_hans means the notification content in Simplified Chinese
|
||||
# For example, after_login_notification_content_zh_hans means the notification content in Chinese (Simplified)
|
||||
after_register_notification_content =
|
||||
|
||||
# Set to true to display custom notification in home page every time users login
|
||||
@@ -315,7 +321,7 @@ amap_application_key =
|
||||
# "external_proxy": use an external proxy to request amap api (amap application secret should be set by external proxy)
|
||||
# "plain_text": append amap application secret to frontend request directly (insecurity for public network)
|
||||
# Please visit https://developer.amap.com/api/jsapi-v2/guide/abc/load for more information
|
||||
amap_security_verification_method = plain_text
|
||||
amap_security_verification_method = internal_proxy
|
||||
|
||||
# For "amap" map provider only, Amap JavaScript API application secret, this setting must be provided when "amap_security_verification_method" is set to "internal_proxy" or "plain_text", please visit https://lbs.amap.com/api/javascript-api/guide/abc/prepare for more information
|
||||
amap_application_secret =
|
||||
|
||||
+22
-27
@@ -1,36 +1,31 @@
|
||||
import globals from 'globals';
|
||||
import pluginVue from 'eslint-plugin-vue';
|
||||
import vueTsEslintConfig from '@vue/eslint-config-typescript';
|
||||
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import js from '@eslint/js';
|
||||
import { FlatCompat } from '@eslint/eslintrc';
|
||||
import { includeIgnoreFile } from '@eslint/compat';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const gitignorePath = path.resolve(__dirname, '.gitignore');
|
||||
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: __dirname,
|
||||
recommendedConfig: js.configs.recommended,
|
||||
allConfig: js.configs.all
|
||||
});
|
||||
|
||||
export default [...compat.extends('eslint:recommended', 'plugin:vue/vue3-essential'),
|
||||
includeIgnoreFile(gitignorePath), {
|
||||
export default [
|
||||
...pluginVue.configs['flat/essential'],
|
||||
...vueTsEslintConfig(),
|
||||
{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
ignores: [
|
||||
'dist/**',
|
||||
'**/*.{js,jsx,cjs,mjs}'
|
||||
]
|
||||
},
|
||||
{
|
||||
files: [
|
||||
"**/*.{vue,js,jsx,cjs,mjs}"
|
||||
'**/*.{vue,ts,tsx,mts,js,jsx,cjs,mjs}'
|
||||
],
|
||||
rules: {
|
||||
'vue/no-use-v-if-with-v-for': 'off',
|
||||
'vue/valid-v-slot': ['error', {
|
||||
allowModifiers: true,
|
||||
}],
|
||||
allowModifiers: true
|
||||
}]
|
||||
}
|
||||
},
|
||||
}];
|
||||
];
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
module github.com/mayswind/ezbookkeeping
|
||||
|
||||
go 1.22
|
||||
go 1.24
|
||||
|
||||
require (
|
||||
github.com/boombuler/barcode v1.0.2
|
||||
github.com/extrame/xls v0.0.2-0.20200426124601-4a6cf263071b
|
||||
github.com/gin-contrib/cache v1.3.0
|
||||
github.com/gin-contrib/gzip v1.0.1
|
||||
github.com/gin-contrib/cache v1.3.1
|
||||
github.com/gin-contrib/gzip v1.2.2
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/go-co-op/gocron/v2 v2.12.3
|
||||
github.com/go-playground/validator/v10 v10.22.1
|
||||
github.com/go-co-op/gocron/v2 v2.15.0
|
||||
github.com/go-playground/validator/v10 v10.24.0
|
||||
github.com/go-sql-driver/mysql v1.8.1
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/mattn/go-sqlite3 v1.14.24
|
||||
github.com/minio/minio-go/v7 v7.0.80
|
||||
github.com/minio/minio-go/v7 v7.0.85
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible
|
||||
github.com/pquerna/otp v1.4.0
|
||||
github.com/sirupsen/logrus v1.9.3
|
||||
github.com/stretchr/testify v1.9.0
|
||||
github.com/urfave/cli/v2 v2.27.4
|
||||
github.com/stretchr/testify v1.10.0
|
||||
github.com/urfave/cli/v2 v2.27.5
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8
|
||||
golang.org/x/crypto v0.28.0
|
||||
golang.org/x/net v0.30.0
|
||||
golang.org/x/text v0.19.0
|
||||
golang.org/x/crypto v0.33.0
|
||||
golang.org/x/net v0.34.0
|
||||
golang.org/x/text v0.22.0
|
||||
gopkg.in/ini.v1 v1.67.0
|
||||
gopkg.in/mail.v2 v2.3.1
|
||||
xorm.io/builder v0.3.13
|
||||
@@ -35,30 +35,30 @@ require (
|
||||
github.com/bahlo/generic-list-go v0.2.0 // indirect
|
||||
github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874 // indirect
|
||||
github.com/buger/jsonparser v1.1.1 // indirect
|
||||
github.com/bytedance/sonic v1.11.6 // indirect
|
||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||
github.com/bytedance/sonic v1.12.7 // indirect
|
||||
github.com/bytedance/sonic/loader v0.2.2 // indirect
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect
|
||||
github.com/chenzhuoyu/iasm v0.9.1 // indirect
|
||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/extrame/goyymmdd v0.0.0-20210114090516-7cc815f00d1a // indirect
|
||||
github.com/extrame/ole2 v0.0.0-20160812065207-d69429661ad7 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||
github.com/gin-contrib/sse v1.0.0 // indirect
|
||||
github.com/go-ini/ini v1.67.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/goccy/go-json v0.10.3 // indirect
|
||||
github.com/goccy/go-json v0.10.4 // indirect
|
||||
github.com/golang/snappy v0.0.4 // indirect
|
||||
github.com/gomodule/redigo v1.8.9 // indirect
|
||||
github.com/gomodule/redigo v1.9.2 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/jonboulle/clockwork v0.4.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/compress v1.17.11 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.8 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
@@ -66,7 +66,7 @@ require (
|
||||
github.com/minio/md5-simd v1.1.2 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||
github.com/robfig/go-cache v0.0.0-20130306151617-9fc39e0dbf62 // indirect
|
||||
@@ -77,10 +77,10 @@ require (
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
golang.org/x/arch v0.13.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 // indirect
|
||||
golang.org/x/sys v0.26.0 // indirect
|
||||
google.golang.org/protobuf v1.34.1 // indirect
|
||||
golang.org/x/sys v0.30.0 // indirect
|
||||
google.golang.org/protobuf v1.36.2 // indirect
|
||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
@@ -12,10 +12,11 @@ github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMU
|
||||
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
|
||||
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
|
||||
github.com/bytedance/sonic v1.10.0-rc/go.mod h1:ElCzW+ufi8qKqNW0FY314xriJhyJhuoJ3gFZdAHF7NM=
|
||||
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
|
||||
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
||||
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
||||
github.com/bytedance/sonic v1.12.7 h1:CQU8pxOy9HToxhndH0Kx/S1qU/CuS9GnKYrGioDcU1Q=
|
||||
github.com/bytedance/sonic v1.12.7/go.mod h1:tnbal4mxOMju17EGfknm2XyYcpyCnIROYOEYuemj13I=
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/bytedance/sonic/loader v0.2.2 h1:jxAJuN9fOot/cyz5Q6dUuMJF5OqQ6+5GfA8FjjQ0R4o=
|
||||
github.com/bytedance/sonic/loader v0.2.2/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d/go.mod h1:8EPpVsBuRksnlj1mLy4AWzRNQYxauNi62uWcE3to6eA=
|
||||
@@ -23,10 +24,9 @@ github.com/chenzhuoyu/iasm v0.9.0/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLI
|
||||
github.com/chenzhuoyu/iasm v0.9.1/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog=
|
||||
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -39,38 +39,38 @@ github.com/extrame/ole2 v0.0.0-20160812065207-d69429661ad7/go.mod h1:GPpMrAfHdb8
|
||||
github.com/extrame/xls v0.0.2-0.20200426124601-4a6cf263071b h1:jqW/h4gcXYEB6kVf6iuxjU9ONWA0ugUB94TP9UNmgdg=
|
||||
github.com/extrame/xls v0.0.2-0.20200426124601-4a6cf263071b/go.mod h1:iACcgahst7BboCpIMSpnFs4SKyU9ZjsvZBfNbUxZOJI=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||
github.com/gin-contrib/cache v1.3.0 h1:wEEw38uvb4rTraQJVpd9ex4ZotXNlc0fSaSUsuPXS/w=
|
||||
github.com/gin-contrib/cache v1.3.0/go.mod h1:EA63LrWGI5vwSI95TS5fgBrtxZ1tM2NKx+NrEeyEDcU=
|
||||
github.com/gin-contrib/gzip v1.0.1 h1:HQ8ENHODeLY7a4g1Au/46Z92bdGFl74OhxcZble9WJE=
|
||||
github.com/gin-contrib/gzip v1.0.1/go.mod h1:njt428fdUNRvjuJf16tZMYZ2Yl+WQB53X5wmhDwXvC4=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||
github.com/gin-contrib/cache v1.3.1 h1:EWjkOaLocs5fGt9feQaI7rt1GZbDyatFXEUh2/s3ZI8=
|
||||
github.com/gin-contrib/cache v1.3.1/go.mod h1:6Tme0p3QEF/Ck/KUcq7h/OAqZvUDjHRH1DtQbNgfIX0=
|
||||
github.com/gin-contrib/gzip v1.2.2 h1:iUU/EYCM8ENfkjmZaVrxbjF/ZC267Iqv5S0MMCMEliI=
|
||||
github.com/gin-contrib/gzip v1.2.2/go.mod h1:C1a5cacjlDsS20cKnHlZRCPUu57D3qH6B2pV0rl+Y/s=
|
||||
github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E=
|
||||
github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0=
|
||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/go-co-op/gocron/v2 v2.12.3 h1:3JkKjkFoAPp/i0YE+sonlF5gi+xnBChwYh75nX16MaE=
|
||||
github.com/go-co-op/gocron/v2 v2.12.3/go.mod h1:xY7bJxGazKam1cz04EebrlP4S9q4iWdiAylMGP3jY9w=
|
||||
github.com/go-co-op/gocron/v2 v2.15.0 h1:Kpvo71VSihE+RImmpA+3ta5CcMhoRzMGw4dJawrj4zo=
|
||||
github.com/go-co-op/gocron/v2 v2.15.0/go.mod h1:ZF70ZwEqz0OO4RBXE1sNxnANy/zvwLcattWEFsqpKig=
|
||||
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
|
||||
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.22.1 h1:40JcKH+bBNGFczGuoBYgX4I6m/i27HYW8P9FDk5PbgA=
|
||||
github.com/go-playground/validator/v10 v10.22.1/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/go-playground/validator/v10 v10.24.0 h1:KHQckvo8G6hlWnrPX4NJJ+aBfWNAE/HH+qdL2cBpCmg=
|
||||
github.com/go-playground/validator/v10 v10.24.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus=
|
||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
|
||||
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-json v0.10.4 h1:JSwxQzIqKfmFX1swYPpUThQZp/Ka4wzJdK0LWVytLPM=
|
||||
github.com/goccy/go-json v0.10.4/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
|
||||
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/gomodule/redigo v1.8.9 h1:Sl3u+2BI/kk+VEatbj0scLdrFhjPmbxOc1myhDP41ws=
|
||||
github.com/gomodule/redigo v1.8.9/go.mod h1:7ArFNvsTjH8GMMzB4uy1snslv2BwmginuMs06a1uzZE=
|
||||
github.com/gomodule/redigo v1.9.2 h1:HrutZBLhSIU8abiSfW8pj8mPhOyMYjZT/wcA4/L9L9s=
|
||||
github.com/gomodule/redigo v1.9.2/go.mod h1:KsU3hiK/Ay8U42qpaJk+kuNa3C+spxapWpM+ywhcgtw=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
@@ -84,8 +84,8 @@ github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IX
|
||||
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
|
||||
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM=
|
||||
github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY=
|
||||
github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
@@ -104,8 +104,8 @@ github.com/memcachier/mc/v3 v3.0.3 h1:qii+lDiPKi36O4Xg+HVKwHu6Oq+Gt17b+uEiA0Drwv
|
||||
github.com/memcachier/mc/v3 v3.0.3/go.mod h1:GzjocBahcXPxt2cmqzknrgqCOmMxiSzhVKPOe90Tpug=
|
||||
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
|
||||
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
|
||||
github.com/minio/minio-go/v7 v7.0.80 h1:2mdUHXEykRdY/BigLt3Iuu1otL0JTogT0Nmltg0wujk=
|
||||
github.com/minio/minio-go/v7 v7.0.80/go.mod h1:84gmIilaX4zcvAWWzJ5Z1WI5axN+hAbM5w25xf8xvC0=
|
||||
github.com/minio/minio-go/v7 v7.0.85 h1:9psTLS/NTvC3MWoyjhjXpwcKoNbkongaCSF3PNpSuXo=
|
||||
github.com/minio/minio-go/v7 v7.0.85/go.mod h1:57YXpvc5l3rjPdhqNrDsvVlY0qPI6UTk1bflAe+9doY=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
@@ -116,8 +116,8 @@ github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+W
|
||||
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
|
||||
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg=
|
||||
@@ -142,8 +142,8 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE=
|
||||
github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ=
|
||||
github.com/tealeg/xlsx v1.0.5 h1:+f8oFmvY8Gw1iUXzPk+kz+4GpbDZPK1FhPiQRd+ypgE=
|
||||
@@ -152,34 +152,33 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/urfave/cli/v2 v2.27.4 h1:o1owoI+02Eb+K107p27wEX9Bb8eqIoZCfLXloLUSWJ8=
|
||||
github.com/urfave/cli/v2 v2.27.4/go.mod h1:m4QzxcD2qpra4z7WhzEGn74WZLViBnMpb1ToCAKdGRQ=
|
||||
github.com/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w=
|
||||
github.com/urfave/cli/v2 v2.27.5/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ=
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
|
||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
|
||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
|
||||
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
|
||||
golang.org/x/arch v0.13.0 h1:KCkqVVV1kGg0X87TFysjCJ8MxtZEIU4Ja/yXGeoECdA=
|
||||
golang.org/x/arch v0.13.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
|
||||
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
|
||||
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 h1:yixxcjnhBmY0nkL253HFVIm0JsFHwrHdT3Yh6szTnfY=
|
||||
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
|
||||
golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
|
||||
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
|
||||
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
|
||||
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
|
||||
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
||||
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
|
||||
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||
google.golang.org/protobuf v1.36.2 h1:R8FeyR1/eLmkutZOM5CWghmo5itiG9z0ktFlTVLuTmU=
|
||||
google.golang.org/protobuf v1.36.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
|
||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
Generated
+2470
-1226
File diff suppressed because it is too large
Load Diff
+29
-23
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ezbookkeeping",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.0",
|
||||
"private": true,
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -15,52 +15,58 @@
|
||||
"serve": "cross-env NODE_ENV=development vite",
|
||||
"build": "cross-env NODE_ENV=production vite build",
|
||||
"serve:dist": "vite preview",
|
||||
"lint": "eslint . --fix"
|
||||
"lint": "tsc --noEmit && eslint . --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mdi/js": "^7.4.47",
|
||||
"@vuepic/vue-datepicker": "^10.0.0",
|
||||
"axios": "^1.7.7",
|
||||
"@vuepic/vue-datepicker": "^11.0.1",
|
||||
"axios": "^1.7.9",
|
||||
"cbor-js": "^0.1.0",
|
||||
"clipboard": "^2.0.11",
|
||||
"crypto-js": "^4.2.0",
|
||||
"dom7": "^4.0.6",
|
||||
"echarts": "^5.5.1",
|
||||
"echarts": "^5.6.0",
|
||||
"framework7": "^8.3.4",
|
||||
"framework7-icons": "^5.0.5",
|
||||
"framework7-vue": "^8.3.4",
|
||||
"leaflet": "^1.9.4",
|
||||
"line-awesome": "^1.3.0",
|
||||
"moment": "^2.30.1",
|
||||
"moment-timezone": "^0.5.46",
|
||||
"pinia": "^2.2.5",
|
||||
"moment-timezone": "^0.5.47",
|
||||
"pinia": "^2.3.1",
|
||||
"register-service-worker": "^1.7.2",
|
||||
"skeleton-elements": "^4.0.1",
|
||||
"swiper": "^10.2.0",
|
||||
"ua-parser-js": "^1.0.39",
|
||||
"vue": "^3.5.12",
|
||||
"vue": "^3.5.13",
|
||||
"vue-echarts": "^7.0.3",
|
||||
"vue-i18n": "^10.0.4",
|
||||
"vue-router": "^4.4.5",
|
||||
"vue-i18n": "^11.1.1",
|
||||
"vue-router": "^4.5.0",
|
||||
"vue3-perfect-scrollbar": "^2.0.0",
|
||||
"vuedraggable": "^4.1.0",
|
||||
"vuetify": "^3.7.3"
|
||||
"vuetify": "^3.7.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "^1.2.2",
|
||||
"@eslint/eslintrc": "^3.1.0",
|
||||
"@eslint/js": "^9.14.0",
|
||||
"@vitejs/plugin-vue": "^5.1.4",
|
||||
"@tsconfig/node22": "^22.0.0",
|
||||
"@types/cbor-js": "^0.1.1",
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"@types/git-rev-sync": "^2.0.2",
|
||||
"@types/node": "^22.12.0",
|
||||
"@types/ua-parser-js": "^0.7.39",
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"@vue/eslint-config-typescript": "^14.3.0",
|
||||
"@vue/tsconfig": "^0.7.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^9.14.0",
|
||||
"eslint-plugin-vue": "^9.30.0",
|
||||
"eslint": "^9.20.0",
|
||||
"eslint-plugin-vue": "^9.32.0",
|
||||
"git-rev-sync": "^3.0.2",
|
||||
"globals": "^15.11.0",
|
||||
"postcss-preset-env": "^10.0.9",
|
||||
"sass": "^1.80.6",
|
||||
"vite": "^5.4.10",
|
||||
"vite-plugin-pwa": "^0.20.5",
|
||||
"vite-plugin-vuetify": "^2.0.4"
|
||||
"postcss-preset-env": "^10.1.3",
|
||||
"sass": "^1.84.0",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.1.0",
|
||||
"vite-plugin-pwa": "^0.21.1",
|
||||
"vite-plugin-vuetify": "^2.1.0",
|
||||
"vue-tsc": "^2.2.0"
|
||||
},
|
||||
"browserslist": [
|
||||
"> 1%",
|
||||
|
||||
@@ -28,6 +28,9 @@ var (
|
||||
container: settings.Container,
|
||||
},
|
||||
ApiUsingDuplicateChecker: ApiUsingDuplicateChecker{
|
||||
ApiUsingConfig: ApiUsingConfig{
|
||||
container: settings.Container,
|
||||
},
|
||||
container: duplicatechecker.Container,
|
||||
},
|
||||
accounts: services.Accounts,
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/avatars"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/duplicatechecker"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
@@ -15,6 +16,7 @@ import (
|
||||
// AuthorizationsApi represents authorization api
|
||||
type AuthorizationsApi struct {
|
||||
ApiUsingConfig
|
||||
ApiUsingDuplicateChecker
|
||||
ApiWithUserInfo
|
||||
users *services.UserService
|
||||
tokens *services.TokenService
|
||||
@@ -27,6 +29,12 @@ var (
|
||||
ApiUsingConfig: ApiUsingConfig{
|
||||
container: settings.Container,
|
||||
},
|
||||
ApiUsingDuplicateChecker: ApiUsingDuplicateChecker{
|
||||
ApiUsingConfig: ApiUsingConfig{
|
||||
container: settings.Container,
|
||||
},
|
||||
container: duplicatechecker.Container,
|
||||
},
|
||||
ApiWithUserInfo: ApiWithUserInfo{
|
||||
ApiUsingConfig: ApiUsingConfig{
|
||||
container: settings.Container,
|
||||
@@ -51,7 +59,23 @@ func (a *AuthorizationsApi) AuthorizeHandler(c *core.WebContext) (any, *errs.Err
|
||||
return nil, errs.ErrLoginNameOrPasswordInvalid
|
||||
}
|
||||
|
||||
user, err := a.users.GetUserByUsernameOrEmailAndPassword(c, credential.LoginName, credential.Password)
|
||||
err = a.CheckFailureCount(c, 0)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[authorizations.AuthorizeHandler] cannot login for user \"%s\", because %s", credential.LoginName, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrFailureCountLimitReached)
|
||||
}
|
||||
|
||||
user, uid, err := a.users.GetUserByUsernameOrEmailAndPassword(c, credential.LoginName, credential.Password)
|
||||
|
||||
if errs.IsCustomError(err) {
|
||||
failureCheckErr := a.CheckAndIncreaseFailureCount(c, uid)
|
||||
|
||||
if failureCheckErr != nil {
|
||||
log.Warnf(c, "[authorizations.AuthorizeHandler] cannot login for user \"%s\", because %s", credential.LoginName, failureCheckErr.Error())
|
||||
return nil, errs.Or(failureCheckErr, errs.ErrFailureCountLimitReached)
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[authorizations.AuthorizeHandler] login failed for user \"%s\", because %s", credential.LoginName, err.Error())
|
||||
@@ -133,6 +157,13 @@ func (a *AuthorizationsApi) TwoFactorAuthorizeHandler(c *core.WebContext) (any,
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
err = a.CheckFailureCount(c, uid)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[authorizations.TwoFactorAuthorizeHandler] cannot auth for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrFailureCountLimitReached)
|
||||
}
|
||||
|
||||
twoFactorSetting, err := a.twoFactorAuthorizations.GetUserTwoFactorSettingByUid(c, uid)
|
||||
|
||||
if err != nil {
|
||||
@@ -142,6 +173,14 @@ func (a *AuthorizationsApi) TwoFactorAuthorizeHandler(c *core.WebContext) (any,
|
||||
|
||||
if !totp.Validate(credential.Passcode, twoFactorSetting.Secret) {
|
||||
log.Warnf(c, "[authorizations.TwoFactorAuthorizeHandler] passcode is invalid for user \"uid:%d\"", uid)
|
||||
|
||||
err = a.CheckAndIncreaseFailureCount(c, uid)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[authorizations.TwoFactorAuthorizeHandler] cannot auth for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrFailureCountLimitReached)
|
||||
}
|
||||
|
||||
return nil, errs.ErrPasscodeInvalid
|
||||
}
|
||||
|
||||
@@ -196,6 +235,13 @@ func (a *AuthorizationsApi) TwoFactorAuthorizeByRecoveryCodeHandler(c *core.WebC
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
err = a.CheckFailureCount(c, uid)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] cannot auth for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrFailureCountLimitReached)
|
||||
}
|
||||
|
||||
enableTwoFactor, err := a.twoFactorAuthorizations.ExistsTwoFactorSetting(c, uid)
|
||||
|
||||
if err != nil {
|
||||
@@ -226,6 +272,15 @@ func (a *AuthorizationsApi) TwoFactorAuthorizeByRecoveryCodeHandler(c *core.WebC
|
||||
|
||||
err = a.twoFactorAuthorizations.GetAndUseUserTwoFactorRecoveryCode(c, uid, credential.RecoveryCode, user.Salt)
|
||||
|
||||
if errs.IsCustomError(err) {
|
||||
failureCheckErr := a.CheckAndIncreaseFailureCount(c, uid)
|
||||
|
||||
if failureCheckErr != nil {
|
||||
log.Warnf(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] cannot auth for user \"uid:%d\", because %s", uid, failureCheckErr.Error())
|
||||
return nil, errs.Or(failureCheckErr, errs.ErrFailureCountLimitReached)
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] failed to get two-factor recovery code for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrTwoFactorRecoveryCodeNotExist)
|
||||
|
||||
@@ -5,9 +5,13 @@ import (
|
||||
"sort"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/avatars"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/duplicatechecker"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||
)
|
||||
|
||||
const internalTransactionPictureUrlFormat = "%spictures/%d.%s"
|
||||
@@ -100,6 +104,7 @@ func (a *ApiUsingConfig) GetAfterOpenNotificationContent(userLanguage string, cl
|
||||
|
||||
// ApiUsingDuplicateChecker represents an api that need to use duplicate checker
|
||||
type ApiUsingDuplicateChecker struct {
|
||||
ApiUsingConfig
|
||||
container *duplicatechecker.DuplicateCheckerContainer
|
||||
}
|
||||
|
||||
@@ -113,6 +118,67 @@ func (a *ApiUsingDuplicateChecker) SetSubmissionRemark(checkerType duplicatechec
|
||||
a.container.SetSubmissionRemark(checkerType, uid, identification, remark)
|
||||
}
|
||||
|
||||
// CheckFailureCount returns whether the failure count of the specified IP and user has reached the limit and increases the failure count
|
||||
func (a *ApiUsingDuplicateChecker) CheckFailureCount(c *core.WebContext, uid int64) error {
|
||||
if a.CurrentConfig().MaxFailuresPerIpPerMinute > 0 {
|
||||
clientIp := c.ClientIP()
|
||||
ipFailureCount := a.container.GetFailureCount(clientIp)
|
||||
|
||||
if ipFailureCount >= a.CurrentConfig().MaxFailuresPerIpPerMinute {
|
||||
log.Warnf(c, "[base.CheckFailureCount] operation failure via IP \"%s\", current failure count: %d reached the limit", clientIp, ipFailureCount)
|
||||
return errs.ErrFailureCountLimitReached
|
||||
}
|
||||
}
|
||||
|
||||
if a.CurrentConfig().MaxFailuresPerUserPerMinute > 0 && uid > 0 {
|
||||
uidFailureCount := a.container.GetFailureCount(utils.Int64ToString(uid))
|
||||
|
||||
if uidFailureCount >= a.CurrentConfig().MaxFailuresPerUserPerMinute {
|
||||
log.Warnf(c, "[base.CheckFailureCount] operation failure via uid \"%d\", current failure count: %d reached the limit", uid, uidFailureCount)
|
||||
return errs.ErrFailureCountLimitReached
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckAndIncreaseFailureCount returns whether the failure count of the specified IP and user has reached the limit and increases the failure count
|
||||
func (a *ApiUsingDuplicateChecker) CheckAndIncreaseFailureCount(c *core.WebContext, uid int64) error {
|
||||
clientIp := c.ClientIP()
|
||||
ipFailureCount := uint32(0)
|
||||
uidFailureCount := uint32(0)
|
||||
|
||||
if a.CurrentConfig().MaxFailuresPerIpPerMinute > 0 {
|
||||
ipFailureCount = a.container.GetFailureCount(clientIp)
|
||||
}
|
||||
|
||||
if a.CurrentConfig().MaxFailuresPerUserPerMinute > 0 && uid > 0 {
|
||||
uidFailureCount = a.container.GetFailureCount(utils.Int64ToString(uid))
|
||||
}
|
||||
|
||||
if a.CurrentConfig().MaxFailuresPerIpPerMinute > 0 && ipFailureCount < a.CurrentConfig().MaxFailuresPerIpPerMinute {
|
||||
log.Warnf(c, "[base.CheckAndIncreaseFailureCount] operation failure via IP \"%s\", previous failure count: %d", clientIp, ipFailureCount)
|
||||
a.container.IncreaseFailureCount(clientIp)
|
||||
}
|
||||
|
||||
if a.CurrentConfig().MaxFailuresPerUserPerMinute > 0 && uid > 0 && uidFailureCount < a.CurrentConfig().MaxFailuresPerUserPerMinute {
|
||||
log.Warnf(c, "[base.CheckAndIncreaseFailureCount] operation failure via uid \"%d\", previous failure count: %d", uid, uidFailureCount)
|
||||
a.container.IncreaseFailureCount(utils.Int64ToString(uid))
|
||||
}
|
||||
|
||||
if a.CurrentConfig().MaxFailuresPerIpPerMinute > 0 && ipFailureCount >= a.CurrentConfig().MaxFailuresPerIpPerMinute {
|
||||
log.Warnf(c, "[base.CheckAndIncreaseFailureCount] operation failure via IP \"%s\", current failure count: %d reached the limit", clientIp, ipFailureCount)
|
||||
return errs.ErrFailureCountLimitReached
|
||||
}
|
||||
|
||||
if a.CurrentConfig().MaxFailuresPerUserPerMinute > 0 && uid > 0 && uidFailureCount >= a.CurrentConfig().MaxFailuresPerUserPerMinute {
|
||||
log.Warnf(c, "[base.CheckAndIncreaseFailureCount] operation failure via uid \"%d\", current failure count: %d reached the limit", uid, uidFailureCount)
|
||||
return errs.ErrFailureCountLimitReached
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ApiUsingAvatarProvider represents an api that need to use avatar provider
|
||||
type ApiUsingAvatarProvider struct {
|
||||
container *avatars.AvatarProviderContainer
|
||||
|
||||
@@ -66,7 +66,12 @@ func (a *ExchangeRatesApi) LatestExchangeRateHandler(c *core.WebContext) (any, *
|
||||
|
||||
for i := 0; i < len(requests); i++ {
|
||||
req := requests[i]
|
||||
req.Header.Set("User-Agent", fmt.Sprintf("ezBookkeeping/%s ", settings.Version))
|
||||
|
||||
if len(req.Header.Values("User-Agent")) < 1 {
|
||||
req.Header.Set("User-Agent", fmt.Sprintf("ezBookkeeping/%s", settings.Version))
|
||||
} else if req.Header.Get("User-Agent") == "" {
|
||||
req.Header.Del("User-Agent")
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ func TestExchangeRatesApiLatestExchangeRateHandler_ReserveBankOfAustraliaDataSou
|
||||
|
||||
assert.Equal(t, "AUD", exchangeRateResponse.BaseCurrency)
|
||||
|
||||
supportedCurrencyCodes := []string{"CAD", "CNY", "EUR", "GBP", "HKD", "IDR", "INR", "JPY", "KRW",
|
||||
supportedCurrencyCodes := []string{"CAD", "CHF", "CNY", "EUR", "GBP", "HKD", "IDR", "INR", "JPY", "KRW",
|
||||
"MYR", "NZD", "PHP", "SGD", "THB", "TWD", "USD", "VND"}
|
||||
|
||||
checkExchangeRatesHaveSpecifiedCurrencies(t, exchangeRateResponse.BaseCurrency, supportedCurrencyCodes, exchangeRateResponse.ExchangeRates)
|
||||
|
||||
@@ -29,6 +29,9 @@ var (
|
||||
container: settings.Container,
|
||||
},
|
||||
ApiUsingDuplicateChecker: ApiUsingDuplicateChecker{
|
||||
ApiUsingConfig: ApiUsingConfig{
|
||||
container: settings.Container,
|
||||
},
|
||||
container: duplicatechecker.Container,
|
||||
},
|
||||
categories: services.TransactionCategories,
|
||||
|
||||
@@ -26,6 +26,9 @@ var (
|
||||
container: settings.Container,
|
||||
},
|
||||
ApiUsingDuplicateChecker: ApiUsingDuplicateChecker{
|
||||
ApiUsingConfig: ApiUsingConfig{
|
||||
container: settings.Container,
|
||||
},
|
||||
container: duplicatechecker.Container,
|
||||
},
|
||||
users: services.Users,
|
||||
|
||||
@@ -31,6 +31,9 @@ var (
|
||||
container: settings.Container,
|
||||
},
|
||||
ApiUsingDuplicateChecker: ApiUsingDuplicateChecker{
|
||||
ApiUsingConfig: ApiUsingConfig{
|
||||
container: settings.Container,
|
||||
},
|
||||
container: duplicatechecker.Container,
|
||||
},
|
||||
templates: services.TransactionTemplates,
|
||||
@@ -156,7 +159,12 @@ func (a *TransactionTemplatesApi) TemplateCreateHandler(c *core.WebContext) (any
|
||||
}
|
||||
|
||||
serverUtcOffset := utils.GetServerTimezoneOffsetMinutes()
|
||||
template := a.createNewTemplateModel(uid, &templateCreateReq, maxOrderId+1)
|
||||
template, err := a.createNewTemplateModel(uid, &templateCreateReq, maxOrderId+1)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transaction_templates.TemplateCreateHandler] failed to create new template for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
if a.CurrentConfig().EnableDuplicateSubmissionsCheck && templateCreateReq.ClientSessionId != "" {
|
||||
found, remark := a.GetSubmissionRemark(duplicatechecker.DUPLICATE_CHECKER_TYPE_NEW_TEMPLATE, uid, templateCreateReq.ClientSessionId)
|
||||
@@ -260,6 +268,34 @@ func (a *TransactionTemplatesApi) TemplateModifyHandler(c *core.WebContext) (any
|
||||
newTemplate.ScheduledFrequency = a.getOrderedFrequencyValues(*templateModifyReq.ScheduledFrequency)
|
||||
newTemplate.ScheduledAt = a.getUTCScheduledAt(*templateModifyReq.ScheduledTimezoneUtcOffset)
|
||||
newTemplate.ScheduledTimezoneUtcOffset = *templateModifyReq.ScheduledTimezoneUtcOffset
|
||||
|
||||
if templateModifyReq.ScheduledStartDate != nil {
|
||||
startTime, err := utils.ParseFromLongDateFirstTime(*templateModifyReq.ScheduledStartDate, *templateModifyReq.ScheduledTimezoneUtcOffset)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transaction_templates.TemplateModifyHandler] failed to parse scheduled start date for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
startUnixTime := startTime.Unix()
|
||||
newTemplate.ScheduledStartTime = &startUnixTime
|
||||
}
|
||||
|
||||
if templateModifyReq.ScheduledEndDate != nil {
|
||||
endTime, err := utils.ParseFromLongDateLastTime(*templateModifyReq.ScheduledEndDate, *templateModifyReq.ScheduledTimezoneUtcOffset)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transaction_templates.TemplateModifyHandler] failed to parse scheduled end date for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
endUnixTime := endTime.Unix()
|
||||
newTemplate.ScheduledEndTime = &endUnixTime
|
||||
}
|
||||
|
||||
if newTemplate.ScheduledStartTime != nil && newTemplate.ScheduledEndTime != nil && *newTemplate.ScheduledStartTime > *newTemplate.ScheduledEndTime {
|
||||
return nil, errs.ErrScheduledTransactionTemplateStartDataLaterThanEndDate
|
||||
}
|
||||
}
|
||||
|
||||
if newTemplate.Name == template.Name &&
|
||||
@@ -277,6 +313,8 @@ func (a *TransactionTemplatesApi) TemplateModifyHandler(c *core.WebContext) (any
|
||||
} else if template.TemplateType == models.TRANSACTION_TEMPLATE_TYPE_SCHEDULE {
|
||||
if newTemplate.ScheduledFrequencyType == template.ScheduledFrequencyType &&
|
||||
newTemplate.ScheduledFrequency == template.ScheduledFrequency &&
|
||||
newTemplate.ScheduledStartTime == template.ScheduledStartTime &&
|
||||
newTemplate.ScheduledEndTime == template.ScheduledEndTime &&
|
||||
newTemplate.ScheduledAt == template.ScheduledAt &&
|
||||
newTemplate.ScheduledTimezoneUtcOffset == template.ScheduledTimezoneUtcOffset {
|
||||
return nil, errs.ErrNothingWillBeUpdated
|
||||
@@ -419,7 +457,7 @@ func (a *TransactionTemplatesApi) TemplateDeleteHandler(c *core.WebContext) (any
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (a *TransactionTemplatesApi) createNewTemplateModel(uid int64, templateCreateReq *models.TransactionTemplateCreateRequest, order int32) *models.TransactionTemplate {
|
||||
func (a *TransactionTemplatesApi) createNewTemplateModel(uid int64, templateCreateReq *models.TransactionTemplateCreateRequest, order int32) (*models.TransactionTemplate, error) {
|
||||
template := &models.TransactionTemplate{
|
||||
Uid: uid,
|
||||
TemplateType: templateCreateReq.TemplateType,
|
||||
@@ -441,9 +479,35 @@ func (a *TransactionTemplatesApi) createNewTemplateModel(uid int64, templateCrea
|
||||
template.ScheduledFrequency = a.getOrderedFrequencyValues(*templateCreateReq.ScheduledFrequency)
|
||||
template.ScheduledAt = a.getUTCScheduledAt(*templateCreateReq.ScheduledTimezoneUtcOffset)
|
||||
template.ScheduledTimezoneUtcOffset = *templateCreateReq.ScheduledTimezoneUtcOffset
|
||||
|
||||
if templateCreateReq.ScheduledStartDate != nil {
|
||||
startTime, err := utils.ParseFromLongDateFirstTime(*templateCreateReq.ScheduledStartDate, *templateCreateReq.ScheduledTimezoneUtcOffset)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return template
|
||||
startUnixTime := startTime.Unix()
|
||||
template.ScheduledStartTime = &startUnixTime
|
||||
}
|
||||
|
||||
if templateCreateReq.ScheduledEndDate != nil {
|
||||
endTime, err := utils.ParseFromLongDateLastTime(*templateCreateReq.ScheduledEndDate, *templateCreateReq.ScheduledTimezoneUtcOffset)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
endUnixTime := endTime.Unix()
|
||||
template.ScheduledEndTime = &endUnixTime
|
||||
}
|
||||
|
||||
if template.ScheduledStartTime != nil && template.ScheduledEndTime != nil && *template.ScheduledStartTime > *template.ScheduledEndTime {
|
||||
return nil, errs.ErrScheduledTransactionTemplateStartDataLaterThanEndDate
|
||||
}
|
||||
}
|
||||
|
||||
return template, nil
|
||||
}
|
||||
|
||||
func (a *TransactionTemplatesApi) getUTCScheduledAt(scheduledTimezoneUtcOffset int16) int16 {
|
||||
|
||||
+164
-3
@@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -8,6 +9,8 @@ import (
|
||||
orderedmap "github.com/wk8/go-ordered-map/v2"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters"
|
||||
baseconverters "github.com/mayswind/ezbookkeeping/pkg/converters/base"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/duplicatechecker"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
@@ -40,6 +43,9 @@ var (
|
||||
container: settings.Container,
|
||||
},
|
||||
ApiUsingDuplicateChecker: ApiUsingDuplicateChecker{
|
||||
ApiUsingConfig: ApiUsingConfig{
|
||||
container: settings.Container,
|
||||
},
|
||||
container: duplicatechecker.Container,
|
||||
},
|
||||
transactions: services.Transactions,
|
||||
@@ -382,10 +388,10 @@ func (a *TransactionsApi) TransactionStatisticsTrendsHandler(c *core.WebContext)
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
statisticTrendsResp := make(models.TransactionStatisticTrendsItemSlice, 0, len(allMonthlyTotalAmounts))
|
||||
statisticTrendsResp := make(models.TransactionStatisticTrendsResponseItemSlice, 0, len(allMonthlyTotalAmounts))
|
||||
|
||||
for yearMonth, monthlyTotalAmounts := range allMonthlyTotalAmounts {
|
||||
monthlyStatisticResp := &models.TransactionStatisticTrendsItem{
|
||||
monthlyStatisticResp := &models.TransactionStatisticTrendsResponseItem{
|
||||
Year: yearMonth / 100,
|
||||
Month: yearMonth % 100,
|
||||
Items: make([]*models.TransactionStatisticResponseItem, len(monthlyTotalAmounts)),
|
||||
@@ -1030,6 +1036,83 @@ func (a *TransactionsApi) TransactionDeleteHandler(c *core.WebContext) (any, *er
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// TransactionParseImportDsvFileDataHandler returns the parsed file data by request parameters for current user
|
||||
func (a *TransactionsApi) TransactionParseImportDsvFileDataHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
uid := c.GetCurrentUid()
|
||||
form, err := c.MultipartForm()
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.TransactionParseImportDsvFileDataHandler] failed to get multi-part form data for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.ErrParameterInvalid
|
||||
}
|
||||
|
||||
fileTypes := form.Value["fileType"]
|
||||
|
||||
if len(fileTypes) < 1 || fileTypes[0] == "" {
|
||||
return nil, errs.ErrImportFileTypeIsEmpty
|
||||
}
|
||||
|
||||
fileType := fileTypes[0]
|
||||
|
||||
if !converters.IsCustomDelimiterSeparatedValuesFileType(fileType) {
|
||||
return nil, errs.Or(err, errs.ErrImportFileTypeNotSupported)
|
||||
}
|
||||
|
||||
fileEncodings := form.Value["fileEncoding"]
|
||||
|
||||
if len(fileEncodings) < 1 || fileEncodings[0] == "" {
|
||||
return nil, errs.ErrImportFileEncodingIsEmpty
|
||||
}
|
||||
|
||||
fileEncoding := fileEncodings[0]
|
||||
dataParser, err := converters.CreateNewDelimiterSeparatedValuesDataParser(fileType, fileEncoding)
|
||||
|
||||
if err != nil {
|
||||
return nil, errs.Or(err, errs.ErrImportFileTypeNotSupported)
|
||||
}
|
||||
|
||||
importFiles := form.File["file"]
|
||||
|
||||
if len(importFiles) < 1 {
|
||||
log.Warnf(c, "[transactions.TransactionParseImportDsvFileDataHandler] there is no import file in request for user \"uid:%d\"", uid)
|
||||
return nil, errs.ErrNoFilesUpload
|
||||
}
|
||||
|
||||
if importFiles[0].Size < 1 {
|
||||
log.Warnf(c, "[transactions.TransactionParseImportDsvFileDataHandler] the size of import file in request is zero for user \"uid:%d\"", uid)
|
||||
return nil, errs.ErrUploadedFileEmpty
|
||||
}
|
||||
|
||||
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)
|
||||
return nil, errs.ErrExceedMaxUploadFileSize
|
||||
}
|
||||
|
||||
importFile, err := importFiles[0].Open()
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.TransactionParseImportDsvFileDataHandler] failed to get import file from request for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.ErrOperationFailed
|
||||
}
|
||||
|
||||
defer importFile.Close()
|
||||
fileData, err := io.ReadAll(importFile)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.TransactionParseImportDsvFileDataHandler] failed to read import file data for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
allLines, err := dataParser.ParseDsvFileLines(c, fileData)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.TransactionParseImportDsvFileDataHandler] failed to parse import file data for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
return allLines, nil
|
||||
}
|
||||
|
||||
// TransactionParseImportFileHandler returns the parsed transaction data by request parameters for current user
|
||||
func (a *TransactionsApi) TransactionParseImportFileHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
uid := c.GetCurrentUid()
|
||||
@@ -1054,7 +1137,84 @@ func (a *TransactionsApi) TransactionParseImportFileHandler(c *core.WebContext)
|
||||
}
|
||||
|
||||
fileType := fileTypes[0]
|
||||
dataImporter, err := converters.GetTransactionDataImporter(fileType)
|
||||
|
||||
var dataImporter baseconverters.TransactionDataImporter
|
||||
|
||||
if converters.IsCustomDelimiterSeparatedValuesFileType(fileType) {
|
||||
fileEncodings := form.Value["fileEncoding"]
|
||||
|
||||
if len(fileEncodings) < 1 || fileEncodings[0] == "" {
|
||||
return nil, errs.ErrImportFileEncodingIsEmpty
|
||||
}
|
||||
|
||||
fileEncoding := fileEncodings[0]
|
||||
|
||||
columnMappings := form.Value["columnMapping"]
|
||||
|
||||
if len(columnMappings) < 1 || columnMappings[0] == "" {
|
||||
return nil, errs.ErrImportFileColumnMappingInvalid
|
||||
}
|
||||
|
||||
var columnIndexMapping = map[datatable.TransactionDataTableColumn]int{}
|
||||
err = json.Unmarshal([]byte(columnMappings[0]), &columnIndexMapping)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.TransactionParseImportFileHandler] failed to parse column mapping for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.ErrImportFileColumnMappingInvalid
|
||||
}
|
||||
|
||||
transactionTypeMappings := form.Value["transactionTypeMapping"]
|
||||
|
||||
if len(transactionTypeMappings) < 1 || transactionTypeMappings[0] == "" {
|
||||
return nil, errs.ErrImportFileTransactionTypeMappingInvalid
|
||||
}
|
||||
|
||||
var transactionTypeNameMapping = map[string]models.TransactionType{}
|
||||
err = json.Unmarshal([]byte(transactionTypeMappings[0]), &transactionTypeNameMapping)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.TransactionParseImportFileHandler] failed to parse transaction type mapping for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.ErrImportFileTransactionTypeMappingInvalid
|
||||
}
|
||||
|
||||
hasHeaderLines := form.Value["hasHeaderLine"]
|
||||
hasHeaderLine := false
|
||||
|
||||
if len(hasHeaderLines) > 0 {
|
||||
hasHeaderLine = hasHeaderLines[0] == "true"
|
||||
}
|
||||
|
||||
timeFormats := form.Value["timeFormat"]
|
||||
|
||||
if len(timeFormats) < 1 || timeFormats[0] == "" {
|
||||
return nil, errs.ErrImportFileTransactionTimeFormatInvalid
|
||||
}
|
||||
|
||||
timezoneFormats := form.Value["timezoneFormat"]
|
||||
timezoneFormat := ""
|
||||
|
||||
if len(timezoneFormats) > 0 {
|
||||
timezoneFormat = timezoneFormats[0]
|
||||
}
|
||||
|
||||
geoLocationSeparators := form.Value["geoSeparator"]
|
||||
geoLocationSeparator := ""
|
||||
|
||||
if len(geoLocationSeparators) > 0 {
|
||||
geoLocationSeparator = geoLocationSeparators[0]
|
||||
}
|
||||
|
||||
transactionTagSeparators := form.Value["tagSeparator"]
|
||||
transactionTagSeparator := ""
|
||||
|
||||
if len(transactionTagSeparators) > 0 {
|
||||
transactionTagSeparator = transactionTagSeparators[0]
|
||||
}
|
||||
|
||||
dataImporter, err = converters.CreateNewDelimiterSeparatedValuesDataImporter(fileType, fileEncoding, columnIndexMapping, transactionTypeNameMapping, hasHeaderLine, timeFormats[0], timezoneFormat, geoLocationSeparator, transactionTagSeparator)
|
||||
} else {
|
||||
dataImporter, err = converters.GetTransactionDataImporter(fileType)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, errs.Or(err, errs.ErrImportFileTypeNotSupported)
|
||||
@@ -1084,6 +1244,7 @@ func (a *TransactionsApi) TransactionParseImportFileHandler(c *core.WebContext)
|
||||
return nil, errs.ErrOperationFailed
|
||||
}
|
||||
|
||||
defer importFile.Close()
|
||||
fileData, err := io.ReadAll(importFile)
|
||||
|
||||
if err != nil {
|
||||
|
||||
@@ -418,7 +418,7 @@ func (c *DataTableTransactionDataImporter) ParseImportedData(ctx core.Context, u
|
||||
geoLongitude := float64(0)
|
||||
geoLatitude := float64(0)
|
||||
|
||||
if dataTable.HasColumn(TRANSACTION_DATA_TABLE_GEOGRAPHIC_LOCATION) {
|
||||
if dataTable.HasColumn(TRANSACTION_DATA_TABLE_GEOGRAPHIC_LOCATION) && c.geoLocationSeparator != "" {
|
||||
geoLocationItems := strings.Split(dataRow.GetData(TRANSACTION_DATA_TABLE_GEOGRAPHIC_LOCATION), c.geoLocationSeparator)
|
||||
|
||||
if len(geoLocationItems) == 2 {
|
||||
@@ -442,7 +442,13 @@ func (c *DataTableTransactionDataImporter) ParseImportedData(ctx core.Context, u
|
||||
var tagNames []string
|
||||
|
||||
if dataTable.HasColumn(TRANSACTION_DATA_TABLE_TAGS) {
|
||||
tagNameItems := strings.Split(dataRow.GetData(TRANSACTION_DATA_TABLE_TAGS), c.transactionTagSeparator)
|
||||
var tagNameItems []string
|
||||
|
||||
if c.transactionTagSeparator != "" {
|
||||
tagNameItems = strings.Split(dataRow.GetData(TRANSACTION_DATA_TABLE_TAGS), c.transactionTagSeparator)
|
||||
} else {
|
||||
tagNameItems = append(tagNameItems, dataRow.GetData(TRANSACTION_DATA_TABLE_TAGS))
|
||||
}
|
||||
|
||||
for i := 0; i < len(tagNameItems); i++ {
|
||||
tagName := tagNameItems[i]
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
package dsv
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/csv"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/text/encoding"
|
||||
"golang.org/x/text/encoding/charmap"
|
||||
"golang.org/x/text/encoding/japanese"
|
||||
"golang.org/x/text/encoding/korean"
|
||||
"golang.org/x/text/encoding/simplifiedchinese"
|
||||
"golang.org/x/text/encoding/traditionalchinese"
|
||||
"golang.org/x/text/encoding/unicode"
|
||||
"golang.org/x/text/transform"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/base"
|
||||
csvconverter "github.com/mayswind/ezbookkeeping/pkg/converters/csv"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||
)
|
||||
|
||||
var supportedFileTypeSeparators = map[string]rune{
|
||||
"custom_csv": ',',
|
||||
"custom_tsv": '\t',
|
||||
}
|
||||
|
||||
var supportedFileEncodings = map[string]encoding.Encoding{
|
||||
"utf-8": unicode.UTF8, // UTF-8
|
||||
"utf-8-bom": unicode.UTF8BOM, // UTF-8 with BOM
|
||||
"utf-16le": unicode.UTF16(unicode.LittleEndian, unicode.IgnoreBOM), // UTF-16 Little Endian
|
||||
"utf-16be": unicode.UTF16(unicode.BigEndian, unicode.IgnoreBOM), // UTF-16 Big Endian
|
||||
"cp437": charmap.CodePage437, // OEM United States (CP-437)
|
||||
"cp863": charmap.CodePage863, // OEM Canadian French (CP-863)
|
||||
"cp037": charmap.CodePage037, // IBM EBCDIC US/Canada (CP-037)
|
||||
"cp1047": charmap.CodePage1047, // IBM EBCDIC Open Systems (CP-1047)
|
||||
"cp1140": charmap.CodePage1140, // IBM EBCDIC US/Canada with Euro (CP-1140)
|
||||
"iso-8859-1": charmap.ISO8859_1, // Western European (ISO-8859-1)
|
||||
"cp850": charmap.CodePage850, // Western European (CP-850)
|
||||
"cp858": charmap.CodePage858, // Western European with Euro (CP-858)
|
||||
"windows-1252": charmap.Windows1252, // Western European (Windows-1252)
|
||||
"iso-8859-15": charmap.ISO8859_15, // Western European (ISO-8859-15)
|
||||
"iso-8859-4": charmap.ISO8859_4, // North European (ISO-8859-4)
|
||||
"iso-8859-10": charmap.ISO8859_10, // North European (ISO-8859-10)
|
||||
"cp865": charmap.CodePage865, // North European (CP-865)
|
||||
"iso-8859-2": charmap.ISO8859_2, // Central European (ISO-8859-2)
|
||||
"cp852": charmap.CodePage852, // Central European (CP-852)
|
||||
"windows-1250": charmap.Windows1250, // Central European (Windows-1250)
|
||||
"iso-8859-14": charmap.ISO8859_14, // Celtic (ISO-8859-14)
|
||||
"iso-8859-3": charmap.ISO8859_3, // South European (ISO-8859-3)
|
||||
"cp860": charmap.CodePage860, // Portuguese (CP-860)
|
||||
"iso-8859-7": charmap.ISO8859_7, // Greek (ISO-8859-7)
|
||||
"windows-1253": charmap.Windows1253, // Greek (Windows-1253)
|
||||
"iso-8859-9": charmap.ISO8859_9, // Turkish (ISO-8859-9)
|
||||
"windows-1254": charmap.Windows1254, // Turkish (Windows-1254)
|
||||
"iso-8859-13": charmap.ISO8859_13, // Baltic (ISO-8859-13)
|
||||
"windows-1257": charmap.Windows1257, // Baltic (Windows-1257)
|
||||
"iso-8859-16": charmap.ISO8859_16, // South-Eastern European (ISO-8859-16)
|
||||
"iso-8859-5": charmap.ISO8859_5, // Cyrillic (ISO-8859-5)
|
||||
"cp855": charmap.CodePage855, // Cyrillic (CP-855)
|
||||
"cp866": charmap.CodePage866, // Cyrillic (CP-866)
|
||||
"windows-1251": charmap.Windows1251, // Cyrillic (Windows-1251)
|
||||
"koi8r": charmap.KOI8R, // Cyrillic (KOI8-R)
|
||||
"koi8u": charmap.KOI8U, // Cyrillic (KOI8-U)
|
||||
"iso-8859-6": charmap.ISO8859_6, // Arabic (ISO-8859-6)
|
||||
"windows-1256": charmap.Windows1256, // Arabic (Windows-1256)
|
||||
"iso-8859-8": charmap.ISO8859_8, // Hebrew (ISO-8859-8)
|
||||
"cp862": charmap.CodePage862, // Hebrew (CP-862)
|
||||
"windows-1255": charmap.Windows1255, // Hebrew (Windows-1255)
|
||||
"windows-874": charmap.Windows874, // Thai (Windows-874)
|
||||
"windows-1258": charmap.Windows1258, // Vietnamese (Windows-1258)
|
||||
"gb18030": simplifiedchinese.GB18030, // Chinese (Simplified, GB18030)
|
||||
"gbk": simplifiedchinese.GBK, // Chinese (Simplified, GBK)
|
||||
"big5": traditionalchinese.Big5, // Chinese (Traditional, Big5)
|
||||
"euc-kr": korean.EUCKR, // Korean (EUC-KR)
|
||||
"euc-jp": japanese.EUCJP, // Japanese (EUC-JP)
|
||||
"iso-2022-jp": japanese.ISO2022JP, // Japanese (ISO-2022-JP)
|
||||
"shift_jis": japanese.ShiftJIS, // Japanese (Shift JIS)
|
||||
}
|
||||
|
||||
var customTransactionTypeNameMapping = map[models.TransactionType]string{
|
||||
models.TRANSACTION_TYPE_MODIFY_BALANCE: utils.IntToString(int(models.TRANSACTION_TYPE_MODIFY_BALANCE)),
|
||||
models.TRANSACTION_TYPE_INCOME: utils.IntToString(int(models.TRANSACTION_TYPE_INCOME)),
|
||||
models.TRANSACTION_TYPE_EXPENSE: utils.IntToString(int(models.TRANSACTION_TYPE_EXPENSE)),
|
||||
models.TRANSACTION_TYPE_TRANSFER: utils.IntToString(int(models.TRANSACTION_TYPE_TRANSFER)),
|
||||
}
|
||||
|
||||
type CustomTransactionDataDsvFileParser interface {
|
||||
ParseDsvFileLines(ctx core.Context, data []byte) ([][]string, error)
|
||||
}
|
||||
|
||||
// customTransactionDataDsvFileImporter defines the structure of custom dsv importer for transaction data
|
||||
type customTransactionDataDsvFileImporter struct {
|
||||
fileEncoding encoding.Encoding
|
||||
separator rune
|
||||
columnIndexMapping map[datatable.TransactionDataTableColumn]int
|
||||
transactionTypeNameMapping map[string]models.TransactionType
|
||||
hasHeaderLine bool
|
||||
timeFormat string
|
||||
timezoneFormat string
|
||||
geoLocationSeparator string
|
||||
transactionTagSeparator string
|
||||
}
|
||||
|
||||
// ParseDsvFileLines returns the parsed file lines for specified the dsv file data
|
||||
func (c *customTransactionDataDsvFileImporter) ParseDsvFileLines(ctx core.Context, data []byte) ([][]string, error) {
|
||||
reader := transform.NewReader(bytes.NewReader(data), c.fileEncoding.NewDecoder())
|
||||
csvReader := csv.NewReader(reader)
|
||||
csvReader.Comma = c.separator
|
||||
csvReader.FieldsPerRecord = -1
|
||||
|
||||
allLines := make([][]string, 0)
|
||||
|
||||
for {
|
||||
items, err := csvReader.Read()
|
||||
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(ctx, "[custom_transaction_data_dsv_file_importer.ParseDsvFileLines] cannot parse dsv data, because %s", err.Error())
|
||||
return nil, errs.ErrInvalidCSVFile
|
||||
}
|
||||
|
||||
if len(items) == 1 && items[0] == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
for index := range items {
|
||||
items[index] = strings.Trim(items[index], " ")
|
||||
}
|
||||
|
||||
allLines = append(allLines, items)
|
||||
}
|
||||
|
||||
return allLines, nil
|
||||
}
|
||||
|
||||
// ParseImportedData returns the imported data by parsing the custom transaction dsv data
|
||||
func (c *customTransactionDataDsvFileImporter) ParseImportedData(ctx core.Context, user *models.User, data []byte, defaultTimezoneOffset int16, accountMap map[string]*models.Account, expenseCategoryMap map[string]*models.TransactionCategory, incomeCategoryMap map[string]*models.TransactionCategory, transferCategoryMap map[string]*models.TransactionCategory, tagMap map[string]*models.TransactionTag) (models.ImportedTransactionSlice, []*models.Account, []*models.TransactionCategory, []*models.TransactionCategory, []*models.TransactionCategory, []*models.TransactionTag, error) {
|
||||
allLines, err := c.ParseDsvFileLines(ctx, data)
|
||||
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, nil, nil, err
|
||||
}
|
||||
|
||||
if !c.hasHeaderLine {
|
||||
allLines = append([][]string{{}}, allLines...)
|
||||
}
|
||||
|
||||
dataTable := csvconverter.CreateNewCustomCsvImportedDataTable(allLines)
|
||||
transactionDataTable := CreateNewCustomPlainTextDataTable(dataTable, c.columnIndexMapping, c.transactionTypeNameMapping, c.timeFormat, c.timezoneFormat)
|
||||
dataTableImporter := datatable.CreateNewImporter(customTransactionTypeNameMapping, c.geoLocationSeparator, c.transactionTagSeparator)
|
||||
|
||||
return dataTableImporter.ParseImportedData(ctx, user, transactionDataTable, defaultTimezoneOffset, accountMap, expenseCategoryMap, incomeCategoryMap, transferCategoryMap, tagMap)
|
||||
}
|
||||
|
||||
// IsDelimiterSeparatedValuesFileType returns whether the file type is the delimiter-separated values file type
|
||||
func IsDelimiterSeparatedValuesFileType(fileType string) bool {
|
||||
_, exists := supportedFileTypeSeparators[fileType]
|
||||
return exists
|
||||
}
|
||||
|
||||
// CreateNewCustomTransactionDataDsvFileParser returns a new custom dsv parser for transaction data
|
||||
func CreateNewCustomTransactionDataDsvFileParser(fileType string, fileEncoding string) (CustomTransactionDataDsvFileParser, error) {
|
||||
separator, exists := supportedFileTypeSeparators[fileType]
|
||||
|
||||
if !exists {
|
||||
return nil, errs.ErrImportFileTypeNotSupported
|
||||
}
|
||||
|
||||
enc, exists := supportedFileEncodings[fileEncoding]
|
||||
|
||||
if !exists {
|
||||
return nil, errs.ErrImportFileEncodingNotSupported
|
||||
}
|
||||
|
||||
return &customTransactionDataDsvFileImporter{
|
||||
fileEncoding: enc,
|
||||
separator: separator,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CreateNewCustomTransactionDataDsvFileImporter returns a new custom dsv importer for transaction data
|
||||
func CreateNewCustomTransactionDataDsvFileImporter(fileType string, fileEncoding string, columnIndexMapping map[datatable.TransactionDataTableColumn]int, transactionTypeNameMapping map[string]models.TransactionType, hasHeaderLine bool, timeFormat string, timezoneFormat string, geoLocationSeparator string, transactionTagSeparator string) (base.TransactionDataImporter, error) {
|
||||
separator, exists := supportedFileTypeSeparators[fileType]
|
||||
|
||||
if !exists {
|
||||
return nil, errs.ErrImportFileTypeNotSupported
|
||||
}
|
||||
|
||||
enc, exists := supportedFileEncodings[fileEncoding]
|
||||
|
||||
if !exists {
|
||||
return nil, errs.ErrImportFileEncodingNotSupported
|
||||
}
|
||||
|
||||
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 &customTransactionDataDsvFileImporter{
|
||||
fileEncoding: enc,
|
||||
separator: separator,
|
||||
columnIndexMapping: columnIndexMapping,
|
||||
transactionTypeNameMapping: transactionTypeNameMapping,
|
||||
hasHeaderLine: hasHeaderLine,
|
||||
timeFormat: timeFormat,
|
||||
timezoneFormat: timezoneFormat,
|
||||
geoLocationSeparator: geoLocationSeparator,
|
||||
transactionTagSeparator: transactionTagSeparator,
|
||||
}, nil
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,275 @@
|
||||
package dsv
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||
)
|
||||
|
||||
// customPlainTextDataTable defines the structure of custom plain text transaction data table
|
||||
type customPlainTextDataTable struct {
|
||||
innerDataTable datatable.ImportedDataTable
|
||||
columnIndexMapping map[datatable.TransactionDataTableColumn]int
|
||||
transactionTypeNameMapping map[string]models.TransactionType
|
||||
timeFormat string
|
||||
timezoneFormat string
|
||||
timeFormatIncludeTimezone bool
|
||||
}
|
||||
|
||||
// customPlainTextDataRow defines the structure of custom plain text transaction data row
|
||||
type customPlainTextDataRow struct {
|
||||
transactionDataTable *customPlainTextDataTable
|
||||
rowData map[datatable.TransactionDataTableColumn]string
|
||||
isValid bool
|
||||
}
|
||||
|
||||
// customPlainTextDataRowIterator defines the structure of custom plain text transaction data row iterator
|
||||
type customPlainTextDataRowIterator struct {
|
||||
transactionDataTable *customPlainTextDataTable
|
||||
innerIterator datatable.ImportedDataRowIterator
|
||||
}
|
||||
|
||||
// HasColumn returns whether the data table has specified column
|
||||
func (t *customPlainTextDataTable) HasColumn(column datatable.TransactionDataTableColumn) bool {
|
||||
// custom dsv file allows no sub category, account name and related account name column mapping, but data table converter needs these columns
|
||||
if column == datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY ||
|
||||
column == datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME ||
|
||||
column == datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME {
|
||||
return true
|
||||
}
|
||||
|
||||
// timezone column will be added when original time format contains timezone
|
||||
if t.timeFormatIncludeTimezone && column == datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE {
|
||||
return true
|
||||
}
|
||||
|
||||
_, exists := t.columnIndexMapping[column]
|
||||
return exists
|
||||
}
|
||||
|
||||
// TransactionRowCount returns the total count of transaction data row
|
||||
func (t *customPlainTextDataTable) TransactionRowCount() int {
|
||||
return t.innerDataTable.DataRowCount()
|
||||
}
|
||||
|
||||
// TransactionRowIterator returns the iterator of transaction data row
|
||||
func (t *customPlainTextDataTable) TransactionRowIterator() datatable.TransactionDataRowIterator {
|
||||
return &customPlainTextDataRowIterator{
|
||||
transactionDataTable: t,
|
||||
innerIterator: t.innerDataTable.DataRowIterator(),
|
||||
}
|
||||
}
|
||||
|
||||
// IsValid returns whether this row is valid data for importing
|
||||
func (r *customPlainTextDataRow) IsValid() bool {
|
||||
return r.isValid
|
||||
}
|
||||
|
||||
// GetData returns the data in the specified column type
|
||||
func (r *customPlainTextDataRow) GetData(column datatable.TransactionDataTableColumn) string {
|
||||
return r.rowData[column]
|
||||
}
|
||||
|
||||
// HasNext returns whether the iterator does not reach the end
|
||||
func (t *customPlainTextDataRowIterator) HasNext() bool {
|
||||
return t.innerIterator.HasNext()
|
||||
}
|
||||
|
||||
// Next returns the next transaction data row
|
||||
func (t *customPlainTextDataRowIterator) Next(ctx core.Context, user *models.User) (daraRow datatable.TransactionDataRow, err error) {
|
||||
importedRow := t.innerIterator.Next()
|
||||
|
||||
if importedRow == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
rowData, isValid, err := t.parseTransaction(ctx, user, importedRow)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(ctx, "[custom_transaction_plain_text_data_table.Next] cannot parsing transaction in row \"%s\", because %s", t.innerIterator.CurrentRowId(), err.Error())
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &customPlainTextDataRow{
|
||||
transactionDataTable: t.transactionDataTable,
|
||||
rowData: rowData,
|
||||
isValid: isValid,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (t *customPlainTextDataRowIterator) parseTransaction(ctx core.Context, user *models.User, row datatable.ImportedDataRow) (map[datatable.TransactionDataTableColumn]string, bool, error) {
|
||||
rowData := make(map[datatable.TransactionDataTableColumn]string, len(t.transactionDataTable.columnIndexMapping))
|
||||
|
||||
for column, columnIndex := range t.transactionDataTable.columnIndexMapping {
|
||||
if columnIndex < 0 || columnIndex >= row.ColumnCount() {
|
||||
continue
|
||||
}
|
||||
|
||||
value := row.GetData(columnIndex)
|
||||
rowData[column] = value
|
||||
}
|
||||
|
||||
// parse transaction type
|
||||
if rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] != "" {
|
||||
transactionType, exists := t.transactionDataTable.transactionTypeNameMapping[rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE]]
|
||||
|
||||
if !exists {
|
||||
log.Warnf(ctx, "[custom_transaction_plain_text_data_table.parseTransaction] skip parsing this transaction, because transaction type \"%s\" mapping not defined", rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE])
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
mappedTransactionType, exists := customTransactionTypeNameMapping[transactionType]
|
||||
|
||||
if !exists {
|
||||
log.Errorf(ctx, "[custom_transaction_plain_text_data_table.parseTransaction] cannot parsing transaction type \"%s\", because type \"%d\" is invalid", rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE], transactionType)
|
||||
return nil, false, errs.ErrTransactionTypeInvalid
|
||||
}
|
||||
|
||||
rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = mappedTransactionType
|
||||
}
|
||||
|
||||
// parse date time
|
||||
if rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME] != "" {
|
||||
dateTime, err := time.Parse(t.transactionDataTable.timeFormat, rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME])
|
||||
|
||||
if err != nil {
|
||||
return nil, false, errs.ErrTransactionTimeInvalid
|
||||
}
|
||||
|
||||
rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIME] = utils.FormatUnixTimeToLongDateTime(dateTime.Unix(), dateTime.Location())
|
||||
|
||||
if t.transactionDataTable.timeFormatIncludeTimezone {
|
||||
rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE] = utils.FormatTimezoneOffset(dateTime.Location())
|
||||
}
|
||||
}
|
||||
|
||||
// parse timezone
|
||||
if rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE] != "" {
|
||||
if t.transactionDataTable.timezoneFormat == "Z" || t.transactionDataTable.timezoneFormat == "" { // -HH:mm
|
||||
// Do Nothing
|
||||
} else if t.transactionDataTable.timezoneFormat == "ZZ" { // -HHmm
|
||||
timezone := rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE]
|
||||
|
||||
if len(timezone) != 5 {
|
||||
return nil, false, errs.ErrTransactionTimeZoneInvalid
|
||||
}
|
||||
|
||||
timezone = timezone[:3] + ":" + timezone[3:]
|
||||
rowData[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TIMEZONE] = timezone
|
||||
} else {
|
||||
return nil, false, errs.ErrImportFileTransactionTimezoneFormatInvalid
|
||||
}
|
||||
}
|
||||
|
||||
// use primary category if sub category is empty
|
||||
if rowData[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] == "" && rowData[datatable.TRANSACTION_DATA_TABLE_CATEGORY] != "" {
|
||||
rowData[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] = rowData[datatable.TRANSACTION_DATA_TABLE_CATEGORY]
|
||||
}
|
||||
|
||||
// trim trailing zero in decimal
|
||||
if rowData[datatable.TRANSACTION_DATA_TABLE_AMOUNT] != "" {
|
||||
rowData[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.TrimTrailingZerosInDecimal(rowData[datatable.TRANSACTION_DATA_TABLE_AMOUNT])
|
||||
amount, err := utils.ParseAmount(rowData[datatable.TRANSACTION_DATA_TABLE_AMOUNT])
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(ctx, "[custom_transaction_plain_text_data_table.parseTransaction] cannot parsing transaction amount \"%s\", because %s", rowData[datatable.TRANSACTION_DATA_TABLE_AMOUNT], err.Error())
|
||||
return nil, false, errs.ErrAmountInvalid
|
||||
}
|
||||
|
||||
rowData[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(amount)
|
||||
}
|
||||
|
||||
if rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT] != "" {
|
||||
rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT] = utils.TrimTrailingZerosInDecimal(rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT])
|
||||
amount, err := utils.ParseAmount(rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT])
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(ctx, "[custom_transaction_plain_text_data_table.parseTransaction] cannot parsing transaction related amount \"%s\", because %s", rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT], err.Error())
|
||||
return nil, false, errs.ErrAmountInvalid
|
||||
}
|
||||
|
||||
rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT] = utils.FormatAmount(amount)
|
||||
}
|
||||
|
||||
if _, exists := rowData[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY]; !exists {
|
||||
rowData[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] = ""
|
||||
}
|
||||
|
||||
if _, exists := rowData[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME]; !exists {
|
||||
rowData[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = ""
|
||||
}
|
||||
|
||||
if _, exists := rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME]; !exists {
|
||||
rowData[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = ""
|
||||
}
|
||||
|
||||
return rowData, true, nil
|
||||
}
|
||||
|
||||
// CreateNewCustomPlainTextDataTable returns transaction data table from imported data table
|
||||
func CreateNewCustomPlainTextDataTable(dataTable datatable.ImportedDataTable, columnIndexMapping map[datatable.TransactionDataTableColumn]int, transactionTypeNameMapping map[string]models.TransactionType, timeFormat string, timezoneFormat string) *customPlainTextDataTable {
|
||||
timeFormatIncludeTimezone := strings.Contains(timeFormat, "z") || strings.Contains(timeFormat, "Z")
|
||||
|
||||
return &customPlainTextDataTable{
|
||||
innerDataTable: dataTable,
|
||||
columnIndexMapping: columnIndexMapping,
|
||||
transactionTypeNameMapping: transactionTypeNameMapping,
|
||||
timeFormat: getDateTimeFormat(timeFormat),
|
||||
timezoneFormat: timezoneFormat,
|
||||
timeFormatIncludeTimezone: timeFormatIncludeTimezone,
|
||||
}
|
||||
}
|
||||
|
||||
func getDateTimeFormat(format string) string {
|
||||
// convert moment.js format to Go format
|
||||
|
||||
format = strings.ReplaceAll(format, "YYYY", "2006")
|
||||
format = strings.ReplaceAll(format, "YY", "06")
|
||||
|
||||
format = strings.ReplaceAll(format, "MMMM", "January")
|
||||
format = strings.ReplaceAll(format, "MMM", "Jan")
|
||||
format = strings.ReplaceAll(format, "MM", "01")
|
||||
format = strings.ReplaceAll(format, "M", "1")
|
||||
|
||||
format = strings.ReplaceAll(format, "DD", "02")
|
||||
format = strings.ReplaceAll(format, "D", "2")
|
||||
|
||||
format = strings.ReplaceAll(format, "dddd", "Monday")
|
||||
format = strings.ReplaceAll(format, "ddd", "Mon")
|
||||
|
||||
format = strings.ReplaceAll(format, "HH", "15")
|
||||
format = strings.ReplaceAll(format, "H", "15")
|
||||
|
||||
format = strings.ReplaceAll(format, "hh", "03")
|
||||
format = strings.ReplaceAll(format, "h", "3")
|
||||
|
||||
format = strings.ReplaceAll(format, "mm", "04")
|
||||
format = strings.ReplaceAll(format, "m", "4")
|
||||
|
||||
format = strings.ReplaceAll(format, "ss", "05")
|
||||
format = strings.ReplaceAll(format, "s", "5")
|
||||
|
||||
for i := 9; i >= 1; i-- {
|
||||
format = strings.ReplaceAll(format, "."+strings.Repeat("S", i), "."+strings.Repeat("9", i))
|
||||
}
|
||||
|
||||
format = strings.ReplaceAll(format, "A", "PM")
|
||||
format = strings.ReplaceAll(format, "a", "pm")
|
||||
|
||||
format = strings.ReplaceAll(format, "zz", "MST")
|
||||
format = strings.ReplaceAll(format, "z", "MST")
|
||||
|
||||
if strings.Contains(format, "ZZ") {
|
||||
format = strings.ReplaceAll(format, "ZZ", "Z0700")
|
||||
} else if strings.Contains(format, "Z") {
|
||||
format = strings.ReplaceAll(format, "Z", "Z07:00")
|
||||
}
|
||||
|
||||
return format
|
||||
}
|
||||
+1
@@ -98,6 +98,7 @@ func (t *gnucashTransactionDataRowIterator) Next(ctx core.Context, user *models.
|
||||
rowItems, isValid, err := t.parseTransaction(ctx, user, data)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(ctx, "[gnucash_transaction_table.Next] cannot parsing transaction in row#%d, because %s", t.currentIndex, err.Error())
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -129,11 +129,26 @@ func (r *iifDataReader) read(ctx core.Context) ([]*iifAccountDataset, []*iifTran
|
||||
}
|
||||
} else if lastLineSign == iifTransactionLineSignColumnName || lastLineSign == iifTransactionSplitLineSignColumnName {
|
||||
if items[0] == iifTransactionSplitLineSignColumnName {
|
||||
if currentTransactionData == nil {
|
||||
log.Errorf(ctx, "[iif_data_reader.read] expected current transaction data is not nil, but read \"%s\"", items[0])
|
||||
return nil, nil, errs.ErrInvalidIIFFile
|
||||
}
|
||||
|
||||
currentTransactionData.splitData = append(currentTransactionData.splitData, &iifTransactionSplitData{
|
||||
dataItems: items,
|
||||
})
|
||||
lastLineSign = items[0]
|
||||
} else if items[0] == iifTransactionEndLineSignColumnName {
|
||||
if currentTransactionData == nil {
|
||||
log.Errorf(ctx, "[iif_data_reader.read] expected current transaction data is not nil, but read \"%s\"", items[0])
|
||||
return nil, nil, errs.ErrInvalidIIFFile
|
||||
}
|
||||
|
||||
if len(currentTransactionData.splitData) < 1 {
|
||||
log.Errorf(ctx, "[iif_data_reader.read] expected reading transaction split line, but read \"%s\"", items[0])
|
||||
return nil, nil, errs.ErrInvalidIIFFile
|
||||
}
|
||||
|
||||
currentTransactionDataset.transactions = append(currentTransactionDataset.transactions, currentTransactionData)
|
||||
lastLineSign = ""
|
||||
} else {
|
||||
@@ -214,7 +229,7 @@ func (r *iifDataReader) readTransactionSampleLines(ctx core.Context, items []str
|
||||
}
|
||||
|
||||
if len(transactionEndSampleItems) < 1 || transactionEndSampleItems[0] != iifTransactionEndSampleLineSignColumnName {
|
||||
log.Errorf(ctx, "[iif_data_reader.readTransactionSampleLines] expected reading transaction end sample line, but read \"%s\"", strings.Join(splitSampleItems, "\t"))
|
||||
log.Errorf(ctx, "[iif_data_reader.readTransactionSampleLines] expected reading transaction end sample line, but read \"%s\"", strings.Join(transactionEndSampleItems, "\t"))
|
||||
return nil, errs.ErrInvalidIIFFile
|
||||
}
|
||||
|
||||
|
||||
@@ -333,7 +333,7 @@ func TestIIFTransactionDataFileParseImportedData_ParseYearMonthDayFormatTime(t *
|
||||
assert.Equal(t, int64(1725408000), utils.GetUnixTimeFromTransactionTime(allNewTransactions[3].TransactionTime))
|
||||
}
|
||||
|
||||
func TestIIFTransactionDataFileParseImportedData_ParseShortMonthDayFormatTime(t *testing.T) {
|
||||
func TestIIFTransactionDataFileParseImportedData_ParseShortMonthDayYearFormatTime(t *testing.T) {
|
||||
converter := IifTransactionDataFileImporter
|
||||
context := core.NewNullContext()
|
||||
|
||||
@@ -364,6 +364,37 @@ func TestIIFTransactionDataFileParseImportedData_ParseShortMonthDayFormatTime(t
|
||||
assert.Equal(t, int64(1725321600), utils.GetUnixTimeFromTransactionTime(allNewTransactions[2].TransactionTime))
|
||||
}
|
||||
|
||||
func TestIIFTransactionDataFileParseImportedData_ParseShortMonthDayTwoDigitsYearFormatTime(t *testing.T) {
|
||||
converter := IifTransactionDataFileImporter
|
||||
context := core.NewNullContext()
|
||||
|
||||
user := &models.User{
|
||||
Uid: 1234567890,
|
||||
DefaultCurrency: "CNY",
|
||||
}
|
||||
|
||||
allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(
|
||||
"!TRNS\tDATE\tACCNT\tAMOUNT\n"+
|
||||
"!SPL\tDATE\tACCNT\tAMOUNT\n"+
|
||||
"!ENDTRNS\t\t\t\n"+
|
||||
"TRNS\t9/01/24\tTest Account\t123.45\n"+
|
||||
"SPL\t9/01/24\tTest Account2\t-123.45\n"+
|
||||
"ENDTRNS\t\t\t\n"+
|
||||
"TRNS\t09/2/24\tTest Account\t123.45\n"+
|
||||
"SPL\t09/2/24\tTest Account2\t-123.45\n"+
|
||||
"ENDTRNS\t\t\t\n"+
|
||||
"TRNS\t9/3/24\tTest Account\t123.45\n"+
|
||||
"SPL\t9/3/24\tTest Account2\t-123.45\n"+
|
||||
"ENDTRNS\t\t\t\n"), 0, nil, nil, nil, nil, nil)
|
||||
|
||||
assert.Nil(t, err)
|
||||
|
||||
assert.Equal(t, 3, len(allNewTransactions))
|
||||
assert.Equal(t, int64(1725148800), utils.GetUnixTimeFromTransactionTime(allNewTransactions[0].TransactionTime))
|
||||
assert.Equal(t, int64(1725235200), utils.GetUnixTimeFromTransactionTime(allNewTransactions[1].TransactionTime))
|
||||
assert.Equal(t, int64(1725321600), utils.GetUnixTimeFromTransactionTime(allNewTransactions[2].TransactionTime))
|
||||
}
|
||||
|
||||
func TestIIFTransactionDataFileParseImportedData_ParseInvalidTime(t *testing.T) {
|
||||
converter := IifTransactionDataFileImporter
|
||||
context := core.NewNullContext()
|
||||
@@ -377,8 +408,8 @@ func TestIIFTransactionDataFileParseImportedData_ParseInvalidTime(t *testing.T)
|
||||
"!TRNS\tDATE\tACCNT\tAMOUNT\n"+
|
||||
"!SPL\tDATE\tACCNT\tAMOUNT\n"+
|
||||
"!ENDTRNS\t\t\t\n"+
|
||||
"TRNS\t9/1/24\tTest Account\t123.45\n"+
|
||||
"SPL\t9/1/24\tTest Account2\t-123.45\n"+
|
||||
"TRNS\t09-01-2024\tTest Account\t123.45\n"+
|
||||
"SPL\t09-01-2024\tTest Account2\t-123.45\n"+
|
||||
"ENDTRNS\t\t\t\n"), 0, nil, nil, nil, nil, nil)
|
||||
assert.EqualError(t, err, errs.ErrTransactionTimeInvalid.Message)
|
||||
|
||||
@@ -486,7 +517,7 @@ func TestIIFTransactionDataFileParseImportedData_ParseDescription(t *testing.T)
|
||||
assert.Equal(t, "Test", allNewTransactions[0].Comment)
|
||||
}
|
||||
|
||||
func TestIIFTransactionDataFileParseImportedData_NotSupportedToParseSplitTransaction(t *testing.T) {
|
||||
func TestIIFTransactionDataFileParseImportedData_ParseSplitTransaction(t *testing.T) {
|
||||
converter := IifTransactionDataFileImporter
|
||||
context := core.NewNullContext()
|
||||
|
||||
@@ -495,11 +526,218 @@ func TestIIFTransactionDataFileParseImportedData_NotSupportedToParseSplitTransac
|
||||
DefaultCurrency: "CNY",
|
||||
}
|
||||
|
||||
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(
|
||||
allNewTransactions, allNewAccounts, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(
|
||||
"!ACCNT\tNAME\tACCNTTYPE\n"+
|
||||
"ACCNT\tTest Category\tINC\n"+
|
||||
"ACCNT\tTest Category2\tEXP\n"+
|
||||
"!TRNS\tDATE\tACCNT\tAMOUNT\n"+
|
||||
"!SPL\tDATE\tACCNT\tAMOUNT\n"+
|
||||
"!ENDTRNS\t\t\t\n"+
|
||||
"TRNS\t09/01/2024\tTest Account\t123.45\n"+
|
||||
"SPL\t09/01/2024\tTest Category\t-23.45\n"+
|
||||
"SPL\t09/01/2024\tTest Account2\t-100.00\n"+
|
||||
"ENDTRNS\t\t\t\n"+
|
||||
"TRNS\t09/02/2024\tTest Account\t-100.00\n"+
|
||||
"SPL\t09/02/2024\tTest Category2\t30.00\n"+
|
||||
"SPL\t09/02/2024\tTest Account3\t20.00\n"+
|
||||
"SPL\t09/02/2024\tTest Account4\t50.00\n"+
|
||||
"ENDTRNS\t\t\t\n"+
|
||||
"TRNS\t09/03/2024\tTest Account\t100.00\n"+
|
||||
"SPL\t09/03/2024\tTest Account2\t-100.00\n"+
|
||||
"ENDTRNS\t\t\t\n"+
|
||||
"TRNS\t09/04/2024\tTest Category\t-100.00\n"+
|
||||
"SPL\t09/04/2024\tTest Account\t40.00\n"+
|
||||
"SPL\t09/04/2024\tTest Account2\t60.00\n"+
|
||||
"ENDTRNS\t\t\t\n"+
|
||||
"TRNS\t09/05/2024\tTest Category2\t100.00\n"+
|
||||
"SPL\t09/05/2024\tTest Account3\t-40.00\n"+
|
||||
"SPL\t09/05/2024\tTest Account4\t-60.00\n"+
|
||||
"ENDTRNS\t\t\t\n"), 0, nil, nil, nil, nil, nil)
|
||||
|
||||
assert.Nil(t, err)
|
||||
|
||||
assert.Equal(t, 10, len(allNewTransactions))
|
||||
assert.Equal(t, 4, len(allNewAccounts))
|
||||
|
||||
assert.Equal(t, int64(1234567890), allNewTransactions[0].Uid)
|
||||
assert.Equal(t, models.TRANSACTION_DB_TYPE_INCOME, allNewTransactions[0].Type)
|
||||
assert.Equal(t, int64(1725148800), utils.GetUnixTimeFromTransactionTime(allNewTransactions[0].TransactionTime))
|
||||
assert.Equal(t, int64(2345), allNewTransactions[0].Amount)
|
||||
assert.Equal(t, "Test Account", allNewTransactions[0].OriginalSourceAccountName)
|
||||
assert.Equal(t, "Test Category", allNewTransactions[0].OriginalCategoryName)
|
||||
|
||||
assert.Equal(t, int64(1234567890), allNewTransactions[1].Uid)
|
||||
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[1].Type)
|
||||
assert.Equal(t, int64(1725148800), utils.GetUnixTimeFromTransactionTime(allNewTransactions[1].TransactionTime))
|
||||
assert.Equal(t, int64(10000), allNewTransactions[1].Amount)
|
||||
assert.Equal(t, "Test Account2", allNewTransactions[1].OriginalSourceAccountName)
|
||||
assert.Equal(t, "Test Account", allNewTransactions[1].OriginalDestinationAccountName)
|
||||
assert.Equal(t, "", allNewTransactions[1].OriginalCategoryName)
|
||||
|
||||
assert.Equal(t, int64(1234567890), allNewTransactions[2].Uid)
|
||||
assert.Equal(t, models.TRANSACTION_DB_TYPE_EXPENSE, allNewTransactions[2].Type)
|
||||
assert.Equal(t, int64(1725235200), utils.GetUnixTimeFromTransactionTime(allNewTransactions[2].TransactionTime))
|
||||
assert.Equal(t, int64(3000), allNewTransactions[2].Amount)
|
||||
assert.Equal(t, "Test Account", allNewTransactions[2].OriginalSourceAccountName)
|
||||
assert.Equal(t, "Test Category2", allNewTransactions[2].OriginalCategoryName)
|
||||
|
||||
assert.Equal(t, int64(1234567890), allNewTransactions[3].Uid)
|
||||
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[3].Type)
|
||||
assert.Equal(t, int64(1725235200), utils.GetUnixTimeFromTransactionTime(allNewTransactions[3].TransactionTime))
|
||||
assert.Equal(t, int64(2000), allNewTransactions[3].Amount)
|
||||
assert.Equal(t, "Test Account", allNewTransactions[3].OriginalSourceAccountName)
|
||||
assert.Equal(t, "Test Account3", allNewTransactions[3].OriginalDestinationAccountName)
|
||||
assert.Equal(t, "", allNewTransactions[3].OriginalCategoryName)
|
||||
|
||||
assert.Equal(t, int64(1234567890), allNewTransactions[4].Uid)
|
||||
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[4].Type)
|
||||
assert.Equal(t, int64(1725235200), utils.GetUnixTimeFromTransactionTime(allNewTransactions[4].TransactionTime))
|
||||
assert.Equal(t, int64(5000), allNewTransactions[4].Amount)
|
||||
assert.Equal(t, "Test Account", allNewTransactions[4].OriginalSourceAccountName)
|
||||
assert.Equal(t, "Test Account4", allNewTransactions[4].OriginalDestinationAccountName)
|
||||
assert.Equal(t, "", allNewTransactions[4].OriginalCategoryName)
|
||||
|
||||
assert.Equal(t, int64(1234567890), allNewTransactions[5].Uid)
|
||||
assert.Equal(t, models.TRANSACTION_DB_TYPE_TRANSFER_OUT, allNewTransactions[5].Type)
|
||||
assert.Equal(t, int64(1725321600), utils.GetUnixTimeFromTransactionTime(allNewTransactions[5].TransactionTime))
|
||||
assert.Equal(t, int64(10000), allNewTransactions[5].Amount)
|
||||
assert.Equal(t, "Test Account2", allNewTransactions[5].OriginalSourceAccountName)
|
||||
assert.Equal(t, "Test Account", allNewTransactions[5].OriginalDestinationAccountName)
|
||||
assert.Equal(t, "", allNewTransactions[5].OriginalCategoryName)
|
||||
|
||||
assert.Equal(t, int64(1234567890), allNewTransactions[6].Uid)
|
||||
assert.Equal(t, models.TRANSACTION_DB_TYPE_INCOME, allNewTransactions[6].Type)
|
||||
assert.Equal(t, int64(1725408000), utils.GetUnixTimeFromTransactionTime(allNewTransactions[6].TransactionTime))
|
||||
assert.Equal(t, int64(4000), allNewTransactions[6].Amount)
|
||||
assert.Equal(t, "Test Account", allNewTransactions[6].OriginalSourceAccountName)
|
||||
assert.Equal(t, "Test Category", allNewTransactions[6].OriginalCategoryName)
|
||||
|
||||
assert.Equal(t, int64(1234567890), allNewTransactions[7].Uid)
|
||||
assert.Equal(t, models.TRANSACTION_DB_TYPE_INCOME, allNewTransactions[7].Type)
|
||||
assert.Equal(t, int64(1725408000), utils.GetUnixTimeFromTransactionTime(allNewTransactions[7].TransactionTime))
|
||||
assert.Equal(t, int64(6000), allNewTransactions[7].Amount)
|
||||
assert.Equal(t, "Test Account2", allNewTransactions[7].OriginalSourceAccountName)
|
||||
assert.Equal(t, "Test Category", allNewTransactions[7].OriginalCategoryName)
|
||||
|
||||
assert.Equal(t, int64(1234567890), allNewTransactions[8].Uid)
|
||||
assert.Equal(t, models.TRANSACTION_DB_TYPE_EXPENSE, allNewTransactions[8].Type)
|
||||
assert.Equal(t, int64(1725494400), utils.GetUnixTimeFromTransactionTime(allNewTransactions[8].TransactionTime))
|
||||
assert.Equal(t, int64(4000), allNewTransactions[8].Amount)
|
||||
assert.Equal(t, "Test Account3", allNewTransactions[8].OriginalSourceAccountName)
|
||||
assert.Equal(t, "Test Category2", allNewTransactions[8].OriginalCategoryName)
|
||||
|
||||
assert.Equal(t, int64(1234567890), allNewTransactions[9].Uid)
|
||||
assert.Equal(t, models.TRANSACTION_DB_TYPE_EXPENSE, allNewTransactions[9].Type)
|
||||
assert.Equal(t, int64(1725494400), utils.GetUnixTimeFromTransactionTime(allNewTransactions[9].TransactionTime))
|
||||
assert.Equal(t, int64(6000), allNewTransactions[9].Amount)
|
||||
assert.Equal(t, "Test Account4", allNewTransactions[9].OriginalSourceAccountName)
|
||||
assert.Equal(t, "Test Category2", allNewTransactions[9].OriginalCategoryName)
|
||||
|
||||
assert.Equal(t, int64(1234567890), allNewAccounts[0].Uid)
|
||||
assert.Equal(t, "Test Account", allNewAccounts[0].Name)
|
||||
assert.Equal(t, "CNY", allNewAccounts[0].Currency)
|
||||
|
||||
assert.Equal(t, int64(1234567890), allNewAccounts[1].Uid)
|
||||
assert.Equal(t, "Test Account2", allNewAccounts[1].Name)
|
||||
assert.Equal(t, "CNY", allNewAccounts[1].Currency)
|
||||
|
||||
assert.Equal(t, int64(1234567890), allNewAccounts[2].Uid)
|
||||
assert.Equal(t, "Test Account3", allNewAccounts[2].Name)
|
||||
assert.Equal(t, "CNY", allNewAccounts[2].Currency)
|
||||
|
||||
assert.Equal(t, int64(1234567890), allNewAccounts[3].Uid)
|
||||
assert.Equal(t, "Test Account4", allNewAccounts[3].Name)
|
||||
assert.Equal(t, "CNY", allNewAccounts[3].Currency)
|
||||
}
|
||||
|
||||
func TestIIFTransactionDataFileParseImportedData_ParseSplitTransactionDescription(t *testing.T) {
|
||||
converter := IifTransactionDataFileImporter
|
||||
context := core.NewNullContext()
|
||||
|
||||
user := &models.User{
|
||||
Uid: 1234567890,
|
||||
DefaultCurrency: "CNY",
|
||||
}
|
||||
|
||||
allNewTransactions, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(
|
||||
"!TRNS\tDATE\tACCNT\tNAME\tAMOUNT\tMEMO\n"+
|
||||
"!SPL\tDATE\tACCNT\tNAME\tAMOUNT\tMEMO\n"+
|
||||
"!ENDTRNS\t\t\t\t\t\n"+
|
||||
"TRNS\t09/01/2024\tTest Account\t\"Test\"\t123.45\t\"foo bar\t#test\"\n"+
|
||||
"SPL\t09/01/2024\tTest Account2\t\t-100.00\t\"foo\ttest#bar\"\n"+
|
||||
"SPL\t09/01/2024\tTest Account3\t\t-23.45\t\n"+
|
||||
"ENDTRNS\t\t\t\t\t\n"), 0, nil, nil, nil, nil, nil)
|
||||
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, 2, len(allNewTransactions))
|
||||
assert.Equal(t, "foo\ttest#bar", allNewTransactions[0].Comment)
|
||||
assert.Equal(t, "foo bar\t#test", allNewTransactions[1].Comment)
|
||||
|
||||
allNewTransactions, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(
|
||||
"!TRNS\tDATE\tACCNT\tNAME\tAMOUNT\tMEMO\n"+
|
||||
"!SPL\tDATE\tACCNT\tNAME\tAMOUNT\tMEMO\n"+
|
||||
"!ENDTRNS\t\t\t\t\t\n"+
|
||||
"TRNS\t09/01/2024\tTest Account\tTest\t123.45\t\n"+
|
||||
"SPL\t09/01/2024\tTest Account2\t\t-100.00\t\"test\"\n"+
|
||||
"SPL\t09/01/2024\tTest Account3\tfoo\t-12.34\t\n"+
|
||||
"SPL\t09/01/2024\tTest Account4\t\t-11.11\t\n"+
|
||||
"ENDTRNS\t\t\t\t\t\n"), 0, nil, nil, nil, nil, nil)
|
||||
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, 3, len(allNewTransactions))
|
||||
assert.Equal(t, "test", allNewTransactions[0].Comment)
|
||||
assert.Equal(t, "foo", allNewTransactions[1].Comment)
|
||||
assert.Equal(t, "Test", allNewTransactions[2].Comment)
|
||||
}
|
||||
|
||||
func TestIIFTransactionDataFileParseImportedData_NotSupportedSplitTransaction(t *testing.T) {
|
||||
converter := IifTransactionDataFileImporter
|
||||
context := core.NewNullContext()
|
||||
|
||||
user := &models.User{
|
||||
Uid: 1234567890,
|
||||
DefaultCurrency: "CNY",
|
||||
}
|
||||
|
||||
// Opening balance transaction
|
||||
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(
|
||||
"!TRNS\tTRNSTYPE\tDATE\tACCNT\tAMOUNT\n"+
|
||||
"!SPL\tTRNSTYPE\tDATE\tACCNT\tAMOUNT\n"+
|
||||
"!ENDTRNS\t\t\t\t\n"+
|
||||
"TRNS\tBEGINBALCHECK\t09/01/2024\tTest Account\t123.45\n"+
|
||||
"SPL\tBEGINBALCHECK\t09/01/2024\tTest Account2\t-100.00\n"+
|
||||
"SPL\tBEGINBALCHECK\t09/01/2024\tTest Account3\t-23.45\n"+
|
||||
"ENDTRNS\t\t\t\n"), 0, nil, nil, nil, nil, nil)
|
||||
assert.EqualError(t, err, errs.ErrNotSupportedSplitTransactions.Message)
|
||||
|
||||
// Transaction with invalid amount
|
||||
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(
|
||||
"!TRNS\tDATE\tACCNT\tAMOUNT\n"+
|
||||
"!SPL\tDATE\tACCNT\tAMOUNT\n"+
|
||||
"!ENDTRNS\t\t\t\n"+
|
||||
"TRNS\t09/01/2024\tTest Account\t123 45\n"+
|
||||
"SPL\t09/01/2024\tTest Account2\t-100.00\n"+
|
||||
"SPL\t09/01/2024\tTest Account3\t-23.45\n"+
|
||||
"ENDTRNS\t\t\t\n"), 0, nil, nil, nil, nil, nil)
|
||||
assert.EqualError(t, err, errs.ErrAmountInvalid.Message)
|
||||
|
||||
// Transaction split data with invalid amount
|
||||
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(
|
||||
"!TRNS\tDATE\tACCNT\tAMOUNT\n"+
|
||||
"!SPL\tDATE\tACCNT\tAMOUNT\n"+
|
||||
"!ENDTRNS\t\t\t\n"+
|
||||
"TRNS\t09/01/2024\tTest Account\t123.45\n"+
|
||||
"SPL\t09/01/2024\tTest Account2\t-100 00\n"+
|
||||
"SPL\t09/01/2024\tTest Account3\t-23.45\n"+
|
||||
"ENDTRNS\t\t\t\n"), 0, nil, nil, nil, nil, nil)
|
||||
assert.EqualError(t, err, errs.ErrAmountInvalid.Message)
|
||||
|
||||
// Transaction amount not equal to sum of split data amount
|
||||
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(
|
||||
"!TRNS\tDATE\tACCNT\tAMOUNT\n"+
|
||||
"!SPL\tDATE\tACCNT\tAMOUNT\n"+
|
||||
"!ENDTRNS\t\t\t\n"+
|
||||
"TRNS\t09/01/2024\tTest Account\t123.00\n"+
|
||||
"SPL\t09/01/2024\tTest Account2\t-100.00\n"+
|
||||
"SPL\t09/01/2024\tTest Account3\t-23.45\n"+
|
||||
"ENDTRNS\t\t\t\n"), 0, nil, nil, nil, nil, nil)
|
||||
@@ -515,7 +753,7 @@ func TestIIFTransactionDataFileParseImportedData_InvalidDataLines(t *testing.T)
|
||||
DefaultCurrency: "CNY",
|
||||
}
|
||||
|
||||
// Missing Transaction Line
|
||||
//Missing Transaction Line
|
||||
_, _, _, _, _, _, err := converter.ParseImportedData(context, user, []byte(
|
||||
"!TRNS\tDATE\tACCNT\tAMOUNT\n"+
|
||||
"!SPL\tDATE\tACCNT\tAMOUNT\n"+
|
||||
@@ -524,6 +762,14 @@ func TestIIFTransactionDataFileParseImportedData_InvalidDataLines(t *testing.T)
|
||||
"ENDTRNS\t\t\t\n"), 0, nil, nil, nil, nil, nil)
|
||||
assert.EqualError(t, err, errs.ErrInvalidIIFFile.Message)
|
||||
|
||||
// Missing Transaction And Split Line
|
||||
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(
|
||||
"!TRNS\tDATE\tACCNT\tAMOUNT\n"+
|
||||
"!SPL\tDATE\tACCNT\tAMOUNT\n"+
|
||||
"!ENDTRNS\t\t\t\n"+
|
||||
"ENDTRNS\t\t\t\n"), 0, nil, nil, nil, nil, nil)
|
||||
assert.EqualError(t, err, errs.ErrInvalidIIFFile.Message)
|
||||
|
||||
// Missing Split Line
|
||||
_, _, _, _, _, _, err = converter.ParseImportedData(context, user, []byte(
|
||||
"!TRNS\tDATE\tACCNT\tAMOUNT\n"+
|
||||
|
||||
@@ -3,6 +3,7 @@ package iif
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
@@ -59,6 +60,7 @@ type iifTransactionDataRowIterator struct {
|
||||
dataTable *iifTransactionDataTable
|
||||
currentDatasetIndex int
|
||||
currentIndexInDataset int
|
||||
currentSplitDataIndex int
|
||||
}
|
||||
|
||||
// HasColumn returns whether the transaction data table has specified column
|
||||
@@ -72,8 +74,15 @@ func (t *iifTransactionDataTable) TransactionRowCount() int {
|
||||
totalDataRowCount := 0
|
||||
|
||||
for i := 0; i < len(t.transactionDatasets); i++ {
|
||||
transactions := t.transactionDatasets[i]
|
||||
totalDataRowCount += len(transactions.transactions)
|
||||
datasets := t.transactionDatasets[i]
|
||||
|
||||
for j := 0; j < len(datasets.transactions); j++ {
|
||||
transaction := datasets.transactions[j]
|
||||
|
||||
if transaction.splitData != nil {
|
||||
totalDataRowCount += len(transaction.splitData)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return totalDataRowCount
|
||||
@@ -84,7 +93,8 @@ func (t *iifTransactionDataTable) TransactionRowIterator() datatable.Transaction
|
||||
return &iifTransactionDataRowIterator{
|
||||
dataTable: t,
|
||||
currentDatasetIndex: 0,
|
||||
currentIndexInDataset: -1,
|
||||
currentIndexInDataset: 0,
|
||||
currentSplitDataIndex: -1,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,6 +126,9 @@ func (t *iifTransactionDataRowIterator) HasNext() bool {
|
||||
|
||||
if t.currentIndexInDataset+1 < len(currentDataset.transactions) {
|
||||
return true
|
||||
} else if t.currentIndexInDataset < len(currentDataset.transactions) &&
|
||||
t.currentSplitDataIndex+1 < len(currentDataset.transactions[t.currentIndexInDataset].splitData) {
|
||||
return true
|
||||
}
|
||||
|
||||
for i := t.currentDatasetIndex + 1; i < len(allDatasets); i++ {
|
||||
@@ -134,20 +147,29 @@ func (t *iifTransactionDataRowIterator) HasNext() bool {
|
||||
// Next returns the next imported data row
|
||||
func (t *iifTransactionDataRowIterator) Next(ctx core.Context, user *models.User) (daraRow datatable.TransactionDataRow, err error) {
|
||||
allDatasets := t.dataTable.transactionDatasets
|
||||
currentIndexInDataset := t.currentIndexInDataset
|
||||
|
||||
for i := t.currentDatasetIndex; i < len(allDatasets); i++ {
|
||||
foundNextRow := false
|
||||
dataset := allDatasets[i]
|
||||
|
||||
if currentIndexInDataset+1 < len(dataset.transactions) {
|
||||
for j := t.currentIndexInDataset; j < len(dataset.transactions); j++ {
|
||||
if t.currentSplitDataIndex+1 < len(dataset.transactions[j].splitData) {
|
||||
t.currentSplitDataIndex++
|
||||
foundNextRow = true
|
||||
break
|
||||
}
|
||||
|
||||
t.currentIndexInDataset++
|
||||
currentIndexInDataset = t.currentIndexInDataset
|
||||
t.currentSplitDataIndex = -1
|
||||
}
|
||||
|
||||
if foundNextRow {
|
||||
break
|
||||
}
|
||||
|
||||
t.currentDatasetIndex++
|
||||
t.currentIndexInDataset = -1
|
||||
currentIndexInDataset = -1
|
||||
t.currentIndexInDataset = 0
|
||||
t.currentSplitDataIndex = -1
|
||||
}
|
||||
|
||||
if t.currentDatasetIndex >= len(allDatasets) {
|
||||
@@ -161,11 +183,30 @@ func (t *iifTransactionDataRowIterator) Next(ctx core.Context, user *models.User
|
||||
}
|
||||
|
||||
data := currentDataset.transactions[t.currentIndexInDataset]
|
||||
rowItems, err := t.parseTransaction(ctx, user, currentDataset, data)
|
||||
|
||||
if len(data.splitData) < 1 {
|
||||
log.Errorf(ctx, "[iif_transaction_data_table.Next] cannot parsing transaction in row#%d (dataset#%d), because split data is empty", t.currentIndexInDataset, t.currentDatasetIndex)
|
||||
return nil, errs.ErrInvalidIIFFile
|
||||
}
|
||||
|
||||
if t.currentSplitDataIndex >= len(data.splitData) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if len(data.splitData) > 1 {
|
||||
_, err := t.isSplitTransactionSupported(ctx, currentDataset, data)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
rowItems, err := t.parseTransaction(ctx, user, currentDataset, data, t.currentSplitDataIndex)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(ctx, "[iif_transaction_data_table.Next] cannot parsing transaction in row#%d-split#%d (dataset#%d), because %s", t.currentIndexInDataset, t.currentSplitDataIndex, t.currentDatasetIndex, err.Error())
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &iifTransactionDataRow{
|
||||
dataTable: t.dataTable,
|
||||
@@ -173,13 +214,7 @@ func (t *iifTransactionDataRowIterator) Next(ctx core.Context, user *models.User
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (t *iifTransactionDataRowIterator) parseTransaction(ctx core.Context, user *models.User, dataset *iifTransactionDataset, transactionData *iifTransactionData) (map[datatable.TransactionDataTableColumn]string, error) {
|
||||
if len(transactionData.splitData) < 1 {
|
||||
return nil, errs.ErrInvalidIIFFile
|
||||
} else if len(transactionData.splitData) > 1 {
|
||||
return nil, errs.ErrNotSupportedSplitTransactions
|
||||
}
|
||||
|
||||
func (t *iifTransactionDataRowIterator) parseTransaction(ctx core.Context, user *models.User, dataset *iifTransactionDataset, transactionData *iifTransactionData, splitDataIndex int) (map[datatable.TransactionDataTableColumn]string, error) {
|
||||
var err error
|
||||
|
||||
data := make(map[datatable.TransactionDataTableColumn]string, len(iifTransactionSupportedColumns))
|
||||
@@ -189,18 +224,18 @@ func (t *iifTransactionDataRowIterator) parseTransaction(ctx core.Context, user
|
||||
return nil, err
|
||||
}
|
||||
|
||||
transactionType, _ := dataset.getTransactionDataItemValue(transactionData, iifTransactionTypeColumnName)
|
||||
accountName1, _ := dataset.getTransactionDataItemValue(transactionData, iifTransactionAccountNameColumnName)
|
||||
accountName2, _ := dataset.getSplitDataItemValue(transactionData.splitData[0], iifTransactionAccountNameColumnName)
|
||||
amount1, _ := dataset.getTransactionDataItemValue(transactionData, iifTransactionAmountColumnName)
|
||||
amount2, _ := dataset.getSplitDataItemValue(transactionData.splitData[0], iifTransactionAmountColumnName)
|
||||
amountNum1, err := utils.ParseAmount(strings.ReplaceAll(amount1, ",", ""))
|
||||
transactionType, _ := dataset.getSplitDataItemValue(transactionData.splitData[splitDataIndex], iifTransactionTypeColumnName)
|
||||
mainAccountName, _ := dataset.getTransactionDataItemValue(transactionData, iifTransactionAccountNameColumnName)
|
||||
splitAccountName, _ := dataset.getSplitDataItemValue(transactionData.splitData[splitDataIndex], iifTransactionAccountNameColumnName)
|
||||
mainAmount, _ := dataset.getTransactionDataItemValue(transactionData, iifTransactionAmountColumnName)
|
||||
splitAmount, _ := dataset.getSplitDataItemValue(transactionData.splitData[splitDataIndex], iifTransactionAmountColumnName)
|
||||
mainAmountNum, err := parseAmount(mainAmount)
|
||||
|
||||
if err != nil {
|
||||
return nil, errs.ErrAmountInvalid
|
||||
}
|
||||
|
||||
amountNum2, err := utils.ParseAmount(strings.ReplaceAll(amount2, ",", ""))
|
||||
splitAmountNum, err := parseAmount(splitAmount)
|
||||
|
||||
if err != nil {
|
||||
return nil, errs.ErrAmountInvalid
|
||||
@@ -208,24 +243,35 @@ func (t *iifTransactionDataRowIterator) parseTransaction(ctx core.Context, user
|
||||
|
||||
if transactionType == iifTransactionTypeBeginningBalance { // balance modification
|
||||
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = iifTransactionTypeNameMapping[models.TRANSACTION_TYPE_MODIFY_BALANCE]
|
||||
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = accountName1
|
||||
data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(amountNum1)
|
||||
} else if t.dataTable.incomeAccountNames[accountName1] || t.dataTable.incomeAccountNames[accountName2] { // income
|
||||
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = mainAccountName
|
||||
data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(mainAmountNum)
|
||||
} else if (t.dataTable.incomeAccountNames[mainAccountName] && !t.dataTable.incomeAccountNames[splitAccountName] && !t.dataTable.expenseAccountNames[splitAccountName]) ||
|
||||
(t.dataTable.incomeAccountNames[splitAccountName] && !t.dataTable.incomeAccountNames[mainAccountName] && !t.dataTable.expenseAccountNames[mainAccountName]) { // income
|
||||
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = iifTransactionTypeNameMapping[models.TRANSACTION_TYPE_INCOME]
|
||||
categoryName := ""
|
||||
accountName := ""
|
||||
amountNum := int64(0)
|
||||
|
||||
if t.dataTable.incomeAccountNames[accountName1] && !t.dataTable.incomeAccountNames[accountName2] {
|
||||
categoryName = accountName1
|
||||
accountName = accountName2
|
||||
amountNum = amountNum2
|
||||
} else if t.dataTable.incomeAccountNames[accountName2] && !t.dataTable.incomeAccountNames[accountName1] {
|
||||
categoryName = accountName2
|
||||
accountName = accountName1
|
||||
amountNum = amountNum1
|
||||
if t.dataTable.incomeAccountNames[mainAccountName] && !t.dataTable.incomeAccountNames[splitAccountName] {
|
||||
categoryName = mainAccountName
|
||||
accountName = splitAccountName
|
||||
|
||||
if len(transactionData.splitData) > 1 {
|
||||
amountNum = splitAmountNum
|
||||
} else {
|
||||
log.Errorf(ctx, "[iif_transaction_data_table.parseTransaction] cannot parse transaction, because two accounts \"%s\" and \"%s\" are all income account", accountName1, accountName2)
|
||||
amountNum = -mainAmountNum
|
||||
}
|
||||
} else if t.dataTable.incomeAccountNames[splitAccountName] && !t.dataTable.incomeAccountNames[mainAccountName] {
|
||||
categoryName = splitAccountName
|
||||
accountName = mainAccountName
|
||||
|
||||
if len(transactionData.splitData) > 1 {
|
||||
amountNum = -splitAmountNum
|
||||
} else {
|
||||
amountNum = mainAmountNum
|
||||
}
|
||||
} else {
|
||||
log.Errorf(ctx, "[iif_transaction_data_table.parseTransaction] cannot parse transaction, because main account \"%s\" and split account \"%s\" are all income account", mainAccountName, splitAccountName)
|
||||
return nil, errs.ErrInvalidIIFFile
|
||||
}
|
||||
|
||||
@@ -240,22 +286,33 @@ func (t *iifTransactionDataRowIterator) parseTransaction(ctx core.Context, user
|
||||
|
||||
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = accountName
|
||||
data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(amountNum)
|
||||
} else if t.dataTable.expenseAccountNames[accountName1] || t.dataTable.expenseAccountNames[accountName2] { // expense
|
||||
} else if (t.dataTable.expenseAccountNames[mainAccountName] && !t.dataTable.expenseAccountNames[splitAccountName] && !t.dataTable.incomeAccountNames[splitAccountName]) ||
|
||||
(t.dataTable.expenseAccountNames[splitAccountName] && !t.dataTable.expenseAccountNames[mainAccountName] && !t.dataTable.incomeAccountNames[mainAccountName]) { // expense
|
||||
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = iifTransactionTypeNameMapping[models.TRANSACTION_TYPE_EXPENSE]
|
||||
categoryName := ""
|
||||
accountName := ""
|
||||
amountNum := int64(0)
|
||||
|
||||
if t.dataTable.expenseAccountNames[accountName1] && !t.dataTable.expenseAccountNames[accountName2] {
|
||||
categoryName = accountName1
|
||||
accountName = accountName2
|
||||
amountNum = amountNum2
|
||||
} else if t.dataTable.expenseAccountNames[accountName2] && !t.dataTable.expenseAccountNames[accountName1] {
|
||||
categoryName = accountName2
|
||||
accountName = accountName1
|
||||
amountNum = amountNum1
|
||||
if t.dataTable.expenseAccountNames[mainAccountName] && !t.dataTable.expenseAccountNames[splitAccountName] {
|
||||
categoryName = mainAccountName
|
||||
accountName = splitAccountName
|
||||
|
||||
if len(transactionData.splitData) > 1 {
|
||||
amountNum = -splitAmountNum
|
||||
} else {
|
||||
log.Errorf(ctx, "[iif_transaction_data_table.parseTransaction] cannot parse transaction, because two accounts \"%s\" and \"%s\" are all expense account", accountName1, accountName2)
|
||||
amountNum = mainAmountNum
|
||||
}
|
||||
} else if t.dataTable.expenseAccountNames[splitAccountName] && !t.dataTable.expenseAccountNames[mainAccountName] {
|
||||
categoryName = splitAccountName
|
||||
accountName = mainAccountName
|
||||
|
||||
if len(transactionData.splitData) > 1 {
|
||||
amountNum = splitAmountNum
|
||||
} else {
|
||||
amountNum = -mainAmountNum
|
||||
}
|
||||
} else {
|
||||
log.Errorf(ctx, "[iif_transaction_data_table.parseTransaction] cannot parse transaction, because main account \"%s\" and split account \"%s\" are all expense account", mainAccountName, splitAccountName)
|
||||
return nil, errs.ErrInvalidIIFFile
|
||||
}
|
||||
|
||||
@@ -269,26 +326,57 @@ func (t *iifTransactionDataRowIterator) parseTransaction(ctx core.Context, user
|
||||
}
|
||||
|
||||
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = accountName
|
||||
data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(-amountNum)
|
||||
data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(amountNum)
|
||||
} else {
|
||||
data[datatable.TRANSACTION_DATA_TABLE_TRANSACTION_TYPE] = iifTransactionTypeNameMapping[models.TRANSACTION_TYPE_TRANSFER]
|
||||
data[datatable.TRANSACTION_DATA_TABLE_SUB_CATEGORY] = ""
|
||||
amountNum := int64(0)
|
||||
relatedAmountNum := int64(0)
|
||||
mainAccountTransferToSplitAccount := false
|
||||
|
||||
if amountNum1 >= 0 {
|
||||
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = accountName2
|
||||
data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(-amountNum2)
|
||||
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = accountName1
|
||||
data[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT] = utils.FormatAmount(amountNum1)
|
||||
} else if amountNum2 >= 0 {
|
||||
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = accountName1
|
||||
data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(-amountNum1)
|
||||
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = accountName2
|
||||
data[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT] = utils.FormatAmount(amountNum2)
|
||||
if len(transactionData.splitData) > 1 {
|
||||
amountNum = splitAmountNum
|
||||
relatedAmountNum = splitAmountNum
|
||||
mainAccountTransferToSplitAccount = amountNum >= 0
|
||||
} else {
|
||||
if mainAmountNum >= 0 {
|
||||
amountNum = splitAmountNum
|
||||
relatedAmountNum = mainAmountNum
|
||||
mainAccountTransferToSplitAccount = false
|
||||
} else if splitAmountNum >= 0 {
|
||||
amountNum = mainAmountNum
|
||||
relatedAmountNum = splitAmountNum
|
||||
mainAccountTransferToSplitAccount = true
|
||||
}
|
||||
}
|
||||
|
||||
if memo, _ := dataset.getTransactionDataItemValue(transactionData, iifTransactionMemoColumnName); memo != "" {
|
||||
if mainAccountTransferToSplitAccount {
|
||||
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = mainAccountName
|
||||
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = splitAccountName
|
||||
} else {
|
||||
data[datatable.TRANSACTION_DATA_TABLE_ACCOUNT_NAME] = splitAccountName
|
||||
data[datatable.TRANSACTION_DATA_TABLE_RELATED_ACCOUNT_NAME] = mainAccountName
|
||||
}
|
||||
|
||||
if amountNum >= 0 {
|
||||
data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(amountNum)
|
||||
} else {
|
||||
data[datatable.TRANSACTION_DATA_TABLE_AMOUNT] = utils.FormatAmount(-amountNum)
|
||||
}
|
||||
|
||||
if relatedAmountNum >= 0 {
|
||||
data[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT] = utils.FormatAmount(relatedAmountNum)
|
||||
} else {
|
||||
data[datatable.TRANSACTION_DATA_TABLE_RELATED_AMOUNT] = utils.FormatAmount(-relatedAmountNum)
|
||||
}
|
||||
}
|
||||
|
||||
if splitMemo, _ := dataset.getSplitDataItemValue(transactionData.splitData[splitDataIndex], iifTransactionMemoColumnName); splitMemo != "" {
|
||||
data[datatable.TRANSACTION_DATA_TABLE_DESCRIPTION] = splitMemo
|
||||
} else if memo, _ := dataset.getTransactionDataItemValue(transactionData, iifTransactionMemoColumnName); memo != "" {
|
||||
data[datatable.TRANSACTION_DATA_TABLE_DESCRIPTION] = memo
|
||||
} else if splitName, _ := dataset.getSplitDataItemValue(transactionData.splitData[splitDataIndex], iifTransactionNameColumnName); splitName != "" {
|
||||
data[datatable.TRANSACTION_DATA_TABLE_DESCRIPTION] = splitName
|
||||
} else if name, _ := dataset.getTransactionDataItemValue(transactionData, iifTransactionNameColumnName); name != "" {
|
||||
data[datatable.TRANSACTION_DATA_TABLE_DESCRIPTION] = name
|
||||
} else {
|
||||
@@ -298,6 +386,49 @@ func (t *iifTransactionDataRowIterator) parseTransaction(ctx core.Context, user
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (t *iifTransactionDataRowIterator) isSplitTransactionSupported(ctx core.Context, dataset *iifTransactionDataset, transactionData *iifTransactionData) (bool, error) {
|
||||
supportSplitTransactions := true
|
||||
transactionType, _ := dataset.getTransactionDataItemValue(transactionData, iifTransactionTypeColumnName)
|
||||
|
||||
if transactionType == iifTransactionTypeBeginningBalance { // balance modification
|
||||
supportSplitTransactions = false
|
||||
log.Errorf(ctx, "[iif_transaction_data_table.isSplitTransactionSupported] cannot parse split balance modification transaction#%d (dataset#%d)", t.currentIndexInDataset, t.currentDatasetIndex)
|
||||
} else {
|
||||
transactionAmountStr, _ := dataset.getTransactionDataItemValue(transactionData, iifTransactionAmountColumnName)
|
||||
transactionAmount, err := parseAmount(transactionAmountStr)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(ctx, "[iif_transaction_data_table.isSplitTransactionSupported] cannot parsing transaction in row#%d (dataset#%d), because transaction amount \"%s\" is invalid", t.currentIndexInDataset, t.currentDatasetIndex, transactionAmountStr)
|
||||
return false, errs.ErrAmountInvalid
|
||||
}
|
||||
|
||||
splitTotalAmount := int64(0)
|
||||
|
||||
for i := 0; i < len(transactionData.splitData); i++ {
|
||||
splitAmountStr, _ := dataset.getSplitDataItemValue(transactionData.splitData[i], iifTransactionAmountColumnName)
|
||||
splitAmount, err := parseAmount(splitAmountStr)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(ctx, "[iif_transaction_data_table.isSplitTransactionSupported] cannot parsing transaction in row#%d-split#%d (dataset#%d), because split amount \"%s\" is invalid", t.currentIndexInDataset, i, t.currentDatasetIndex, splitAmountStr)
|
||||
return false, errs.ErrAmountInvalid
|
||||
}
|
||||
|
||||
splitTotalAmount += splitAmount
|
||||
}
|
||||
|
||||
if splitTotalAmount != -transactionAmount {
|
||||
supportSplitTransactions = false
|
||||
log.Errorf(ctx, "[iif_transaction_data_table.isSplitTransactionSupported] cannot parse split transaction#%d (dataset#%d), because the sum amount of each split data \"%d\" not equal to the transaction amount \"%d\"", t.currentIndexInDataset, t.currentDatasetIndex, splitTotalAmount, -transactionAmount)
|
||||
}
|
||||
}
|
||||
|
||||
if len(transactionData.splitData) > 1 && !supportSplitTransactions {
|
||||
return false, errs.ErrNotSupportedSplitTransactions
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (t *iifTransactionDataRowIterator) parseTransactionTime(dataset *iifTransactionDataset, transactionData *iifTransactionData) (string, error) {
|
||||
date, _ := dataset.getTransactionDataItemValue(transactionData, iifTransactionDateColumnName)
|
||||
dateParts := strings.Split(date, "/")
|
||||
@@ -316,6 +447,10 @@ func (t *iifTransactionDataRowIterator) parseTransactionTime(dataset *iifTransac
|
||||
day = dateParts[2]
|
||||
}
|
||||
|
||||
if len(year) == 2 {
|
||||
year = utils.IntToString(time.Now().Year()/100) + year
|
||||
}
|
||||
|
||||
if len(month) < 2 {
|
||||
month = "0" + month
|
||||
}
|
||||
@@ -390,3 +525,7 @@ func getIncomeAndExpenseAccountNameMap(accountDatasets []*iifAccountDataset) (in
|
||||
|
||||
return incomeAccountNames, expenseAccountNames
|
||||
}
|
||||
|
||||
func parseAmount(amount string) (int64, error) {
|
||||
return utils.ParseAmount(strings.ReplaceAll(amount, ",", ""))
|
||||
}
|
||||
|
||||
@@ -20,8 +20,9 @@ import (
|
||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||
)
|
||||
|
||||
const ofxUnicodeEncoding = "unicode"
|
||||
const ofxUSAsciiEncoding = "usascii"
|
||||
const ofx1USAsciiEncoding = "usascii"
|
||||
const ofx1UnicodeEncoding = "unicode"
|
||||
const ofx1UTF8Encoding = "utf8" // non-standard ofx 1.x encoding, used by some banks (https://github.com/mayswind/ezbookkeeping/issues/48)
|
||||
const ofx1SGMLDataFormat = "OFXSGML"
|
||||
|
||||
var ofx2HeaderPattern = regexp.MustCompile("<\\?OFX( +[A-Z]+=\"[^=]*\")* *\\?>")
|
||||
@@ -231,7 +232,7 @@ func readOFX1FileHeader(ctx core.Context, data []byte) (fileHeader *ofxFileHeade
|
||||
}
|
||||
}
|
||||
|
||||
if fileEncoding == ofxUSAsciiEncoding {
|
||||
if fileEncoding == ofx1USAsciiEncoding {
|
||||
if utils.IsStringOnlyContainsDigits(fileCharset) {
|
||||
fileCharset = "cp" + fileCharset
|
||||
}
|
||||
@@ -245,12 +246,18 @@ func readOFX1FileHeader(ctx core.Context, data []byte) (fileHeader *ofxFileHeade
|
||||
if enc == nil {
|
||||
enc = charmap.Windows1252
|
||||
}
|
||||
} else if fileEncoding == ofxUnicodeEncoding {
|
||||
enc, _ = charset.Lookup(ofxUnicodeEncoding)
|
||||
} else if fileEncoding == ofx1UnicodeEncoding {
|
||||
enc, _ = charset.Lookup(ofx1UnicodeEncoding)
|
||||
|
||||
if enc == nil {
|
||||
enc = unicode.UTF16(unicode.LittleEndian, unicode.IgnoreBOM)
|
||||
}
|
||||
} else if fileEncoding == ofx1UTF8Encoding {
|
||||
enc, _ = charset.Lookup(ofx1UTF8Encoding)
|
||||
|
||||
if enc == nil {
|
||||
enc = unicode.UTF8
|
||||
}
|
||||
} else {
|
||||
log.Errorf(ctx, "[ofx_data_reader.readOFX1FileHeader] cannot parse ofx 1.x file, because encoding \"%s\" is unknown", fileEncoding)
|
||||
return nil, nil, "", nil, errs.ErrInvalidOFXFile
|
||||
|
||||
+3
-1
@@ -105,6 +105,7 @@ func (t *ofxTransactionDataRowIterator) Next(ctx core.Context, user *models.User
|
||||
rowItems, err := t.parseTransaction(ctx, user, data)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(ctx, "[ofx_transaction_table.Next] cannot parsing transaction in row#%d, because %s", t.currentIndex, err.Error())
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -151,9 +152,10 @@ func (t *ofxTransactionDataRowIterator) parseTransaction(ctx core.Context, user
|
||||
return nil, errs.ErrAmountInvalid
|
||||
}
|
||||
|
||||
amount, err := utils.ParseAmount(strings.ReplaceAll(ofxTransaction.Amount, ",", ".")) // ofx supports decimal point or comma to indicate the start of the fractional amount
|
||||
amount, err := utils.ParseAmount(utils.TrimTrailingZerosInDecimal(strings.ReplaceAll(ofxTransaction.Amount, ",", "."))) // ofx supports decimal point or comma to indicate the start of the fractional amount
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(ctx, "[ofx_transaction_table.parseTransaction] cannot parsing transaction amount \"%s\", because %s", ofxTransaction.Amount, err.Error())
|
||||
return nil, errs.ErrAmountInvalid
|
||||
}
|
||||
|
||||
@@ -105,6 +105,7 @@ func (t *qifTransactionDataRowIterator) Next(ctx core.Context, user *models.User
|
||||
rowItems, err := t.parseTransaction(ctx, user, data)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(ctx, "[qif_transaction_data_table.Next] cannot parsing transaction in row#%d, because %s", t.currentIndex, err.Error())
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,9 @@ package converters
|
||||
import (
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/alipay"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/base"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/datatable"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/default"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/dsv"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/feidee"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/fireflyIII"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/gnucash"
|
||||
@@ -12,6 +14,7 @@ import (
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/qif"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/converters/wechat"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
)
|
||||
|
||||
// GetTransactionDataExporter returns the transaction data exporter according to the file type
|
||||
@@ -61,3 +64,18 @@ func GetTransactionDataImporter(fileType string) (base.TransactionDataImporter,
|
||||
return nil, errs.ErrImportFileTypeNotSupported
|
||||
}
|
||||
}
|
||||
|
||||
// IsCustomDelimiterSeparatedValuesFileType returns whether the file type is the delimiter-separated values file type
|
||||
func IsCustomDelimiterSeparatedValuesFileType(fileType string) bool {
|
||||
return dsv.IsDelimiterSeparatedValuesFileType(fileType)
|
||||
}
|
||||
|
||||
// CreateNewDelimiterSeparatedValuesDataParser returns a new delimiter-separated values data parser according to the file type and encoding
|
||||
func CreateNewDelimiterSeparatedValuesDataParser(fileType string, fileEncoding string) (dsv.CustomTransactionDataDsvFileParser, error) {
|
||||
return dsv.CreateNewCustomTransactionDataDsvFileParser(fileType, fileEncoding)
|
||||
}
|
||||
|
||||
// CreateNewDelimiterSeparatedValuesDataImporter returns a new delimiter-separated values data importer according to the file type and encoding
|
||||
func CreateNewDelimiterSeparatedValuesDataImporter(fileType string, fileEncoding string, columnIndexMapping map[datatable.TransactionDataTableColumn]int, transactionTypeNameMapping map[string]models.TransactionType, hasHeaderLine bool, timeFormat string, timezoneFormat string, geoLocationSeparator string, transactionTagSeparator string) (base.TransactionDataImporter, error) {
|
||||
return dsv.CreateNewCustomTransactionDataDsvFileImporter(fileType, fileEncoding, columnIndexMapping, transactionTypeNameMapping, hasHeaderLine, timeFormat, timezoneFormat, geoLocationSeparator, transactionTagSeparator)
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ const (
|
||||
DECIMAL_SEPARATOR_DEFAULT DecimalSeparator = 0
|
||||
DECIMAL_SEPARATOR_DOT DecimalSeparator = 1
|
||||
DECIMAL_SEPARATOR_COMMA DecimalSeparator = 2
|
||||
DECIMAL_SEPARATOR_SPACE DecimalSeparator = 3
|
||||
DECIMAL_SEPARATOR_INVALID DecimalSeparator = 255
|
||||
)
|
||||
|
||||
@@ -25,8 +24,6 @@ func (f DecimalSeparator) String() string {
|
||||
return "Dot"
|
||||
case DECIMAL_SEPARATOR_COMMA:
|
||||
return "Comma"
|
||||
case DECIMAL_SEPARATOR_SPACE:
|
||||
return "Space"
|
||||
case DECIMAL_SEPARATOR_INVALID:
|
||||
return "Invalid"
|
||||
default:
|
||||
|
||||
@@ -15,22 +15,22 @@ type GocronLoggerAdapter struct {
|
||||
|
||||
// Debug logs debug log
|
||||
func (logger GocronLoggerAdapter) Debug(msg string, args ...any) {
|
||||
log.Debugf(core.NewNullContext(), logger.getFinalLog(msg, args...))
|
||||
log.Debugf(core.NewNullContext(), "%s", logger.getFinalLog(msg, args...))
|
||||
}
|
||||
|
||||
// Info logs info log
|
||||
func (logger GocronLoggerAdapter) Info(msg string, args ...any) {
|
||||
log.Infof(core.NewNullContext(), logger.getFinalLog(msg, args...))
|
||||
log.Infof(core.NewNullContext(), "%s", logger.getFinalLog(msg, args...))
|
||||
}
|
||||
|
||||
// Warn logs warn log
|
||||
func (logger GocronLoggerAdapter) Warn(msg string, args ...any) {
|
||||
log.Warnf(core.NewNullContext(), logger.getFinalLog(msg, args...))
|
||||
log.Warnf(core.NewNullContext(), "%s", logger.getFinalLog(msg, args...))
|
||||
}
|
||||
|
||||
// Error logs error log
|
||||
func (logger GocronLoggerAdapter) Error(msg string, args ...any) {
|
||||
log.Errorf(core.NewNullContext(), logger.getFinalLog(msg, args...))
|
||||
log.Errorf(core.NewNullContext(), "%s", logger.getFinalLog(msg, args...))
|
||||
}
|
||||
|
||||
func (logger GocronLoggerAdapter) getFinalLog(msg string, args ...any) string {
|
||||
|
||||
@@ -4,10 +4,12 @@ import (
|
||||
"xorm.io/xorm"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||
)
|
||||
|
||||
// Database represents a database instance
|
||||
type Database struct {
|
||||
databaseType string
|
||||
engineGroup *xorm.EngineGroup
|
||||
}
|
||||
|
||||
@@ -41,3 +43,23 @@ func (db *Database) DoTransaction(c core.Context, fn func(sess *xorm.Session) er
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetSavePoint sets a save point in the current transaction for Postgres
|
||||
func (db *Database) SetSavePoint(sess *xorm.Session, savePointName string) error {
|
||||
if db.databaseType == settings.PostgresDbType {
|
||||
_, err := sess.Exec("SAVEPOINT " + savePointName)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RollbackToSavePoint rolls back to the specified save point in the current transaction for Postgres
|
||||
func (db *Database) RollbackToSavePoint(sess *xorm.Session, savePointName string) error {
|
||||
if db.databaseType == settings.PostgresDbType {
|
||||
_, err := sess.Exec("ROLLBACK TO SAVEPOINT " + savePointName)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -104,6 +104,7 @@ func initializeDatabase(dbConfig *settings.DatabaseConfig) (*Database, error) {
|
||||
engineGroup.SetConnMaxLifetime(time.Duration(dbConfig.ConnectionMaxLifeTime) * time.Second)
|
||||
|
||||
return &Database{
|
||||
databaseType: dbConfig.DatabaseType,
|
||||
engineGroup: engineGroup,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -8,4 +8,6 @@ type DuplicateChecker interface {
|
||||
SetSubmissionRemark(checkerType DuplicateCheckerType, uid int64, identification string, remark string)
|
||||
GetOrSetCronJobRunningInfo(jobName string, runningInfo string, runningInterval time.Duration) (bool, string)
|
||||
RemoveCronJobRunningInfo(jobName string)
|
||||
GetFailureCount(failureKey string) uint32
|
||||
IncreaseFailureCount(failureKey string) uint32
|
||||
}
|
||||
|
||||
@@ -48,3 +48,13 @@ func (c *DuplicateCheckerContainer) GetOrSetCronJobRunningInfo(jobName string, r
|
||||
func (c *DuplicateCheckerContainer) RemoveCronJobRunningInfo(jobName string) {
|
||||
c.Current.RemoveCronJobRunningInfo(jobName)
|
||||
}
|
||||
|
||||
// GetFailureCount returns the failure count of the specified failure key
|
||||
func (c *DuplicateCheckerContainer) GetFailureCount(failureKey string) uint32 {
|
||||
return c.Current.GetFailureCount(failureKey)
|
||||
}
|
||||
|
||||
// IncreaseFailureCount increases the failure count of the specified failure key
|
||||
func (c *DuplicateCheckerContainer) IncreaseFailureCount(failureKey string) uint32 {
|
||||
return c.Current.IncreaseFailureCount(failureKey)
|
||||
}
|
||||
|
||||
@@ -12,4 +12,5 @@ const (
|
||||
DUPLICATE_CHECKER_TYPE_NEW_TEMPLATE DuplicateCheckerType = 4
|
||||
DUPLICATE_CHECKER_TYPE_NEW_PICTURE DuplicateCheckerType = 5
|
||||
DUPLICATE_CHECKER_TYPE_IMPORT_TRANSACTIONS DuplicateCheckerType = 6
|
||||
DUPLICATE_CHECKER_TYPE_FAILURE_CHECK DuplicateCheckerType = 255
|
||||
)
|
||||
|
||||
@@ -69,6 +69,34 @@ func (c *InMemoryDuplicateChecker) RemoveCronJobRunningInfo(jobName string) {
|
||||
c.cache.Delete(c.getCacheKey(DUPLICATE_CHECKER_TYPE_BACKGROUND_CRON_JOB, 0, jobName))
|
||||
}
|
||||
|
||||
// GetFailureCount returns the failure count of the specified failure key
|
||||
func (c *InMemoryDuplicateChecker) GetFailureCount(failureKey string) uint32 {
|
||||
existedFailureCount, found := c.cache.Get(c.getCacheKey(DUPLICATE_CHECKER_TYPE_FAILURE_CHECK, 0, failureKey))
|
||||
|
||||
if found {
|
||||
return existedFailureCount.(uint32)
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
// IncreaseFailureCount increases the failure count of the specified failure key
|
||||
func (c *InMemoryDuplicateChecker) IncreaseFailureCount(failureKey string) uint32 {
|
||||
c.mutex.Lock()
|
||||
defer c.mutex.Unlock()
|
||||
|
||||
cacheKey := c.getCacheKey(DUPLICATE_CHECKER_TYPE_FAILURE_CHECK, 0, failureKey)
|
||||
_, found := c.cache.Get(cacheKey)
|
||||
|
||||
if found {
|
||||
failureCount, _ := c.cache.IncrementUint32(cacheKey, uint32(1))
|
||||
return failureCount
|
||||
} else {
|
||||
c.cache.Set(cacheKey, uint32(1), 1*time.Minute)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
func (c *InMemoryDuplicateChecker) getCacheKey(checkerType DuplicateCheckerType, uid int64, identification string) string {
|
||||
return fmt.Sprintf("%d|%d|%s", checkerType, uid, identification)
|
||||
}
|
||||
|
||||
@@ -155,3 +155,77 @@ func TestGetOrSetRunningInfoConcurrent(t *testing.T) {
|
||||
|
||||
assert.Equal(t, uint32(999), setRunningInfoCount.Load())
|
||||
}
|
||||
|
||||
func TestGetFailureCount(t *testing.T) {
|
||||
checker, _ := NewInMemoryDuplicateChecker(&settings.Config{
|
||||
DuplicateSubmissionsIntervalDuration: time.Second,
|
||||
InMemoryDuplicateCheckerCleanupIntervalDuration: time.Second,
|
||||
})
|
||||
|
||||
failureKey := "127.0.0.1"
|
||||
|
||||
failureCount := checker.GetFailureCount(failureKey)
|
||||
assert.Equal(t, uint32(0), failureCount)
|
||||
|
||||
failureCount = checker.IncreaseFailureCount(failureKey)
|
||||
assert.Equal(t, uint32(1), failureCount)
|
||||
|
||||
failureCount = checker.GetFailureCount(failureKey)
|
||||
assert.Equal(t, uint32(1), failureCount)
|
||||
}
|
||||
|
||||
func TestIncreaseFailureCount(t *testing.T) {
|
||||
checker, _ := NewInMemoryDuplicateChecker(&settings.Config{
|
||||
DuplicateSubmissionsIntervalDuration: time.Second,
|
||||
InMemoryDuplicateCheckerCleanupIntervalDuration: time.Second,
|
||||
})
|
||||
|
||||
failureKey := "127.0.0.1"
|
||||
|
||||
failureCount := checker.IncreaseFailureCount(failureKey)
|
||||
assert.Equal(t, uint32(1), failureCount)
|
||||
|
||||
failureCount = checker.GetFailureCount(failureKey)
|
||||
assert.Equal(t, uint32(1), failureCount)
|
||||
|
||||
failureCount = checker.IncreaseFailureCount(failureKey)
|
||||
assert.Equal(t, uint32(2), failureCount)
|
||||
|
||||
failureCount = checker.GetFailureCount(failureKey)
|
||||
assert.Equal(t, uint32(2), failureCount)
|
||||
|
||||
failureCount = checker.IncreaseFailureCount(failureKey)
|
||||
assert.Equal(t, uint32(3), failureCount)
|
||||
|
||||
failureCount = checker.GetFailureCount(failureKey)
|
||||
assert.Equal(t, uint32(3), failureCount)
|
||||
}
|
||||
|
||||
func TestIncreaseFailureCountConcurrent(t *testing.T) {
|
||||
checker, _ := NewInMemoryDuplicateChecker(&settings.Config{
|
||||
DuplicateSubmissionsIntervalDuration: time.Second,
|
||||
InMemoryDuplicateCheckerCleanupIntervalDuration: time.Second,
|
||||
})
|
||||
|
||||
failureKey := "127.0.0.1"
|
||||
|
||||
concurrentCount := 10
|
||||
var waitGroup sync.WaitGroup
|
||||
|
||||
for routineIndex := 0; routineIndex < concurrentCount; routineIndex++ {
|
||||
waitGroup.Add(1)
|
||||
|
||||
go func(currentRoutineIndex int) {
|
||||
for cycle := 0; cycle < 10; cycle++ {
|
||||
checker.IncreaseFailureCount(failureKey)
|
||||
}
|
||||
|
||||
waitGroup.Done()
|
||||
}(routineIndex)
|
||||
}
|
||||
|
||||
waitGroup.Wait()
|
||||
|
||||
failureCount := checker.GetFailureCount(failureKey)
|
||||
assert.Equal(t, uint32(100), failureCount)
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ var (
|
||||
ErrNoFilesUpload = NewNormalError(NormalSubcategoryGlobal, 15, http.StatusBadRequest, "no files uploaded")
|
||||
ErrUploadedFileEmpty = NewNormalError(NormalSubcategoryGlobal, 16, http.StatusBadRequest, "uploaded file is empty")
|
||||
ErrExceedMaxUploadFileSize = NewNormalError(NormalSubcategoryGlobal, 17, http.StatusBadRequest, "uploaded file size exceeds the maximum allowed size")
|
||||
ErrFailureCountLimitReached = NewNormalError(NormalSubcategoryGlobal, 18, http.StatusBadRequest, "failure count exceeded maximum limit")
|
||||
)
|
||||
|
||||
// GetParameterInvalidMessage returns specific error message for invalid parameter error
|
||||
|
||||
@@ -35,4 +35,10 @@ var (
|
||||
ErrCannotAddTransactionBeforeBalanceModificationTransaction = NewSystemError(NormalSubcategoryTransaction, 28, http.StatusBadRequest, "cannot add transaction before balance modification transaction")
|
||||
ErrBalanceModificationTransactionCannotModifyTime = NewSystemError(NormalSubcategoryTransaction, 29, http.StatusBadRequest, "balance modification transaction cannot modify transaction time")
|
||||
ErrTransferTransactionAmountCannotBeLessThanZero = NewNormalError(NormalSubcategoryTransaction, 30, http.StatusBadRequest, "transfer transaction amount cannot be less than zero")
|
||||
ErrImportFileEncodingIsEmpty = NewSystemError(NormalSubcategoryTransaction, 31, http.StatusBadRequest, "import file encoding is empty")
|
||||
ErrImportFileEncodingNotSupported = NewSystemError(NormalSubcategoryTransaction, 32, http.StatusBadRequest, "import file encoding not supported")
|
||||
ErrImportFileColumnMappingInvalid = NewSystemError(NormalSubcategoryTransaction, 33, http.StatusBadRequest, "column mapping invalid")
|
||||
ErrImportFileTransactionTypeMappingInvalid = NewSystemError(NormalSubcategoryTransaction, 34, http.StatusBadRequest, "transaction type mapping invalid")
|
||||
ErrImportFileTransactionTimeFormatInvalid = NewSystemError(NormalSubcategoryTransaction, 35, http.StatusBadRequest, "transaction time format invalid")
|
||||
ErrImportFileTransactionTimezoneFormatInvalid = NewSystemError(NormalSubcategoryTransaction, 36, http.StatusBadRequest, "transaction time zone format invalid")
|
||||
)
|
||||
|
||||
@@ -10,4 +10,5 @@ var (
|
||||
ErrScheduledTransactionNotEnabled = NewNormalError(NormalSubcategoryTemplate, 3, http.StatusBadRequest, "scheduled transaction is not enabled")
|
||||
ErrScheduledTransactionFrequencyInvalid = NewNormalError(NormalSubcategoryTemplate, 4, http.StatusBadRequest, "scheduled transaction frequency is invalid")
|
||||
ErrTransactionTemplateHasTooManyTags = NewNormalError(NormalSubcategoryTemplate, 5, http.StatusBadRequest, "transaction template has too many tags")
|
||||
ErrScheduledTransactionTemplateStartDataLaterThanEndDate = NewNormalError(NormalSubcategoryTemplate, 6, http.StatusBadRequest, "scheduled transaction start date is later than end time")
|
||||
)
|
||||
|
||||
@@ -80,6 +80,8 @@ func (e *InternationalMonetaryFundDataSource) BuildRequests() ([]*http.Request,
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", "") // Do not set custom user agent
|
||||
|
||||
return []*http.Request{req}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -10,9 +10,21 @@ var DefaultLanguage = en
|
||||
// AllLanguages represents all the supported language
|
||||
// To add new languages, please refer to https://ezbookkeeping.mayswind.net/translating
|
||||
var AllLanguages = map[string]*LocaleInfo{
|
||||
"de": {
|
||||
Content: de,
|
||||
},
|
||||
"en": {
|
||||
Content: en,
|
||||
},
|
||||
"es": {
|
||||
Content: es,
|
||||
},
|
||||
"ja": {
|
||||
Content: ja,
|
||||
},
|
||||
"ru": {
|
||||
Content: ru,
|
||||
},
|
||||
"vi": {
|
||||
Content: vi,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package locales
|
||||
|
||||
import (
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
)
|
||||
|
||||
var de = &LocaleTextItems{
|
||||
DefaultTypes: &DefaultTypes{
|
||||
DecimalSeparator: core.DECIMAL_SEPARATOR_COMMA,
|
||||
DigitGroupingSymbol: core.DIGIT_GROUPING_SYMBOL_DOT,
|
||||
},
|
||||
DataConverterTextItems: &DataConverterTextItems{
|
||||
Alipay: "Alipay",
|
||||
WeChatWallet: "Wallet",
|
||||
},
|
||||
VerifyEmailTextItems: &VerifyEmailTextItems{
|
||||
Title: "E-Mail verifizieren",
|
||||
SalutationFormat: "Hallo %s,",
|
||||
DescriptionAboveBtn: "Bitte klicken Sie auf den untenstehenden Link, um Ihre E-Mail-Adresse zu bestätigen.",
|
||||
VerifyEmail: "E-Mail verifizieren",
|
||||
DescriptionBelowBtnFormat: "Wenn Sie kein %s Konto erstellt haben, ignorieren Sie bitte diese E-Mail. Wenn Sie den obigen Link nicht anklicken können, kopieren Sie bitte die obige URL und fügen Sie sie in Ihren Browser ein. Der Verifizierungslink wird nach %v Minuten ablaufen.",
|
||||
},
|
||||
ForgetPasswordMailTextItems: &ForgetPasswordMailTextItems{
|
||||
Title: "Passwort zurücksetzen",
|
||||
SalutationFormat: "Hallo %s,",
|
||||
DescriptionAboveBtn: "Wir haben kürzlich eine Anfrage zum Zurücksetzen Ihres Passworts erhalten. Sie können auf den untenstehenden Link klicken, um Ihr Passwort zurückzusetzen.",
|
||||
ResetPassword: "Passwort zurücksetzen",
|
||||
DescriptionBelowBtnFormat: "Wenn Sie nicht angefordert haben, Ihr Passwort zurückzusetzen, ignorieren Sie bitte diese E-Mail. Wenn Sie den obigen Link nicht anklicken können, kopieren Sie bitte die obige URL und fügen Sie sie in Ihren Browser ein. Der Link zum Zurücksetzen des Passworts wird nach %v Minuten ablaufen.",
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package locales
|
||||
|
||||
import (
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
)
|
||||
|
||||
var es = &LocaleTextItems{
|
||||
DefaultTypes: &DefaultTypes{
|
||||
DecimalSeparator: core.DECIMAL_SEPARATOR_DOT,
|
||||
DigitGroupingSymbol: core.DIGIT_GROUPING_SYMBOL_COMMA,
|
||||
},
|
||||
DataConverterTextItems: &DataConverterTextItems{
|
||||
Alipay: "Alipay",
|
||||
WeChatWallet: "Wallet",
|
||||
},
|
||||
VerifyEmailTextItems: &VerifyEmailTextItems{
|
||||
Title: "Verifique su cuenta de correo",
|
||||
SalutationFormat: "Hola %s,",
|
||||
DescriptionAboveBtn: "Por favor, haga click en el siguiente enlace para confirmar su cuenta de correo.",
|
||||
VerifyEmail: "Verificar correo",
|
||||
DescriptionBelowBtnFormat: "Si no registró una cuenta de %s, simplemente haga caso omiso a este correo. Si no puede hacer click en el link de verificación, copie la url arriba mostrada y péguela en su navegador. El enlace de verificación de correo expira pasados %v minutos.",
|
||||
},
|
||||
ForgetPasswordMailTextItems: &ForgetPasswordMailTextItems{
|
||||
Title: "Restablezca su Contraseña",
|
||||
SalutationFormat: "Hola %s,",
|
||||
DescriptionAboveBtn: "Hemos recibido una solicitud para restablecer su contraseña. Puede hacer click en el siguiente link para restablecer su contraseña.",
|
||||
ResetPassword: "Restablecer Contraseña",
|
||||
DescriptionBelowBtnFormat: "Si no solicitó un restablecimiento de contraseña, simplemente descarte este correo. Si no puede hacer click en el link anterior, copie la url arriba mostrada y péguela en su navegadror. El enlace de restablecimiento de contraseña expira pasados %v minutos.",
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package locales
|
||||
|
||||
import (
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
)
|
||||
|
||||
var ja = &LocaleTextItems{
|
||||
DefaultTypes: &DefaultTypes{
|
||||
DecimalSeparator: core.DECIMAL_SEPARATOR_DOT,
|
||||
DigitGroupingSymbol: core.DIGIT_GROUPING_SYMBOL_COMMA,
|
||||
},
|
||||
DataConverterTextItems: &DataConverterTextItems{
|
||||
Alipay: "Alipay",
|
||||
WeChatWallet: "Wallet",
|
||||
},
|
||||
VerifyEmailTextItems: &VerifyEmailTextItems{
|
||||
Title: "メールの確認",
|
||||
SalutationFormat: "こんにちは%s,",
|
||||
DescriptionAboveBtn: "次のリンクをクリックしてメールアドレスを確認してください。",
|
||||
VerifyEmail: "メールを確認",
|
||||
DescriptionBelowBtnFormat: "%sアカウントに登録していない場合はこのメールを無視してください。上記のリンクをクリックできない場合は上記のURLをコピーしてブラウザに貼り付けてください。メールの確認リンクは%v分後に期限切れになります。",
|
||||
},
|
||||
ForgetPasswordMailTextItems: &ForgetPasswordMailTextItems{
|
||||
Title: "パスワードのリセット",
|
||||
SalutationFormat: "こんにちは%s,",
|
||||
DescriptionAboveBtn: "パスワードリセットのリクエストを受け取りました。次のリンクをクリックしてパスワードをリセットしてください。",
|
||||
ResetPassword: "パスワードをリセット",
|
||||
DescriptionBelowBtnFormat: "パスワードのリセットをリクエストしていない場合はこのメールを無視してください。上記のリンクをクリックできない場合は、上記のURLをコピーしてブラウザに貼り付けてください。パスワードリセットのリンクは%v分後に期限切れになります。",
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package locales
|
||||
|
||||
import (
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
)
|
||||
|
||||
var ru = &LocaleTextItems{
|
||||
DefaultTypes: &DefaultTypes{
|
||||
DecimalSeparator: core.DECIMAL_SEPARATOR_DOT,
|
||||
DigitGroupingSymbol: core.DIGIT_GROUPING_SYMBOL_COMMA,
|
||||
},
|
||||
DataConverterTextItems: &DataConverterTextItems{
|
||||
Alipay: "Alipay",
|
||||
WeChatWallet: "Wallet",
|
||||
},
|
||||
VerifyEmailTextItems: &VerifyEmailTextItems{
|
||||
Title: "Подтвердите электронную почту",
|
||||
SalutationFormat: "Здравствуйте %s,",
|
||||
DescriptionAboveBtn: "Нажмите на ссылку ниже, чтобы подтвердить свой адрес электронной почты.",
|
||||
VerifyEmail: "Подтвердить электронную почту",
|
||||
DescriptionBelowBtnFormat: "Если вы не регистрировали учетную запись %s, просто проигнорируйте это письмо. Если вы не можете нажать на ссылку выше, скопируйте указанный выше URL и вставьте его в свой браузер. Срок действия ссылки для проверки электронной почты истечет через %v минут.",
|
||||
},
|
||||
ForgetPasswordMailTextItems: &ForgetPasswordMailTextItems{
|
||||
Title: "Сброс пароля",
|
||||
SalutationFormat: "Здравствуйте %s,",
|
||||
DescriptionAboveBtn: "Недавно мы получили запрос на сброс вашего пароля. Нажмите на ссылку ниже, чтобы сбросить свой пароль.",
|
||||
ResetPassword: "Сбросить пароль",
|
||||
DescriptionBelowBtnFormat: "Если вы не запрашивали сброс пароля, просто проигнорируйте это письмо. Если вы не можете нажать на ссылку выше, скопируйте указанный выше URL и вставьте его в браузер. Ссылка для сброса пароля истечет через %v минут.",
|
||||
},
|
||||
}
|
||||
+1
-1
@@ -141,7 +141,7 @@ func SetLoggerConfiguration(config *settings.Config, isDisableBootLog bool) erro
|
||||
return nil
|
||||
}
|
||||
|
||||
// DebugfWithRequestId logs debug log with custom format
|
||||
// Debugf logs debug log with custom format
|
||||
func Debugf(c core.Context, format string, args ...any) {
|
||||
if c == nil {
|
||||
defaultLogger.Debug(getFinalLog(format, args...))
|
||||
|
||||
@@ -64,7 +64,7 @@ type Account struct {
|
||||
Category AccountCategory `xorm:"NOT NULL"`
|
||||
Type AccountType `xorm:"NOT NULL"`
|
||||
ParentAccountId int64 `xorm:"INDEX(IDX_account_uid_deleted_parent_account_id_order) NOT NULL"`
|
||||
Name string `xorm:"VARCHAR(32) NOT NULL"`
|
||||
Name string `xorm:"VARCHAR(64) NOT NULL"`
|
||||
DisplayOrder int32 `xorm:"INDEX(IDX_account_uid_deleted_parent_account_id_order) NOT NULL"`
|
||||
Icon int64 `xorm:"NOT NULL"`
|
||||
Color string `xorm:"VARCHAR(6) NOT NULL"`
|
||||
@@ -85,7 +85,7 @@ type AccountExtend struct {
|
||||
|
||||
// AccountCreateRequest represents all parameters of account creation request
|
||||
type AccountCreateRequest struct {
|
||||
Name string `json:"name" binding:"required,notBlank,max=32"`
|
||||
Name string `json:"name" binding:"required,notBlank,max=64"`
|
||||
Category AccountCategory `json:"category" binding:"required"`
|
||||
Type AccountType `json:"type" binding:"required"`
|
||||
Icon int64 `json:"icon,string" binding:"required,min=1"`
|
||||
@@ -102,7 +102,7 @@ type AccountCreateRequest struct {
|
||||
// AccountModifyRequest represents all parameters of account modification request
|
||||
type AccountModifyRequest struct {
|
||||
Id int64 `json:"id,string" binding:"required,min=1"`
|
||||
Name string `json:"name" binding:"required,notBlank,max=32"`
|
||||
Name string `json:"name" binding:"required,notBlank,max=64"`
|
||||
Category AccountCategory `json:"category" binding:"required"`
|
||||
Icon int64 `json:"icon,string" binding:"min=1"`
|
||||
Color string `json:"color" binding:"required,len=6,validHexRGBColor"`
|
||||
|
||||
@@ -311,8 +311,8 @@ type TransactionStatisticResponseItem struct {
|
||||
TotalAmount int64 `json:"amount"`
|
||||
}
|
||||
|
||||
// TransactionStatisticTrendsItem represents the data within each statistic interval
|
||||
type TransactionStatisticTrendsItem struct {
|
||||
// TransactionStatisticTrendsResponseItem represents the data within each statistic interval
|
||||
type TransactionStatisticTrendsResponseItem struct {
|
||||
Year int32 `json:"year"`
|
||||
Month int32 `json:"month"`
|
||||
Items []*TransactionStatisticResponseItem `json:"items"`
|
||||
@@ -493,21 +493,21 @@ func (s TransactionInfoResponseSlice) Less(i, j int) bool {
|
||||
return s[i].Id > s[j].Id
|
||||
}
|
||||
|
||||
// TransactionStatisticTrendsItemSlice represents the slice data structure of TransactionStatisticTrendsItem
|
||||
type TransactionStatisticTrendsItemSlice []*TransactionStatisticTrendsItem
|
||||
// TransactionStatisticTrendsResponseItemSlice represents the slice data structure of TransactionStatisticTrendsResponseItem
|
||||
type TransactionStatisticTrendsResponseItemSlice []*TransactionStatisticTrendsResponseItem
|
||||
|
||||
// Len returns the count of items
|
||||
func (s TransactionStatisticTrendsItemSlice) Len() int {
|
||||
func (s TransactionStatisticTrendsResponseItemSlice) Len() int {
|
||||
return len(s)
|
||||
}
|
||||
|
||||
// Swap swaps two items
|
||||
func (s TransactionStatisticTrendsItemSlice) Swap(i, j int) {
|
||||
func (s TransactionStatisticTrendsResponseItemSlice) Swap(i, j int) {
|
||||
s[i], s[j] = s[j], s[i]
|
||||
}
|
||||
|
||||
// Less reports whether the first item is less than the second one
|
||||
func (s TransactionStatisticTrendsItemSlice) Less(i, j int) bool {
|
||||
func (s TransactionStatisticTrendsResponseItemSlice) Less(i, j int) bool {
|
||||
if s[i].Year != s[j].Year {
|
||||
return s[i].Year < s[j].Year
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ type TransactionCategory struct {
|
||||
Deleted bool `xorm:"INDEX(IDX_category_uid_deleted_type_parent_category_id_order) NOT NULL"`
|
||||
Type TransactionCategoryType `xorm:"INDEX(IDX_category_uid_deleted_type_parent_category_id_order) NOT NULL"`
|
||||
ParentCategoryId int64 `xorm:"INDEX(IDX_category_uid_deleted_type_parent_category_id_order) NOT NULL"`
|
||||
Name string `xorm:"VARCHAR(32) NOT NULL"`
|
||||
Name string `xorm:"VARCHAR(64) NOT NULL"`
|
||||
DisplayOrder int32 `xorm:"INDEX(IDX_category_uid_deleted_type_parent_category_id_order) NOT NULL"`
|
||||
Icon int64 `xorm:"NOT NULL"`
|
||||
Color string `xorm:"VARCHAR(6) NOT NULL"`
|
||||
@@ -44,7 +44,7 @@ type TransactionCategoryGetRequest struct {
|
||||
|
||||
// TransactionCategoryCreateRequest represents all parameters of single transaction category creation request
|
||||
type TransactionCategoryCreateRequest struct {
|
||||
Name string `json:"name" binding:"required,notBlank,max=32"`
|
||||
Name string `json:"name" binding:"required,notBlank,max=64"`
|
||||
Type TransactionCategoryType `json:"type" binding:"required"`
|
||||
ParentId int64 `json:"parentId,string" binding:"min=0"`
|
||||
Icon int64 `json:"icon,string" binding:"min=1"`
|
||||
@@ -60,7 +60,7 @@ type TransactionCategoryCreateBatchRequest struct {
|
||||
|
||||
// TransactionCategoryCreateWithSubCategories represents all parameters of multi transaction categories creation request
|
||||
type TransactionCategoryCreateWithSubCategories struct {
|
||||
Name string `json:"name" binding:"required,notBlank,max=32"`
|
||||
Name string `json:"name" binding:"required,notBlank,max=64"`
|
||||
Type TransactionCategoryType `json:"type" binding:"required"`
|
||||
Icon int64 `json:"icon,string" binding:"min=1"`
|
||||
Color string `json:"color" binding:"required,len=6,validHexRGBColor"`
|
||||
@@ -71,7 +71,7 @@ type TransactionCategoryCreateWithSubCategories struct {
|
||||
// TransactionCategoryModifyRequest represents all parameters of transaction category modification request
|
||||
type TransactionCategoryModifyRequest struct {
|
||||
Id int64 `json:"id,string" binding:"required,min=1"`
|
||||
Name string `json:"name" binding:"required,notBlank,max=32"`
|
||||
Name string `json:"name" binding:"required,notBlank,max=64"`
|
||||
ParentId int64 `json:"parentId,string" binding:"min=0"`
|
||||
Icon int64 `json:"icon,string" binding:"min=1"`
|
||||
Color string `json:"color" binding:"required,len=6,validHexRGBColor"`
|
||||
|
||||
@@ -5,7 +5,7 @@ type TransactionTag struct {
|
||||
TagId int64 `xorm:"PK"`
|
||||
Uid int64 `xorm:"INDEX(IDX_tag_uid_deleted_order) NOT NULL"`
|
||||
Deleted bool `xorm:"INDEX(IDX_tag_uid_deleted_order) NOT NULL"`
|
||||
Name string `xorm:"VARCHAR(32) NOT NULL"`
|
||||
Name string `xorm:"VARCHAR(64) NOT NULL"`
|
||||
DisplayOrder int32 `xorm:"INDEX(IDX_tag_uid_deleted_order) NOT NULL"`
|
||||
Hidden bool `xorm:"NOT NULL"`
|
||||
CreatedUnixTime int64
|
||||
@@ -20,13 +20,13 @@ type TransactionTagGetRequest struct {
|
||||
|
||||
// TransactionTagCreateRequest represents all parameters of transaction tag creation request
|
||||
type TransactionTagCreateRequest struct {
|
||||
Name string `json:"name" binding:"required,notBlank,max=32"`
|
||||
Name string `json:"name" binding:"required,notBlank,max=64"`
|
||||
}
|
||||
|
||||
// TransactionTagModifyRequest represents all parameters of transaction tag modification request
|
||||
type TransactionTagModifyRequest struct {
|
||||
Id int64 `json:"id,string" binding:"required,min=1"`
|
||||
Name string `json:"name" binding:"required,notBlank,max=32"`
|
||||
Name string `json:"name" binding:"required,notBlank,max=64"`
|
||||
}
|
||||
|
||||
// TransactionTagHideRequest represents all parameters of transaction tag hiding request
|
||||
|
||||
@@ -2,6 +2,7 @@ package models
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||
)
|
||||
@@ -29,15 +30,17 @@ const (
|
||||
type TransactionTemplate struct {
|
||||
TemplateId int64 `xorm:"PK"`
|
||||
Uid int64 `xorm:"INDEX(IDX_transaction_template_uid_deleted_template_type_order) NOT NULL"`
|
||||
Deleted bool `xorm:"INDEX(IDX_transaction_template_uid_deleted_template_type_order) INDEX(IDX_transaction_template_deleted_type_freqtype_scheduled_at) NOT NULL"`
|
||||
TemplateType TransactionTemplateType `xorm:"INDEX(IDX_transaction_template_uid_deleted_template_type_order) INDEX(IDX_transaction_template_deleted_type_freqtype_scheduled_at) NOT NULL"`
|
||||
Name string `xorm:"VARCHAR(32) NOT NULL"`
|
||||
Deleted bool `xorm:"INDEX(IDX_transaction_template_uid_deleted_template_type_order) INDEX(IDX_transaction_template_deleted_type_freqtype_scheduled_time) NOT NULL"`
|
||||
TemplateType TransactionTemplateType `xorm:"INDEX(IDX_transaction_template_uid_deleted_template_type_order) INDEX(IDX_transaction_template_deleted_type_freqtype_scheduled_time) NOT NULL"`
|
||||
Name string `xorm:"VARCHAR(64) NOT NULL"`
|
||||
Type TransactionType `xorm:"NOT NULL"`
|
||||
CategoryId int64 `xorm:"NOT NULL"`
|
||||
AccountId int64 `xorm:"NOT NULL"`
|
||||
ScheduledFrequencyType TransactionScheduleFrequencyType `xorm:"INDEX(IDX_transaction_template_deleted_type_freqtype_scheduled_at)"`
|
||||
ScheduledFrequencyType TransactionScheduleFrequencyType `xorm:"INDEX(IDX_transaction_template_deleted_type_freqtype_scheduled_time)"`
|
||||
ScheduledFrequency string `xorm:"VARCHAR(100)"`
|
||||
ScheduledAt int16 `xorm:"INDEX(IDX_transaction_template_deleted_type_freqtype_scheduled_at)"`
|
||||
ScheduledStartTime *int64 `xorm:"INDEX(IDX_transaction_template_deleted_type_freqtype_scheduled_time)"`
|
||||
ScheduledEndTime *int64 `xorm:"INDEX(IDX_transaction_template_deleted_type_freqtype_scheduled_time)"`
|
||||
ScheduledAt int16 `xorm:"INDEX(IDX_transaction_template_deleted_type_freqtype_scheduled_time)"`
|
||||
ScheduledTimezoneUtcOffset int16
|
||||
TagIds string `xorm:"VARCHAR(255) NOT NULL"`
|
||||
Amount int64 `xorm:"NOT NULL"`
|
||||
@@ -65,7 +68,7 @@ type TransactionTemplateGetRequest struct {
|
||||
// TransactionTemplateCreateRequest represents all parameters of transaction template creation request
|
||||
type TransactionTemplateCreateRequest struct {
|
||||
TemplateType TransactionTemplateType `json:"templateType"`
|
||||
Name string `json:"name" binding:"required,notBlank,max=32"`
|
||||
Name string `json:"name" binding:"required,notBlank,max=64"`
|
||||
Type TransactionType `json:"type" binding:"required"`
|
||||
CategoryId int64 `json:"categoryId,string" binding:"required,min=1"`
|
||||
SourceAccountId int64 `json:"sourceAccountId,string" binding:"required,min=1"`
|
||||
@@ -77,6 +80,8 @@ type TransactionTemplateCreateRequest struct {
|
||||
Comment string `json:"comment" binding:"max=255"`
|
||||
ScheduledFrequencyType *TransactionScheduleFrequencyType `json:"scheduledFrequencyType" binding:"omitempty"`
|
||||
ScheduledFrequency *string `json:"scheduledFrequency" binding:"omitempty"`
|
||||
ScheduledStartDate *string `json:"scheduledStartDate" binding:"omitempty"`
|
||||
ScheduledEndDate *string `json:"scheduledEndDate" binding:"omitempty"`
|
||||
ScheduledTimezoneUtcOffset *int16 `json:"utcOffset" binding:"omitempty,min=-720,max=840"`
|
||||
ClientSessionId string `json:"clientSessionId"`
|
||||
}
|
||||
@@ -84,13 +89,13 @@ type TransactionTemplateCreateRequest struct {
|
||||
// TransactionTemplateModifyNameRequest represents all parameters of transaction template name modification request
|
||||
type TransactionTemplateModifyNameRequest struct {
|
||||
Id int64 `json:"id,string" binding:"required,min=1"`
|
||||
Name string `json:"name" binding:"required,notBlank,max=32"`
|
||||
Name string `json:"name" binding:"required,notBlank,max=64"`
|
||||
}
|
||||
|
||||
// TransactionTemplateModifyRequest represents all parameters of transaction template modification request
|
||||
type TransactionTemplateModifyRequest struct {
|
||||
Id int64 `json:"id,string" binding:"required,min=1"`
|
||||
Name string `json:"name" binding:"required,notBlank,max=32"`
|
||||
Name string `json:"name" binding:"required,notBlank,max=64"`
|
||||
Type TransactionType `json:"type" binding:"required"`
|
||||
CategoryId int64 `json:"categoryId,string" binding:"required,min=1"`
|
||||
SourceAccountId int64 `json:"sourceAccountId,string" binding:"required,min=1"`
|
||||
@@ -102,6 +107,8 @@ type TransactionTemplateModifyRequest struct {
|
||||
Comment string `json:"comment" binding:"max=255"`
|
||||
ScheduledFrequencyType *TransactionScheduleFrequencyType `json:"scheduledFrequencyType" binding:"omitempty"`
|
||||
ScheduledFrequency *string `json:"scheduledFrequency" binding:"omitempty"`
|
||||
ScheduledStartDate *string `json:"scheduledStartDate" binding:"omitempty"`
|
||||
ScheduledEndDate *string `json:"scheduledEndDate" binding:"omitempty"`
|
||||
ScheduledTimezoneUtcOffset *int16 `json:"utcOffset" binding:"omitempty,min=-720,max=840"`
|
||||
}
|
||||
|
||||
@@ -133,6 +140,8 @@ type TransactionTemplateInfoResponse struct {
|
||||
Name string `json:"name"`
|
||||
ScheduledFrequencyType *TransactionScheduleFrequencyType `json:"scheduledFrequencyType,omitempty"`
|
||||
ScheduledFrequency *string `json:"scheduledFrequency,omitempty"`
|
||||
ScheduledStartDate *string `json:"scheduledStartDate" binding:"omitempty"`
|
||||
ScheduledEndDate *string `json:"scheduledEndDate" binding:"omitempty"`
|
||||
ScheduledAt *int16 `json:"scheduledAt,omitempty"`
|
||||
DisplayOrder int32 `json:"displayOrder"`
|
||||
Hidden bool `json:"hidden"`
|
||||
@@ -171,6 +180,18 @@ func (t *TransactionTemplate) ToTransactionTemplateInfoResponse(serverUtcOffset
|
||||
response.ScheduledFrequencyType = &t.ScheduledFrequencyType
|
||||
response.ScheduledFrequency = &t.ScheduledFrequency
|
||||
response.ScheduledAt = &t.ScheduledAt
|
||||
|
||||
templateTimeZone := time.FixedZone("Template Timezone", int(t.ScheduledTimezoneUtcOffset)*60)
|
||||
|
||||
if t.ScheduledStartTime != nil {
|
||||
startDate := utils.FormatUnixTimeToLongDate(*t.ScheduledStartTime, templateTimeZone)
|
||||
response.ScheduledStartDate = &startDate
|
||||
}
|
||||
|
||||
if t.ScheduledEndTime != nil {
|
||||
endDate := utils.FormatUnixTimeToLongDate(*t.ScheduledEndTime, templateTimeZone)
|
||||
response.ScheduledEndDate = &endDate
|
||||
}
|
||||
}
|
||||
|
||||
return response
|
||||
|
||||
@@ -127,25 +127,25 @@ func TestTransactionInfoResponseSliceLess(t *testing.T) {
|
||||
assert.Equal(t, int64(4), transactionRespSlice[4].Id)
|
||||
}
|
||||
|
||||
func TestTransactionStatisticTrendsItemSliceLess(t *testing.T) {
|
||||
var transactionTrendsSlice TransactionStatisticTrendsItemSlice
|
||||
transactionTrendsSlice = append(transactionTrendsSlice, &TransactionStatisticTrendsItem{
|
||||
func TestTransactionStatisticTrendsResponseItemSliceLess(t *testing.T) {
|
||||
var transactionTrendsSlice TransactionStatisticTrendsResponseItemSlice
|
||||
transactionTrendsSlice = append(transactionTrendsSlice, &TransactionStatisticTrendsResponseItem{
|
||||
Year: 2024,
|
||||
Month: 9,
|
||||
})
|
||||
transactionTrendsSlice = append(transactionTrendsSlice, &TransactionStatisticTrendsItem{
|
||||
transactionTrendsSlice = append(transactionTrendsSlice, &TransactionStatisticTrendsResponseItem{
|
||||
Year: 2022,
|
||||
Month: 10,
|
||||
})
|
||||
transactionTrendsSlice = append(transactionTrendsSlice, &TransactionStatisticTrendsItem{
|
||||
transactionTrendsSlice = append(transactionTrendsSlice, &TransactionStatisticTrendsResponseItem{
|
||||
Year: 2023,
|
||||
Month: 1,
|
||||
})
|
||||
transactionTrendsSlice = append(transactionTrendsSlice, &TransactionStatisticTrendsItem{
|
||||
transactionTrendsSlice = append(transactionTrendsSlice, &TransactionStatisticTrendsResponseItem{
|
||||
Year: 2022,
|
||||
Month: 2,
|
||||
})
|
||||
transactionTrendsSlice = append(transactionTrendsSlice, &TransactionStatisticTrendsItem{
|
||||
transactionTrendsSlice = append(transactionTrendsSlice, &TransactionStatisticTrendsResponseItem{
|
||||
Year: 2024,
|
||||
Month: 1,
|
||||
})
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/datastore"
|
||||
"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/uuid"
|
||||
@@ -270,7 +271,9 @@ func (s *AccountService) CreateAccounts(c core.Context, mainAccount *models.Acco
|
||||
}
|
||||
}
|
||||
|
||||
return s.UserDataDB(mainAccount.Uid).DoTransaction(c, func(sess *xorm.Session) error {
|
||||
userDataDb := s.UserDataDB(mainAccount.Uid)
|
||||
|
||||
return userDataDb.DoTransaction(c, func(sess *xorm.Session) error {
|
||||
for i := 0; i < len(allAccounts); i++ {
|
||||
account := allAccounts[i]
|
||||
_, err := sess.Insert(account)
|
||||
@@ -282,9 +285,25 @@ func (s *AccountService) CreateAccounts(c core.Context, mainAccount *models.Acco
|
||||
|
||||
for i := 0; i < len(allInitTransactions); i++ {
|
||||
transaction := allInitTransactions[i]
|
||||
|
||||
insertTransactionSavePointName := "insert_transaction"
|
||||
err := userDataDb.SetSavePoint(sess, insertTransactionSavePointName)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[accounts.CreateAccounts] failed to set save point \"%s\", because %s", insertTransactionSavePointName, err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
createdRows, err := sess.Insert(transaction)
|
||||
|
||||
if err != nil || createdRows < 1 { // maybe another transaction has same time
|
||||
err = userDataDb.RollbackToSavePoint(sess, insertTransactionSavePointName)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[accounts.CreateAccounts] failed to rollback to save point \"%s\", because %s", insertTransactionSavePointName, err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
sameSecondLatestTransaction := &models.Transaction{}
|
||||
minTransactionTime := utils.GetMinTransactionTimeFromUnixTime(utils.GetUnixTimeFromTransactionTime(transaction.TransactionTime))
|
||||
maxTransactionTime := utils.GetMaxTransactionTimeFromUnixTime(utils.GetUnixTimeFromTransactionTime(transaction.TransactionTime))
|
||||
|
||||
@@ -136,7 +136,7 @@ func (s *TransactionTemplateService) ModifyTemplate(c core.Context, template *mo
|
||||
template.UpdatedUnixTime = time.Now().Unix()
|
||||
|
||||
return s.UserDataDB(template.Uid).DoTransaction(c, func(sess *xorm.Session) error {
|
||||
updatedRows, err := sess.ID(template.TemplateId).Cols("name", "type", "category_id", "account_id", "scheduled_frequency_type", "scheduled_frequency", "scheduled_at", "scheduled_timezone_utc_offset", "tag_ids", "amount", "related_account_id", "related_account_amount", "hide_amount", "comment", "updated_unix_time").Where("uid=? AND deleted=?", template.Uid, false).Update(template)
|
||||
updatedRows, err := sess.ID(template.TemplateId).Cols("name", "type", "category_id", "account_id", "scheduled_frequency_type", "scheduled_frequency", "scheduled_start_time", "scheduled_end_time", "scheduled_at", "scheduled_timezone_utc_offset", "tag_ids", "amount", "related_account_id", "related_account_amount", "hide_amount", "comment", "updated_unix_time").Where("uid=? AND deleted=?", template.Uid, false).Update(template)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -252,8 +252,10 @@ func (s *TransactionService) CreateTransaction(c core.Context, transaction *mode
|
||||
UpdatedUnixTime: now,
|
||||
}
|
||||
|
||||
return s.UserDataDB(transaction.Uid).DoTransaction(c, func(sess *xorm.Session) error {
|
||||
return s.doCreateTransaction(sess, transaction, transactionTagIndexes, tagIds, pictureIds, pictureUpdateModel)
|
||||
userDataDb := s.UserDataDB(transaction.Uid)
|
||||
|
||||
return userDataDb.DoTransaction(c, func(sess *xorm.Session) error {
|
||||
return s.doCreateTransaction(c, userDataDb, sess, transaction, transactionTagIndexes, tagIds, pictureIds, pictureUpdateModel)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -355,12 +357,14 @@ func (s *TransactionService) BatchCreateTransactions(c core.Context, uid int64,
|
||||
allTransactionTagIds[transaction.TransactionId] = uniqueTagIds
|
||||
}
|
||||
|
||||
return s.UserDataDB(uid).DoTransaction(c, func(sess *xorm.Session) error {
|
||||
userDataDb := s.UserDataDB(uid)
|
||||
|
||||
return userDataDb.DoTransaction(c, func(sess *xorm.Session) error {
|
||||
for i := 0; i < len(transactions); i++ {
|
||||
transaction := transactions[i]
|
||||
transactionTagIndexes := allTransactionTagIndexes[transaction.TransactionId]
|
||||
transactionTagIds := allTransactionTagIds[transaction.TransactionId]
|
||||
err := s.doCreateTransaction(sess, transaction, transactionTagIndexes, transactionTagIds, nil, nil)
|
||||
err := s.doCreateTransaction(c, userDataDb, sess, transaction, transactionTagIndexes, transactionTagIds, nil, nil)
|
||||
|
||||
if err != nil {
|
||||
transactionUnixTime := utils.GetUnixTimeFromTransactionTime(transaction.TransactionTime)
|
||||
@@ -394,7 +398,7 @@ func (s *TransactionService) CreateScheduledTransactions(c core.Context, current
|
||||
|
||||
for i := 0; i < s.UserDataDBCount(); i++ {
|
||||
var templates []*models.TransactionTemplate
|
||||
err := s.UserDataDBByIndex(i).NewSession(c).Where("deleted=? AND template_type=? AND (scheduled_frequency_type=? OR scheduled_frequency_type=?) AND scheduled_at>=? AND scheduled_at<?", false, models.TRANSACTION_TEMPLATE_TYPE_SCHEDULE, models.TRANSACTION_SCHEDULE_FREQUENCY_TYPE_WEEKLY, models.TRANSACTION_SCHEDULE_FREQUENCY_TYPE_MONTHLY, minScheduledAt, maxScheduledAt).Find(&templates)
|
||||
err := s.UserDataDBByIndex(i).NewSession(c).Where("deleted=? AND template_type=? AND (scheduled_frequency_type=? OR scheduled_frequency_type=?) AND (scheduled_start_time IS NULL OR scheduled_start_time<=?) AND (scheduled_end_time IS NULL OR scheduled_end_time>=?) AND scheduled_at>=? AND scheduled_at<?", false, models.TRANSACTION_TEMPLATE_TYPE_SCHEDULE, models.TRANSACTION_SCHEDULE_FREQUENCY_TYPE_WEEKLY, models.TRANSACTION_SCHEDULE_FREQUENCY_TYPE_MONTHLY, startTime.Unix(), startTime.Unix(), minScheduledAt, maxScheduledAt).Find(&templates)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -453,6 +457,18 @@ func (s *TransactionService) CreateScheduledTransactions(c core.Context, current
|
||||
continue
|
||||
}
|
||||
|
||||
if template.ScheduledStartTime != nil && *template.ScheduledStartTime > transactionUnixTime {
|
||||
skipCount++
|
||||
log.Infof(c, "[transactions.CreateScheduledTransactions] transaction template \"id:%d\" does not need to create transaction, now is earlier than the start time %d", template.TemplateId, *template.ScheduledStartTime)
|
||||
continue
|
||||
}
|
||||
|
||||
if template.ScheduledEndTime != nil && *template.ScheduledEndTime < transactionUnixTime {
|
||||
skipCount++
|
||||
log.Infof(c, "[transactions.CreateScheduledTransactions] transaction template \"id:%d\" does not need to create transaction, now is later than the end time %d", template.TemplateId, *template.ScheduledEndTime)
|
||||
continue
|
||||
}
|
||||
|
||||
var transactionDbType models.TransactionDbType
|
||||
|
||||
if template.Type == models.TRANSACTION_TYPE_EXPENSE {
|
||||
@@ -546,6 +562,7 @@ func (s *TransactionService) ModifyTransaction(c core.Context, transaction *mode
|
||||
has, err := sess.ID(transaction.TransactionId).Where("uid=? AND deleted=?", transaction.Uid, false).Get(oldTransaction)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.ModifyTransaction] failed to get current transaction, because %s", err.Error())
|
||||
return err
|
||||
} else if !has {
|
||||
return errs.ErrTransactionNotFound
|
||||
@@ -568,6 +585,7 @@ func (s *TransactionService) ModifyTransaction(c core.Context, transaction *mode
|
||||
sourceAccount, destinationAccount, err := s.getAccountModels(sess, transaction)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.ModifyTransaction] failed to get account, because %s", err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -592,6 +610,7 @@ func (s *TransactionService) ModifyTransaction(c core.Context, transaction *mode
|
||||
oldSourceAccount, oldDestinationAccount, err := s.getOldAccountModels(sess, transaction, oldTransaction, sourceAccount, destinationAccount)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.ModifyTransaction] failed to get old account, because %s", err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -625,6 +644,7 @@ func (s *TransactionService) ModifyTransaction(c core.Context, transaction *mode
|
||||
has, err = sess.Where("uid=? AND deleted=? AND transaction_time>=? AND transaction_time<=?", transaction.Uid, false, minTransactionTime, maxTransactionTime).OrderBy("transaction_time desc").Limit(1).Get(sameSecondLatestTransaction)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.ModifyTransaction] failed to get trasaction time, because %s", err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -706,6 +726,7 @@ func (s *TransactionService) ModifyTransaction(c core.Context, transaction *mode
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.ModifyTransaction] failed to get whether other transactions exist, because %s", err.Error())
|
||||
return err
|
||||
} else if otherTransactionExists {
|
||||
return errs.ErrCannotAddTransactionBeforeBalanceModificationTransaction
|
||||
@@ -716,6 +737,7 @@ func (s *TransactionService) ModifyTransaction(c core.Context, transaction *mode
|
||||
updatedRows, err := sess.ID(transaction.TransactionId).Cols(updateCols...).Where("uid=? AND deleted=?", transaction.Uid, false).Update(transaction)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.ModifyTransaction] failed to update transaction, because %s", err.Error())
|
||||
return err
|
||||
} else if updatedRows < 1 {
|
||||
return errs.ErrTransactionNotFound
|
||||
@@ -732,6 +754,7 @@ func (s *TransactionService) ModifyTransaction(c core.Context, transaction *mode
|
||||
updatedRows, err := sess.ID(relatedTransaction.TransactionId).Cols(relatedUpdateCols...).Where("uid=? AND deleted=?", relatedTransaction.Uid, false).Update(relatedTransaction)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.ModifyTransaction] failed to update related transaction, because %s", err.Error())
|
||||
return err
|
||||
} else if updatedRows < 1 {
|
||||
return errs.ErrDatabaseOperationFailed
|
||||
@@ -748,6 +771,7 @@ func (s *TransactionService) ModifyTransaction(c core.Context, transaction *mode
|
||||
deletedRows, err := sess.Cols("deleted", "deleted_unix_time").Where("uid=? AND deleted=? AND transaction_id=?", transaction.Uid, false, transaction.TransactionId).In("tag_id", removeTagIds).Update(tagIndexUpdateModel)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.ModifyTransaction] failed to remove old transaction tag index, because %s", err.Error())
|
||||
return err
|
||||
} else if deletedRows < 1 {
|
||||
return errs.ErrTransactionTagNotFound
|
||||
@@ -762,6 +786,7 @@ func (s *TransactionService) ModifyTransaction(c core.Context, transaction *mode
|
||||
_, err := sess.Insert(transactionTagIndex)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.ModifyTransaction] failed to add new transaction tag index, because %s", err.Error())
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -773,6 +798,7 @@ func (s *TransactionService) ModifyTransaction(c core.Context, transaction *mode
|
||||
_, err := sess.Where("uid=? AND deleted=? AND transaction_id=?", transaction.Uid, false, transaction.TransactionId).Update(tagIndexUpdateModel)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.ModifyTransaction] failed to update transaction tag index, because %s", err.Error())
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -787,6 +813,7 @@ func (s *TransactionService) ModifyTransaction(c core.Context, transaction *mode
|
||||
deletedRows, err := sess.Cols("deleted", "deleted_unix_time").Where("uid=? AND deleted=? AND transaction_id=?", transaction.Uid, false, transaction.TransactionId).In("picture_id", removePictureIds).Update(pictureUpdateModel)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.ModifyTransaction] failed to remove old transaction picture info, because %s", err.Error())
|
||||
return err
|
||||
} else if deletedRows < 1 {
|
||||
return errs.ErrTransactionPictureNotFound
|
||||
@@ -802,6 +829,7 @@ func (s *TransactionService) ModifyTransaction(c core.Context, transaction *mode
|
||||
_, err = sess.Cols("transaction_id", "updated_unix_time").Where("uid=? AND deleted=? AND transaction_id=?", transaction.Uid, false, models.TransactionPictureNewPictureTransactionId).In("picture_id", addPictureIds).Update(pictureUpdateModel)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.ModifyTransaction] failed to update new transaction picture info, because %s", err.Error())
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -817,6 +845,7 @@ func (s *TransactionService) ModifyTransaction(c core.Context, transaction *mode
|
||||
updatedRows, err := sess.ID(sourceAccount.AccountId).SetExpr("balance", fmt.Sprintf("balance-(%d)+(%d)", oldTransaction.RelatedAccountAmount, transaction.RelatedAccountAmount)).Cols("updated_unix_time").Where("uid=? AND deleted=?", sourceAccount.Uid, false).Update(sourceAccount)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.ModifyTransaction] failed to update account balance, because %s", err.Error())
|
||||
return err
|
||||
} else if updatedRows < 1 {
|
||||
return errs.ErrDatabaseOperationFailed
|
||||
@@ -837,6 +866,7 @@ func (s *TransactionService) ModifyTransaction(c core.Context, transaction *mode
|
||||
updatedRows, err := sess.ID(oldSourceAccount.AccountId).SetExpr("balance", fmt.Sprintf("balance-(%d)+(%d)", oldTransaction.Amount, oldAccountNewAmount)).Cols("updated_unix_time").Where("uid=? AND deleted=?", oldSourceAccount.Uid, false).Update(oldSourceAccount)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.ModifyTransaction] failed to update account balance, because %s", err.Error())
|
||||
return err
|
||||
} else if updatedRows < 1 {
|
||||
return errs.ErrDatabaseOperationFailed
|
||||
@@ -848,6 +878,7 @@ func (s *TransactionService) ModifyTransaction(c core.Context, transaction *mode
|
||||
updatedRows, err := sess.ID(sourceAccount.AccountId).SetExpr("balance", fmt.Sprintf("balance+(%d)", newAccountNewAmount)).Cols("updated_unix_time").Where("uid=? AND deleted=?", sourceAccount.Uid, false).Update(sourceAccount)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.ModifyTransaction] failed to update account balance, because %s", err.Error())
|
||||
return err
|
||||
} else if updatedRows < 1 {
|
||||
return errs.ErrDatabaseOperationFailed
|
||||
@@ -868,6 +899,7 @@ func (s *TransactionService) ModifyTransaction(c core.Context, transaction *mode
|
||||
updatedRows, err := sess.ID(oldSourceAccount.AccountId).SetExpr("balance", fmt.Sprintf("balance+(%d)-(%d)", oldTransaction.Amount, oldAccountNewAmount)).Cols("updated_unix_time").Where("uid=? AND deleted=?", oldSourceAccount.Uid, false).Update(oldSourceAccount)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.ModifyTransaction] failed to update account balance, because %s", err.Error())
|
||||
return err
|
||||
} else if updatedRows < 1 {
|
||||
return errs.ErrDatabaseOperationFailed
|
||||
@@ -879,6 +911,7 @@ func (s *TransactionService) ModifyTransaction(c core.Context, transaction *mode
|
||||
updatedRows, err := sess.ID(sourceAccount.AccountId).SetExpr("balance", fmt.Sprintf("balance-(%d)", newAccountNewAmount)).Cols("updated_unix_time").Where("uid=? AND deleted=?", sourceAccount.Uid, false).Update(sourceAccount)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.ModifyTransaction] failed to update account balance, because %s", err.Error())
|
||||
return err
|
||||
} else if updatedRows < 1 {
|
||||
return errs.ErrDatabaseOperationFailed
|
||||
@@ -899,6 +932,7 @@ func (s *TransactionService) ModifyTransaction(c core.Context, transaction *mode
|
||||
updatedRows, err := sess.ID(oldSourceAccount.AccountId).SetExpr("balance", fmt.Sprintf("balance+(%d)-(%d)", oldTransaction.Amount, oldSourceAccountNewAmount)).Cols("updated_unix_time").Where("uid=? AND deleted=?", oldSourceAccount.Uid, false).Update(oldSourceAccount)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.ModifyTransaction] failed to update account balance, because %s", err.Error())
|
||||
return err
|
||||
} else if updatedRows < 1 {
|
||||
return errs.ErrDatabaseOperationFailed
|
||||
@@ -910,6 +944,7 @@ func (s *TransactionService) ModifyTransaction(c core.Context, transaction *mode
|
||||
updatedRows, err := sess.ID(sourceAccount.AccountId).SetExpr("balance", fmt.Sprintf("balance-(%d)", newSourceAccountNewAmount)).Cols("updated_unix_time").Where("uid=? AND deleted=?", sourceAccount.Uid, false).Update(sourceAccount)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.ModifyTransaction] failed to update account balance, because %s", err.Error())
|
||||
return err
|
||||
} else if updatedRows < 1 {
|
||||
return errs.ErrDatabaseOperationFailed
|
||||
@@ -930,6 +965,7 @@ func (s *TransactionService) ModifyTransaction(c core.Context, transaction *mode
|
||||
updatedRows, err := sess.ID(oldDestinationAccount.AccountId).SetExpr("balance", fmt.Sprintf("balance-(%d)+(%d)", oldTransaction.RelatedAccountAmount, oldDestinationAccountNewAmount)).Cols("updated_unix_time").Where("uid=? AND deleted=?", oldDestinationAccount.Uid, false).Update(oldDestinationAccount)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.ModifyTransaction] failed to update account balance, because %s", err.Error())
|
||||
return err
|
||||
} else if updatedRows < 1 {
|
||||
return errs.ErrDatabaseOperationFailed
|
||||
@@ -941,6 +977,7 @@ func (s *TransactionService) ModifyTransaction(c core.Context, transaction *mode
|
||||
updatedRows, err := sess.ID(destinationAccount.AccountId).SetExpr("balance", fmt.Sprintf("balance+(%d)", newDestinationAccountNewAmount)).Cols("updated_unix_time").Where("uid=? AND deleted=?", destinationAccount.Uid, false).Update(destinationAccount)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.ModifyTransaction] failed to update account balance, because %s", err.Error())
|
||||
return err
|
||||
} else if updatedRows < 1 {
|
||||
return errs.ErrDatabaseOperationFailed
|
||||
@@ -1541,7 +1578,7 @@ func (s *TransactionService) GetTransactionIds(transactions []*models.Transactio
|
||||
return transactionIds
|
||||
}
|
||||
|
||||
func (s *TransactionService) doCreateTransaction(sess *xorm.Session, transaction *models.Transaction, transactionTagIndexes []*models.TransactionTagIndex, tagIds []int64, pictureIds []int64, pictureUpdateModel *models.TransactionPictureInfo) error {
|
||||
func (s *TransactionService) doCreateTransaction(c core.Context, database *datastore.Database, sess *xorm.Session, transaction *models.Transaction, transactionTagIndexes []*models.TransactionTagIndex, tagIds []int64, pictureIds []int64, pictureUpdateModel *models.TransactionPictureInfo) error {
|
||||
// Get and verify source and destination account
|
||||
sourceAccount, destinationAccount, err := s.getAccountModels(sess, transaction)
|
||||
|
||||
@@ -1593,6 +1630,7 @@ func (s *TransactionService) doCreateTransaction(sess *xorm.Session, transaction
|
||||
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
|
||||
@@ -1610,6 +1648,7 @@ func (s *TransactionService) doCreateTransaction(sess *xorm.Session, 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.ErrCannotAddTransactionBeforeBalanceModificationTransaction
|
||||
@@ -1623,9 +1662,30 @@ func (s *TransactionService) doCreateTransaction(sess *xorm.Session, transaction
|
||||
relatedTransaction = s.GetRelatedTransferTransaction(transaction)
|
||||
}
|
||||
|
||||
insertTransactionSavePointName := "insert_transaction"
|
||||
err = database.SetSavePoint(sess, insertTransactionSavePointName)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.doCreateTransaction] failed to set save point \"%s\", because %s", insertTransactionSavePointName, err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
createdRows, err := sess.Insert(transaction)
|
||||
|
||||
if err != nil || createdRows < 1 { // maybe another transaction has same time
|
||||
if err != nil {
|
||||
log.Warnf(c, "[transactions.doCreateTransaction] cannot create trasaction, because %s, regenerate transaction time value", err.Error())
|
||||
} else {
|
||||
log.Warnf(c, "[transactions.doCreateTransaction] cannot create trasaction, regenerate transaction time value")
|
||||
}
|
||||
|
||||
err = database.RollbackToSavePoint(sess, insertTransactionSavePointName)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.doCreateTransaction] failed to rollback to save point \"%s\", because %s", insertTransactionSavePointName, err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
sameSecondLatestTransaction := &models.Transaction{}
|
||||
minTransactionTime := utils.GetMinTransactionTimeFromUnixTime(utils.GetUnixTimeFromTransactionTime(transaction.TransactionTime))
|
||||
maxTransactionTime := utils.GetMaxTransactionTimeFromUnixTime(utils.GetUnixTimeFromTransactionTime(transaction.TransactionTime))
|
||||
@@ -1633,6 +1693,7 @@ func (s *TransactionService) doCreateTransaction(sess *xorm.Session, transaction
|
||||
has, err := sess.Where("uid=? AND transaction_time>=? AND transaction_time<=?", transaction.Uid, minTransactionTime, maxTransactionTime).OrderBy("transaction_time desc").Limit(1).Get(sameSecondLatestTransaction)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.doCreateTransaction] failed to get trasaction time, because %s", err.Error())
|
||||
return err
|
||||
} else if !has {
|
||||
return errs.ErrDatabaseOperationFailed
|
||||
@@ -1644,6 +1705,7 @@ func (s *TransactionService) doCreateTransaction(sess *xorm.Session, transaction
|
||||
createdRows, err := sess.Insert(transaction)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.doCreateTransaction] failed to add transaction again, because %s", err.Error())
|
||||
return err
|
||||
} else if createdRows < 1 {
|
||||
return errs.ErrDatabaseOperationFailed
|
||||
@@ -1660,6 +1722,7 @@ func (s *TransactionService) doCreateTransaction(sess *xorm.Session, transaction
|
||||
createdRows, err := sess.Insert(relatedTransaction)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.doCreateTransaction] failed to add related transaction, because %s", err.Error())
|
||||
return err
|
||||
} else if createdRows < 1 {
|
||||
return errs.ErrDatabaseOperationFailed
|
||||
@@ -1677,6 +1740,7 @@ func (s *TransactionService) doCreateTransaction(sess *xorm.Session, transaction
|
||||
_, err := sess.Insert(transactionTagIndex)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.doCreateTransaction] failed to add transaction tag index, because %s", err.Error())
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -1687,6 +1751,7 @@ func (s *TransactionService) doCreateTransaction(sess *xorm.Session, transaction
|
||||
_, err = sess.Cols("transaction_id", "updated_unix_time").Where("uid=? AND deleted=? AND transaction_id=?", transaction.Uid, false, models.TransactionPictureNewPictureTransactionId).In("picture_id", pictureIds).Update(pictureUpdateModel)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.doCreateTransaction] failed to update transaction picture info, because %s", err.Error())
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -1697,6 +1762,7 @@ func (s *TransactionService) doCreateTransaction(sess *xorm.Session, transaction
|
||||
updatedRows, err := sess.ID(sourceAccount.AccountId).SetExpr("balance", fmt.Sprintf("balance+(%d)", transaction.RelatedAccountAmount)).Cols("updated_unix_time").Where("uid=? AND deleted=?", sourceAccount.Uid, false).Update(sourceAccount)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.doCreateTransaction] failed to update account balance, because %s", err.Error())
|
||||
return err
|
||||
} else if updatedRows < 1 {
|
||||
return errs.ErrDatabaseOperationFailed
|
||||
@@ -1706,6 +1772,7 @@ func (s *TransactionService) doCreateTransaction(sess *xorm.Session, transaction
|
||||
updatedRows, err := sess.ID(sourceAccount.AccountId).SetExpr("balance", fmt.Sprintf("balance+(%d)", transaction.Amount)).Cols("updated_unix_time").Where("uid=? AND deleted=?", sourceAccount.Uid, false).Update(sourceAccount)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.doCreateTransaction] failed to update account balance, because %s", err.Error())
|
||||
return err
|
||||
} else if updatedRows < 1 {
|
||||
return errs.ErrDatabaseOperationFailed
|
||||
@@ -1715,6 +1782,7 @@ func (s *TransactionService) doCreateTransaction(sess *xorm.Session, transaction
|
||||
updatedRows, err := sess.ID(sourceAccount.AccountId).SetExpr("balance", fmt.Sprintf("balance-(%d)", transaction.Amount)).Cols("updated_unix_time").Where("uid=? AND deleted=?", sourceAccount.Uid, false).Update(sourceAccount)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.doCreateTransaction] failed to update account balance, because %s", err.Error())
|
||||
return err
|
||||
} else if updatedRows < 1 {
|
||||
return errs.ErrDatabaseOperationFailed
|
||||
@@ -1724,6 +1792,7 @@ func (s *TransactionService) doCreateTransaction(sess *xorm.Session, transaction
|
||||
updatedSourceRows, err := sess.ID(sourceAccount.AccountId).SetExpr("balance", fmt.Sprintf("balance-(%d)", transaction.Amount)).Cols("updated_unix_time").Where("uid=? AND deleted=?", sourceAccount.Uid, false).Update(sourceAccount)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.doCreateTransaction] failed to update account balance, because %s", err.Error())
|
||||
return err
|
||||
} else if updatedSourceRows < 1 {
|
||||
return errs.ErrDatabaseOperationFailed
|
||||
@@ -1733,6 +1802,7 @@ func (s *TransactionService) doCreateTransaction(sess *xorm.Session, transaction
|
||||
updatedDestinationRows, err := sess.ID(destinationAccount.AccountId).SetExpr("balance", fmt.Sprintf("balance+(%d)", transaction.RelatedAccountAmount)).Cols("updated_unix_time").Where("uid=? AND deleted=?", destinationAccount.Uid, false).Update(destinationAccount)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[transactions.doCreateTransaction] failed to update account balance, because %s", err.Error())
|
||||
return err
|
||||
} else if updatedDestinationRows < 1 {
|
||||
return errs.ErrDatabaseOperationFailed
|
||||
@@ -1913,7 +1983,7 @@ func (s *TransactionService) appendFilterTagIdsConditionToQuery(sess *xorm.Sessi
|
||||
|
||||
if noTags {
|
||||
subQuery := builder.Select("transaction_id").From("transaction_tag_index").Where(subQueryCondition)
|
||||
sess.NotIn("transaction_id", subQuery)
|
||||
sess.NotIn("transaction_id", subQuery).NotIn("related_id", subQuery)
|
||||
return sess
|
||||
}
|
||||
|
||||
@@ -1929,9 +1999,9 @@ func (s *TransactionService) appendFilterTagIdsConditionToQuery(sess *xorm.Sessi
|
||||
}
|
||||
|
||||
if tagFilterType == models.TRANSACTION_TAG_FILTER_HAS_ANY || tagFilterType == models.TRANSACTION_TAG_FILTER_HAS_ALL {
|
||||
sess.In("transaction_id", subQuery)
|
||||
sess.And(builder.Or(builder.In("transaction_id", subQuery), builder.In("related_id", subQuery)))
|
||||
} else if tagFilterType == models.TRANSACTION_TAG_FILTER_NOT_HAS_ANY || tagFilterType == models.TRANSACTION_TAG_FILTER_NOT_HAS_ALL {
|
||||
sess.NotIn("transaction_id", subQuery)
|
||||
sess.NotIn("transaction_id", subQuery).NotIn("related_id", subQuery)
|
||||
}
|
||||
|
||||
return sess
|
||||
|
||||
@@ -58,7 +58,7 @@ var (
|
||||
)
|
||||
|
||||
// GetUserByUsernameOrEmailAndPassword returns the user model according to login name and password
|
||||
func (s *UserService) GetUserByUsernameOrEmailAndPassword(c core.Context, loginname string, password string) (*models.User, error) {
|
||||
func (s *UserService) GetUserByUsernameOrEmailAndPassword(c core.Context, loginname string, password string) (*models.User, int64, error) {
|
||||
var user *models.User
|
||||
var err error
|
||||
|
||||
@@ -71,14 +71,18 @@ func (s *UserService) GetUserByUsernameOrEmailAndPassword(c core.Context, loginn
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
return nil, 0, errs.ErrUserNotFound
|
||||
}
|
||||
|
||||
if !s.IsPasswordEqualsUserPassword(password, user) {
|
||||
return nil, errs.ErrUserPasswordWrong
|
||||
return nil, user.Uid, errs.ErrUserPasswordWrong
|
||||
}
|
||||
|
||||
return user, nil
|
||||
return user, user.Uid, nil
|
||||
}
|
||||
|
||||
// GetUserById returns the user model according to user uid
|
||||
@@ -301,7 +305,7 @@ func (s *UserService) UpdateUser(c core.Context, user *models.User, modifyUserLa
|
||||
updateCols = append(updateCols, "short_time_format")
|
||||
}
|
||||
|
||||
if core.DECIMAL_SEPARATOR_DEFAULT <= user.DecimalSeparator && user.DecimalSeparator <= core.DECIMAL_SEPARATOR_SPACE {
|
||||
if core.DECIMAL_SEPARATOR_DEFAULT <= user.DecimalSeparator && user.DecimalSeparator <= core.DECIMAL_SEPARATOR_COMMA {
|
||||
updateCols = append(updateCols, "decimal_separator")
|
||||
}
|
||||
|
||||
|
||||
@@ -144,6 +144,8 @@ const (
|
||||
defaultTemporaryTokenExpiredTime uint32 = 300 // 5 minutes
|
||||
defaultEmailVerifyTokenExpiredTime uint32 = 3600 // 60 minutes
|
||||
defaultPasswordResetTokenExpiredTime uint32 = 3600 // 60 minutes
|
||||
defaultMaxFailuresPerIpPerMinute uint32 = 5
|
||||
defaultMaxFailuresPerUserPerMinute uint32 = 5
|
||||
|
||||
defaultTransactionPictureFileMaxSize uint32 = 10485760 // 10MB
|
||||
defaultUserAvatarFileMaxSize uint32 = 1048576 // 1MB
|
||||
@@ -286,6 +288,8 @@ type Config struct {
|
||||
EmailVerifyTokenExpiredTimeDuration time.Duration
|
||||
PasswordResetTokenExpiredTime uint32
|
||||
PasswordResetTokenExpiredTimeDuration time.Duration
|
||||
MaxFailuresPerIpPerMinute uint32
|
||||
MaxFailuresPerUserPerMinute uint32
|
||||
EnableRequestIdHeader bool
|
||||
|
||||
// User
|
||||
@@ -768,6 +772,9 @@ func loadSecurityConfiguration(config *Config, configFile *ini.File, sectionName
|
||||
|
||||
config.PasswordResetTokenExpiredTimeDuration = time.Duration(config.PasswordResetTokenExpiredTime) * time.Second
|
||||
|
||||
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.EnableRequestIdHeader = getConfigItemBoolValue(configFile, sectionName, "request_id_header", true)
|
||||
|
||||
return nil
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
longDateFormat = "2006-01-02"
|
||||
longDateTimeFormat = "2006-01-02 15:04:05"
|
||||
longDateTimeWithTimezoneFormat = "2006-01-02 15:04:05Z07:00"
|
||||
longDateTimeWithTimezoneFormat2 = "2006-01-02 15:04:05 Z0700"
|
||||
@@ -42,6 +43,17 @@ func ParseNumericYearMonth(yearMonth string) (int32, int32, error) {
|
||||
return year, month, nil
|
||||
}
|
||||
|
||||
// FormatUnixTimeToLongDate returns a textual representation of the unix time formatted by long date time format
|
||||
func FormatUnixTimeToLongDate(unixTime int64, timezone *time.Location) string {
|
||||
t := parseFromUnixTime(unixTime)
|
||||
|
||||
if timezone != nil {
|
||||
t = t.In(timezone)
|
||||
}
|
||||
|
||||
return t.Format(longDateFormat)
|
||||
}
|
||||
|
||||
// FormatUnixTimeToLongDateTime returns a textual representation of the unix time formatted by long date time format
|
||||
func FormatUnixTimeToLongDateTime(unixTime int64, timezone *time.Location) string {
|
||||
t := parseFromUnixTime(unixTime)
|
||||
@@ -119,6 +131,24 @@ func GetMaxUnixTimeWithSameLocalDateTime(unixTime int64, currentUtcOffset int16)
|
||||
return unixTime + int64(currentUtcOffset)*60 - westernmostTimezoneUtcOffset*60
|
||||
}
|
||||
|
||||
// ParseFromLongDateFirstTime parses a formatted string in long date format
|
||||
func ParseFromLongDateFirstTime(t string, utcOffset int16) (time.Time, error) {
|
||||
timezone := time.FixedZone("Timezone", int(utcOffset)*60)
|
||||
return time.ParseInLocation(longDateFormat, t, timezone)
|
||||
}
|
||||
|
||||
// ParseFromLongDateLastTime parses a formatted string in long date format
|
||||
func ParseFromLongDateLastTime(t string, utcOffset int16) (time.Time, error) {
|
||||
timezone := time.FixedZone("Timezone", int(utcOffset)*60)
|
||||
lastTime, err := time.ParseInLocation(longDateFormat, t, timezone)
|
||||
|
||||
if err != nil {
|
||||
return lastTime, err
|
||||
}
|
||||
|
||||
return lastTime.Add(24 * time.Hour).Add(-1 * time.Nanosecond), nil
|
||||
}
|
||||
|
||||
// ParseFromLongDateTimeToMinUnixTime parses a formatted string in long date time format to minimal unix time (the westernmost timezone)
|
||||
func ParseFromLongDateTimeToMinUnixTime(t string) (time.Time, error) {
|
||||
timezone := time.FixedZone("Timezone", easternmostTimezoneUtcOffset*60)
|
||||
|
||||
@@ -18,6 +18,20 @@ func TestParseNumericYearMonth(t *testing.T) {
|
||||
assert.Equal(t, expectedMonth, actualMonth)
|
||||
}
|
||||
|
||||
func TestFormatUnixTimeToLongDate(t *testing.T) {
|
||||
unixTime := int64(1617228083)
|
||||
utcTimezone := time.FixedZone("Test Timezone", 0) // UTC
|
||||
utc8Timezone := time.FixedZone("Test Timezone", 28800) // UTC+8
|
||||
|
||||
expectedValue := "2021-03-31"
|
||||
actualValue := FormatUnixTimeToLongDate(unixTime, utcTimezone)
|
||||
assert.Equal(t, expectedValue, actualValue)
|
||||
|
||||
expectedValue = "2021-04-01"
|
||||
actualValue = FormatUnixTimeToLongDate(unixTime, utc8Timezone)
|
||||
assert.Equal(t, expectedValue, actualValue)
|
||||
}
|
||||
|
||||
func TestFormatUnixTimeToLongDateTime(t *testing.T) {
|
||||
unixTime := int64(1617228083)
|
||||
utcTimezone := time.FixedZone("Test Timezone", 0) // UTC
|
||||
@@ -106,6 +120,24 @@ func TestGetMaxUnixTimeWithSameLocalDateTime(t *testing.T) {
|
||||
assert.Equal(t, expectedValue, actualValue)
|
||||
}
|
||||
|
||||
func TestParseFromLongDateFirstTime(t *testing.T) {
|
||||
expectedValue := int64(1690819200)
|
||||
actualTime, err := ParseFromLongDateFirstTime("2023-08-01", 480)
|
||||
assert.Equal(t, nil, err)
|
||||
|
||||
actualValue := actualTime.Unix()
|
||||
assert.Equal(t, expectedValue, actualValue)
|
||||
}
|
||||
|
||||
func TestParseFromLongDateLastTime(t *testing.T) {
|
||||
expectedValue := int64(1690905599)
|
||||
actualTime, err := ParseFromLongDateLastTime("2023-08-01", 480)
|
||||
assert.Equal(t, nil, err)
|
||||
|
||||
actualValue := actualTime.Unix()
|
||||
assert.Equal(t, expectedValue, actualValue)
|
||||
}
|
||||
|
||||
func TestParseFromLongDateTimeToMinUnixTime(t *testing.T) {
|
||||
expectedValue := int64(1690797600)
|
||||
actualTime, err := ParseFromLongDateTimeToMinUnixTime("2023-08-01 00:00:00")
|
||||
|
||||
+71
-73
@@ -4,10 +4,10 @@
|
||||
</v-app>
|
||||
<v-snackbar class="cursor-pointer" color="notification-background" location="top"
|
||||
:multi-line="true" :timeout="-1" :close-on-content-click="true" v-model="showNotification">
|
||||
<v-tooltip activator="parent">{{ $t('Click to close') }}</v-tooltip>
|
||||
<v-tooltip activator="parent">{{ tt('Click to close') }}</v-tooltip>
|
||||
<div class="d-inline-flex">
|
||||
<img alt="logo" class="notification-logo" :src="ezBookkeepingLogoPath" />
|
||||
<span class="ml-2">{{ $t('global.app.title') }}</span>
|
||||
<img alt="logo" class="notification-logo" :src="APPLICATION_LOGO_PATH" />
|
||||
<span class="ml-2">{{ tt('global.app.title') }}</span>
|
||||
</div>
|
||||
<div>
|
||||
{{ currentNotificationContent }}
|
||||
@@ -15,106 +15,104 @@
|
||||
</v-snackbar>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted } from 'vue';
|
||||
|
||||
import { useTheme } from 'vuetify';
|
||||
import { register } from 'register-service-worker';
|
||||
|
||||
import { mapStores } from 'pinia';
|
||||
import { useRootStore } from '@/stores/index.js';
|
||||
import { useSettingsStore } from '@/stores/setting.js';
|
||||
import { useUserStore } from '@/stores/user.js';
|
||||
import { useTokensStore } from '@/stores/token.js';
|
||||
import { useExchangeRatesStore } from '@/stores/exchangeRates.js';
|
||||
import { useI18n } from '@/locales/helpers.ts';
|
||||
|
||||
import assetConstants from '@/consts/asset.js';
|
||||
import { isProduction } from '@/lib/version.js';
|
||||
import { loadMapAssets } from '@/lib/map/index.js';
|
||||
import { getSystemTheme, setExpenseAndIncomeAmountColor } from '@/lib/ui.js';
|
||||
import { useRootStore } from '@/stores/index.ts';
|
||||
import { useSettingsStore } from '@/stores/setting.ts';
|
||||
import { useUserStore } from '@/stores/user.ts';
|
||||
import { useTokensStore } from '@/stores/token.ts';
|
||||
import { useExchangeRatesStore } from '@/stores/exchangeRates.ts';
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
showNotification: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapStores(useRootStore, useSettingsStore, useUserStore, useTokensStore, useExchangeRatesStore),
|
||||
ezBookkeepingLogoPath() {
|
||||
return assetConstants.ezBookkeepingLogoPath;
|
||||
},
|
||||
currentNotificationContent() {
|
||||
return this.rootStore.currentNotification;
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
currentNotificationContent: function (newValue) {
|
||||
this.showNotification = !!newValue;
|
||||
}
|
||||
},
|
||||
created() {
|
||||
const self = this;
|
||||
const theme = useTheme();
|
||||
import { APPLICATION_LOGO_PATH } from '@/consts/asset.ts';
|
||||
import { ThemeType } from '@/core/theme.ts';
|
||||
import { isProduction } from '@/lib/version.ts';
|
||||
import { initMapProvider } from '@/lib/map/index.ts';
|
||||
import { isUserLogined, isUserUnlocked } from '@/lib/userstate.ts';
|
||||
import { getSystemTheme, setExpenseAndIncomeAmountColor } from '@/lib/ui/common.ts';
|
||||
|
||||
if (self.settingsStore.appSettings.theme === 'light') {
|
||||
theme.global.name.value = 'light';
|
||||
} else if (self.settingsStore.appSettings.theme === 'dark') {
|
||||
theme.global.name.value = 'dark';
|
||||
} else {
|
||||
theme.global.name.value = getSystemTheme();
|
||||
}
|
||||
const { tt, getCurrentLanguageInfo, setLanguage, initLocale } = useI18n();
|
||||
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function (e) {
|
||||
if (self.settingsStore.appSettings.theme === 'auto') {
|
||||
if (e.matches) {
|
||||
theme.global.name.value = 'dark';
|
||||
} else {
|
||||
theme.global.name.value = 'light';
|
||||
}
|
||||
}
|
||||
const theme = useTheme();
|
||||
|
||||
const rootStore = useRootStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
const userStore = useUserStore();
|
||||
const tokensStore = useTokensStore();
|
||||
const exchangeRatesStore = useExchangeRatesStore();
|
||||
|
||||
const showNotification = ref<boolean>(false);
|
||||
|
||||
const currentNotificationContent = computed<string | null>(() => rootStore.currentNotification);
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const languageInfo = getCurrentLanguageInfo();
|
||||
initMapProvider(languageInfo?.alternativeLanguageTag);
|
||||
});
|
||||
});
|
||||
|
||||
let localeDefaultSettings = self.$locale.initLocale(self.userStore.currentUserLanguage, self.settingsStore.appSettings.timeZone);
|
||||
self.settingsStore.updateLocalizedDefaultSettings(localeDefaultSettings);
|
||||
watch(currentNotificationContent, (newValue) => {
|
||||
showNotification.value = !!newValue;
|
||||
});
|
||||
|
||||
setExpenseAndIncomeAmountColor(self.userStore.currentUserExpenseAmountColor, self.userStore.currentUserIncomeAmountColor);
|
||||
if (settingsStore.appSettings.theme === ThemeType.Light) {
|
||||
theme.global.name.value = ThemeType.Light;
|
||||
} else if (settingsStore.appSettings.theme === ThemeType.Dark) {
|
||||
theme.global.name.value = ThemeType.Dark;
|
||||
} else {
|
||||
theme.global.name.value = getSystemTheme();
|
||||
}
|
||||
|
||||
if (self.$user.isUserLogined()) {
|
||||
if (!self.settingsStore.appSettings.applicationLock || self.$user.isUserUnlocked()) {
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function (e) {
|
||||
if (settingsStore.appSettings.theme === 'auto') {
|
||||
if (e.matches) {
|
||||
theme.global.name.value = ThemeType.Dark;
|
||||
} else {
|
||||
theme.global.name.value = ThemeType.Light;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let localeDefaultSettings = initLocale(userStore.currentUserLanguage, settingsStore.appSettings.timeZone);
|
||||
settingsStore.updateLocalizedDefaultSettings(localeDefaultSettings);
|
||||
|
||||
setExpenseAndIncomeAmountColor(userStore.currentUserExpenseAmountColor, userStore.currentUserIncomeAmountColor);
|
||||
|
||||
if (isUserLogined()) {
|
||||
if (!settingsStore.appSettings.applicationLock || isUserUnlocked()) {
|
||||
// refresh token if user is logined
|
||||
self.tokensStore.refreshTokenAndRevokeOldToken().then(response => {
|
||||
tokensStore.refreshTokenAndRevokeOldToken().then(response => {
|
||||
if (response.user) {
|
||||
localeDefaultSettings = self.$locale.setLanguage(response.user.language);
|
||||
self.settingsStore.updateLocalizedDefaultSettings(localeDefaultSettings);
|
||||
localeDefaultSettings = setLanguage(response.user.language);
|
||||
settingsStore.updateLocalizedDefaultSettings(localeDefaultSettings);
|
||||
|
||||
setExpenseAndIncomeAmountColor(response.user.expenseAmountColor, response.user.incomeAmountColor);
|
||||
|
||||
if (response.notificationContent) {
|
||||
self.rootStore.setNotificationContent(response.notificationContent);
|
||||
rootStore.setNotificationContent(response.notificationContent);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// auto refresh exchange rates data
|
||||
if (self.settingsStore.appSettings.autoUpdateExchangeRatesData) {
|
||||
self.exchangeRatesStore.getLatestExchangeRates({ silent: true, force: false });
|
||||
}
|
||||
if (settingsStore.appSettings.autoUpdateExchangeRatesData) {
|
||||
exchangeRatesStore.getLatestExchangeRates({ silent: true, force: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isProduction()) {
|
||||
if (isProduction()) {
|
||||
register('./sw.js', {
|
||||
registrationOptions: {
|
||||
scope: './'
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const languageInfo = this.$locale.getCurrentLanguageInfo();
|
||||
loadMapAssets(languageInfo ? languageInfo.alternativeLanguageTag : null);
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
+146
-140
@@ -4,45 +4,58 @@
|
||||
</f7-app>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted } from 'vue';
|
||||
|
||||
import type { Framework7Parameters, Notification, Actions, Dialog, Popover, Popup, Sheet } from 'framework7/types';
|
||||
import { f7ready } from 'framework7-vue';
|
||||
import routes from './router/mobile.js';
|
||||
import routes from './router/mobile.ts';
|
||||
|
||||
import { mapStores } from 'pinia';
|
||||
import { useRootStore } from '@/stores/index.js';
|
||||
import { useSettingsStore } from '@/stores/setting.js';
|
||||
import { useUserStore } from '@/stores/user.js';
|
||||
import { useTokensStore } from '@/stores/token.js';
|
||||
import { useExchangeRatesStore } from '@/stores/exchangeRates.js';
|
||||
import { useI18n } from '@/locales/helpers.ts';
|
||||
|
||||
import assetConstants from '@/consts/asset.js';
|
||||
import { isProduction } from '@/lib/version.js';
|
||||
import { getTheme, isEnableAnimate } from '@/lib/settings.js';
|
||||
import { loadMapAssets } from '@/lib/map/index.js';
|
||||
import { setExpenseAndIncomeAmountColor } from '@/lib/ui.js';
|
||||
import { isModalShowing, setAppFontSize } from '@/lib/ui.mobile.js';
|
||||
import { useRootStore } from '@/stores/index.ts';
|
||||
import { useSettingsStore } from '@/stores/setting.ts';
|
||||
import { useEnvironmentsStore } from '@/stores/environment.ts';
|
||||
import { useUserStore } from '@/stores/user.ts';
|
||||
import { useTokensStore } from '@/stores/token.ts';
|
||||
import { useExchangeRatesStore } from '@/stores/exchangeRates.ts';
|
||||
|
||||
export default {
|
||||
data() {
|
||||
const self = this;
|
||||
let darkMode = 'auto';
|
||||
import { APPLICATION_LOGO_PATH } from '@/consts/asset.ts';
|
||||
import { ThemeType } from '@/core/theme.ts';
|
||||
import { isProduction } from '@/lib/version.ts';
|
||||
import { getTheme, isEnableAnimate } from '@/lib/settings.ts';
|
||||
import { initMapProvider } from '@/lib/map/index.ts';
|
||||
import { isUserLogined, isUserUnlocked } from '@/lib/userstate.ts';
|
||||
import { setExpenseAndIncomeAmountColor } from '@/lib/ui/common.ts';
|
||||
import { isModalShowing, setAppFontSize } from '@/lib/ui/mobile.ts';
|
||||
|
||||
if (getTheme() === 'light') {
|
||||
darkMode = false;
|
||||
} else if (getTheme() === 'dark') {
|
||||
darkMode = true;
|
||||
}
|
||||
const { tt, getCurrentLanguageInfo, setLanguage, initLocale } = useI18n();
|
||||
|
||||
return {
|
||||
notification: null,
|
||||
f7params: {
|
||||
const rootStore = useRootStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
const environmentsStore = useEnvironmentsStore();
|
||||
const userStore = useUserStore();
|
||||
const tokensStore = useTokensStore();
|
||||
const exchangeRatesStore = useExchangeRatesStore();
|
||||
|
||||
const f7params = ref<Framework7Parameters>({
|
||||
name: 'ezBookkeeping',
|
||||
theme: 'ios',
|
||||
colors: {
|
||||
primary: '#c67e48'
|
||||
},
|
||||
routes: routes,
|
||||
darkMode: darkMode,
|
||||
darkMode: (() => {
|
||||
let darkMode: boolean | string = 'auto';
|
||||
|
||||
if (getTheme() === ThemeType.Light) {
|
||||
darkMode = false;
|
||||
} else if (getTheme() === ThemeType.Dark) {
|
||||
darkMode = true;
|
||||
}
|
||||
|
||||
return darkMode;
|
||||
})(),
|
||||
touch: {
|
||||
disableContextMenu: true,
|
||||
tapHold: true
|
||||
@@ -57,6 +70,7 @@ export default {
|
||||
closeOnEscape: true
|
||||
},
|
||||
dialog: {
|
||||
// @ts-expect-error there is an "animate" field in dialog parameters, but it is not declared in the type definition file
|
||||
animate: isEnableAnimate(),
|
||||
backdrop: true
|
||||
},
|
||||
@@ -81,99 +95,78 @@ export default {
|
||||
},
|
||||
view: {
|
||||
animate: isEnableAnimate(),
|
||||
browserHistory: !self.isiOSHomeScreenMode(),
|
||||
browserHistory: !isiOSHomeScreenMode(),
|
||||
browserHistoryInitialMatch: true,
|
||||
browserHistoryAnimate: false,
|
||||
iosSwipeBackAnimateShadow: false,
|
||||
mdSwipeBackAnimateShadow: false
|
||||
}
|
||||
},
|
||||
isDarkMode: undefined,
|
||||
hasPushPopupBackdrop: undefined,
|
||||
hasBackdrop: undefined
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapStores(useRootStore, useSettingsStore, useUserStore, useTokensStore, useExchangeRatesStore),
|
||||
currentNotificationContent() {
|
||||
return this.rootStore.currentNotification;
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
currentNotificationContent: function (newValue) {
|
||||
const self = this;
|
||||
});
|
||||
|
||||
if (self.notification) {
|
||||
self.notification.close();
|
||||
self.notification.destroy();
|
||||
self.notification = null;
|
||||
const notification = ref<Notification.Notification | null>(null);
|
||||
|
||||
const hasPushPopupBackdrop = ref<boolean | undefined>(undefined);
|
||||
const hasBackdrop = ref<boolean | undefined>(undefined);
|
||||
const currentNotificationContent = computed<string | null>(() => rootStore.currentNotification);
|
||||
|
||||
function isiOSHomeScreenMode(): boolean {
|
||||
if ((/iphone|ipod|ipad/gi).test(navigator.platform) && (/Safari/i).test(navigator.appVersion) &&
|
||||
window.matchMedia && window.matchMedia('(display-mode: standalone)').matches
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (newValue) {
|
||||
f7ready((f7) => {
|
||||
self.notification = f7.notification.create({
|
||||
icon: `<img alt="logo" src="${assetConstants.ezBookkeepingLogoPath}" />`,
|
||||
title: self.$t('global.app.title'),
|
||||
text: newValue,
|
||||
closeOnClick: true,
|
||||
on: {
|
||||
close() {
|
||||
self.rootStore.setNotificationContent(null);
|
||||
}
|
||||
}
|
||||
}).open();
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
created() {
|
||||
const self = this;
|
||||
return false;
|
||||
}
|
||||
|
||||
let localeDefaultSettings = self.$locale.initLocale(self.userStore.currentUserLanguage, self.settingsStore.appSettings.timeZone);
|
||||
self.settingsStore.updateLocalizedDefaultSettings(localeDefaultSettings);
|
||||
function setThemeColorMeta(darkMode: boolean | undefined): void {
|
||||
if (hasPushPopupBackdrop.value) {
|
||||
document.querySelector('meta[name=theme-color]')?.setAttribute('content', '#000');
|
||||
return;
|
||||
}
|
||||
|
||||
setExpenseAndIncomeAmountColor(self.userStore.currentUserExpenseAmountColor, self.userStore.currentUserIncomeAmountColor);
|
||||
|
||||
if (self.$user.isUserLogined()) {
|
||||
if (!self.settingsStore.appSettings.applicationLock || self.$user.isUserUnlocked()) {
|
||||
// refresh token if user is logined
|
||||
self.tokensStore.refreshTokenAndRevokeOldToken().then(response => {
|
||||
if (response.user) {
|
||||
localeDefaultSettings = self.$locale.setLanguage(response.user.language);
|
||||
self.settingsStore.updateLocalizedDefaultSettings(localeDefaultSettings);
|
||||
|
||||
setExpenseAndIncomeAmountColor(response.user.expenseAmountColor, response.user.incomeAmountColor);
|
||||
|
||||
if (response.notificationContent) {
|
||||
self.rootStore.setNotificationContent(response.notificationContent);
|
||||
if (darkMode) {
|
||||
if (hasBackdrop.value) {
|
||||
document.querySelector('meta[name=theme-color]')?.setAttribute('content', '#0b0b0b');
|
||||
} else {
|
||||
document.querySelector('meta[name=theme-color]')?.setAttribute('content', '#121212');
|
||||
}
|
||||
} else {
|
||||
if (hasBackdrop.value) {
|
||||
document.querySelector('meta[name=theme-color]')?.setAttribute('content', '#949495');
|
||||
} else {
|
||||
document.querySelector('meta[name=theme-color]')?.setAttribute('content', '#f6f6f8');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// auto refresh exchange rates data
|
||||
if (self.settingsStore.appSettings.autoUpdateExchangeRatesData) {
|
||||
self.exchangeRatesStore.getLatestExchangeRates({ silent: true, force: false });
|
||||
function onBackdropChanged(element: { push?: boolean, opened?: boolean }): void {
|
||||
if (element.push) {
|
||||
hasPushPopupBackdrop.value = element.opened;
|
||||
} else {
|
||||
hasBackdrop.value = element.opened;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
setAppFontSize(this.settingsStore.appSettings.fontSize);
|
||||
|
||||
setThemeColorMeta(environmentsStore.framework7DarkMode);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
setAppFontSize(settingsStore.appSettings.fontSize);
|
||||
|
||||
f7ready((f7) => {
|
||||
this.isDarkMode = f7.darkMode;
|
||||
this.setThemeColorMeta(f7.darkMode);
|
||||
environmentsStore.framework7DarkMode = f7.darkMode;
|
||||
setThemeColorMeta(f7.darkMode);
|
||||
|
||||
f7.on('actionsOpen', (event) => this.onBackdropChanged(event));
|
||||
f7.on('actionsClose', (event) => this.onBackdropChanged(event));
|
||||
f7.on('dialogOpen', (event) => this.onBackdropChanged(event));
|
||||
f7.on('dialogClose', (event) => this.onBackdropChanged(event));
|
||||
f7.on('popoverOpen', (event) => this.onBackdropChanged(event));
|
||||
f7.on('popoverClose', (event) => this.onBackdropChanged(event));
|
||||
f7.on('popupOpen', (event) => this.onBackdropChanged(event));
|
||||
f7.on('popupClose', (event) => this.onBackdropChanged(event));
|
||||
f7.on('sheetOpen', (event) => this.onBackdropChanged(event));
|
||||
f7.on('sheetClose', (event) => this.onBackdropChanged(event));
|
||||
f7.on('actionsOpen', (actions: Actions.Actions) => onBackdropChanged(actions));
|
||||
f7.on('actionsClose', (actions: Actions.Actions) => onBackdropChanged(actions));
|
||||
f7.on('dialogOpen', (dialog: Dialog.Dialog) => onBackdropChanged(dialog));
|
||||
f7.on('dialogClose', (dialog: Dialog.Dialog) => onBackdropChanged(dialog));
|
||||
f7.on('popoverOpen', (popover: Popover.Popover) => onBackdropChanged(popover));
|
||||
f7.on('popoverClose', (popover: Popover.Popover) => onBackdropChanged(popover));
|
||||
f7.on('popupOpen', (popup: Popup.Popup) => onBackdropChanged(popup));
|
||||
f7.on('popupClose', (popup: Popup.Popup) => onBackdropChanged(popup));
|
||||
f7.on('sheetOpen', (sheet: Sheet.Sheet) => onBackdropChanged(sheet));
|
||||
f7.on('sheetClose', (sheet: Sheet.Sheet) => onBackdropChanged(sheet));
|
||||
|
||||
f7.on('pageBeforeOut', () => {
|
||||
if (isModalShowing()) {
|
||||
@@ -185,55 +178,68 @@ export default {
|
||||
}
|
||||
});
|
||||
|
||||
f7.on('darkModeChange', (isDarkMode) => {
|
||||
this.isDarkMode = isDarkMode;
|
||||
this.setThemeColorMeta(isDarkMode);
|
||||
f7.on('darkModeChange', (darkMode) => {
|
||||
environmentsStore.framework7DarkMode = darkMode;
|
||||
setThemeColorMeta(darkMode);
|
||||
});
|
||||
});
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const languageInfo = this.$locale.getCurrentLanguageInfo();
|
||||
loadMapAssets(languageInfo ? languageInfo.alternativeLanguageTag : null);
|
||||
const languageInfo = getCurrentLanguageInfo();
|
||||
initMapProvider(languageInfo?.alternativeLanguageTag);
|
||||
});
|
||||
},
|
||||
methods: {
|
||||
isiOSHomeScreenMode() {
|
||||
if ((/iphone|ipod|ipad/gi).test(navigator.platform) && (/Safari/i).test(navigator.appVersion) &&
|
||||
window.matchMedia && window.matchMedia('(display-mode: standalone)').matches
|
||||
) {
|
||||
return true;
|
||||
});
|
||||
|
||||
watch(currentNotificationContent, (newValue) => {
|
||||
if (notification.value) {
|
||||
notification.value.close();
|
||||
// @ts-expect-error there is an "destroy" function in the Notification component, but it is not defined in the type definition file
|
||||
// see https://framework7.io/docs/notification
|
||||
notification.value.destroy();
|
||||
notification.value = null;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
onBackdropChanged(event) {
|
||||
if (event.push) {
|
||||
this.hasPushPopupBackdrop = event.opened;
|
||||
} else {
|
||||
this.hasBackdrop = event.opened;
|
||||
if (newValue) {
|
||||
f7ready((f7) => {
|
||||
notification.value = f7.notification.create({
|
||||
icon: `<img alt="logo" src="${APPLICATION_LOGO_PATH}" />`,
|
||||
title: tt('global.app.title'),
|
||||
text: newValue,
|
||||
closeOnClick: true,
|
||||
on: {
|
||||
close() {
|
||||
rootStore.setNotificationContent(null);
|
||||
}
|
||||
}
|
||||
}).open();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
this.setThemeColorMeta(this.isDarkMode);
|
||||
},
|
||||
setThemeColorMeta(isDarkMode) {
|
||||
if (this.hasPushPopupBackdrop) {
|
||||
document.querySelector('meta[name=theme-color]').setAttribute('content', '#000');
|
||||
return;
|
||||
}
|
||||
let localeDefaultSettings = initLocale(userStore.currentUserLanguage, settingsStore.appSettings.timeZone);
|
||||
settingsStore.updateLocalizedDefaultSettings(localeDefaultSettings);
|
||||
|
||||
if (isDarkMode) {
|
||||
if (this.hasBackdrop) {
|
||||
document.querySelector('meta[name=theme-color]').setAttribute('content', '#0b0b0b');
|
||||
} else {
|
||||
document.querySelector('meta[name=theme-color]').setAttribute('content', '#121212');
|
||||
}
|
||||
} else {
|
||||
if (this.hasBackdrop) {
|
||||
document.querySelector('meta[name=theme-color]').setAttribute('content', '#949495');
|
||||
} else {
|
||||
document.querySelector('meta[name=theme-color]').setAttribute('content', '#f6f6f8');
|
||||
setExpenseAndIncomeAmountColor(userStore.currentUserExpenseAmountColor, userStore.currentUserIncomeAmountColor);
|
||||
|
||||
if (isUserLogined()) {
|
||||
if (!settingsStore.appSettings.applicationLock || isUserUnlocked()) {
|
||||
// refresh token if user is logined
|
||||
tokensStore.refreshTokenAndRevokeOldToken().then(response => {
|
||||
if (response.user) {
|
||||
localeDefaultSettings = setLanguage(response.user.language);
|
||||
settingsStore.updateLocalizedDefaultSettings(localeDefaultSettings);
|
||||
|
||||
setExpenseAndIncomeAmountColor(response.user.expenseAmountColor, response.user.incomeAmountColor);
|
||||
|
||||
if (response.notificationContent) {
|
||||
rootStore.setNotificationContent(response.notificationContent);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// auto refresh exchange rates data
|
||||
if (settingsStore.appSettings.autoUpdateExchangeRatesData) {
|
||||
exchangeRatesStore.getLatestExchangeRates({ silent: true, force: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { ref, computed } from 'vue';
|
||||
|
||||
import { type TimeRangeAndDateType, type PresetDateRange, type UnixTimeRange, DateRange } from '@/core/datetime.ts';
|
||||
import { arrangeArrayWithNewStartIndex } from '@/lib/common.ts';
|
||||
import {
|
||||
getCurrentUnixTime,
|
||||
getCurrentYear,
|
||||
getUnixTime,
|
||||
getLocalDatetimeFromUnixTime,
|
||||
getTodayFirstUnixTime,
|
||||
getDummyUnixTimeForLocalUsage,
|
||||
getActualUnixTimeForStore,
|
||||
getTimezoneOffsetMinutes,
|
||||
getBrowserTimezoneOffsetMinutes,
|
||||
getDateRangeByDateType
|
||||
} from '@/lib/datetime.ts';
|
||||
|
||||
import { useI18n } from '@/locales/helpers.ts';
|
||||
|
||||
import { useUserStore } from '@/stores/user.ts';
|
||||
|
||||
export interface CommonDateRangeSelectionProps {
|
||||
minTime?: number;
|
||||
maxTime?: number;
|
||||
title?: string;
|
||||
hint?: string;
|
||||
show: boolean;
|
||||
}
|
||||
|
||||
function getDateRangeFromProps(props: CommonDateRangeSelectionProps): { minDate: number; maxDate: number } {
|
||||
let minDate = getTodayFirstUnixTime();
|
||||
let maxDate = getCurrentUnixTime();
|
||||
|
||||
if (props.minTime) {
|
||||
minDate = props.minTime;
|
||||
}
|
||||
|
||||
if (props.maxTime) {
|
||||
maxDate = props.maxTime;
|
||||
}
|
||||
|
||||
return {
|
||||
minDate,
|
||||
maxDate
|
||||
};
|
||||
}
|
||||
|
||||
export function useDateRangeSelectionBase(props: CommonDateRangeSelectionProps) {
|
||||
const { tt, getAllMinWeekdayNames, formatUnixTimeToLongDateTime, isLongDateMonthAfterYear, isLongTime24HourFormat } = useI18n();
|
||||
const userStore = useUserStore();
|
||||
const { minDate, maxDate } = getDateRangeFromProps(props);
|
||||
|
||||
const yearRange = ref<number[]>([
|
||||
2000,
|
||||
getCurrentYear() + 1
|
||||
]);
|
||||
|
||||
const dateRange = ref<Date[]>([
|
||||
getLocalDatetimeFromUnixTime(getDummyUnixTimeForLocalUsage(minDate, getTimezoneOffsetMinutes(), getBrowserTimezoneOffsetMinutes())),
|
||||
getLocalDatetimeFromUnixTime(getDummyUnixTimeForLocalUsage(maxDate, getTimezoneOffsetMinutes(), getBrowserTimezoneOffsetMinutes()))
|
||||
]);
|
||||
|
||||
const firstDayOfWeek = computed<number>(() => userStore.currentUserFirstDayOfWeek);
|
||||
const dayNames = computed<string[]>(() => arrangeArrayWithNewStartIndex(getAllMinWeekdayNames(), firstDayOfWeek.value));
|
||||
const isYearFirst = computed<boolean>(() => isLongDateMonthAfterYear());
|
||||
const is24Hour = computed<boolean>(() => isLongTime24HourFormat());
|
||||
const beginDateTime = computed<string>(() => {
|
||||
const actualBeginUnixTime = getActualUnixTimeForStore(getUnixTime(dateRange.value[0]), getTimezoneOffsetMinutes(), getBrowserTimezoneOffsetMinutes());
|
||||
return formatUnixTimeToLongDateTime(actualBeginUnixTime);
|
||||
});
|
||||
const endDateTime = computed<string>(() => {
|
||||
const actualEndUnixTime = getActualUnixTimeForStore(getUnixTime(dateRange.value[1]), getTimezoneOffsetMinutes(), getBrowserTimezoneOffsetMinutes());
|
||||
return formatUnixTimeToLongDateTime(actualEndUnixTime);
|
||||
});
|
||||
const presetRanges = computed<PresetDateRange[]>(() => {
|
||||
const presetRanges:PresetDateRange[] = [];
|
||||
|
||||
[
|
||||
DateRange.Today,
|
||||
DateRange.LastSevenDays,
|
||||
DateRange.LastThirtyDays,
|
||||
DateRange.ThisWeek,
|
||||
DateRange.ThisMonth,
|
||||
DateRange.ThisYear
|
||||
].forEach(dateRangeType => {
|
||||
const dateRange = getDateRangeByDateType(dateRangeType.type, firstDayOfWeek.value) as TimeRangeAndDateType;
|
||||
|
||||
presetRanges.push({
|
||||
label: tt(dateRangeType.name),
|
||||
value: [
|
||||
getLocalDatetimeFromUnixTime(getDummyUnixTimeForLocalUsage(dateRange.minTime, getTimezoneOffsetMinutes(), getBrowserTimezoneOffsetMinutes())),
|
||||
getLocalDatetimeFromUnixTime(getDummyUnixTimeForLocalUsage(dateRange.maxTime, getTimezoneOffsetMinutes(), getBrowserTimezoneOffsetMinutes()))
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
return presetRanges;
|
||||
});
|
||||
|
||||
function getFinalDateRange(): UnixTimeRange | null {
|
||||
if (!dateRange.value[0] || !dateRange.value[1]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentMinDate = dateRange.value[0];
|
||||
const currentMaxDate = dateRange.value[1];
|
||||
|
||||
let minUnixTime = getUnixTime(currentMinDate);
|
||||
let maxUnixTime = getUnixTime(currentMaxDate);
|
||||
|
||||
if (minUnixTime < 0 || maxUnixTime < 0) {
|
||||
throw new Error('Date is too early');
|
||||
}
|
||||
|
||||
minUnixTime = getActualUnixTimeForStore(minUnixTime, getTimezoneOffsetMinutes(), getBrowserTimezoneOffsetMinutes());
|
||||
maxUnixTime = getActualUnixTimeForStore(maxUnixTime, getTimezoneOffsetMinutes(), getBrowserTimezoneOffsetMinutes());
|
||||
|
||||
return {
|
||||
minUnixTime,
|
||||
maxUnixTime
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
// states
|
||||
yearRange,
|
||||
dateRange,
|
||||
// computed states
|
||||
dayNames,
|
||||
isYearFirst,
|
||||
is24Hour,
|
||||
beginDateTime,
|
||||
endDateTime,
|
||||
presetRanges,
|
||||
// functions
|
||||
getFinalDateRange
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { computed } from 'vue';
|
||||
|
||||
import type { ColorValue } from '@/core/color.ts';
|
||||
import { ALL_ACCOUNT_ICONS, DEFAULT_ACCOUNT_ICON, ALL_CATEGORY_ICONS, DEFAULT_CATEGORY_ICON } from '@/consts/icon.ts';
|
||||
import { DEFAULT_ICON_COLOR, DEFAULT_ACCOUNT_COLOR, DEFAULT_CATEGORY_COLOR } from '@/consts/color.ts';
|
||||
import { isNumber } from '@/lib/common.ts';
|
||||
|
||||
type IconItemStyleName = string;
|
||||
type IconItemStyleValue = ColorValue | string | number | undefined;
|
||||
type CommonIconItemType = 'account' | 'category' | 'fixed';
|
||||
type MobileIconItemType = 'fixed-f7';
|
||||
|
||||
export interface CommonIconProps {
|
||||
iconType: CommonIconItemType | MobileIconItemType;
|
||||
iconId: string | number;
|
||||
color?: ColorValue;
|
||||
defaultColor?: ColorValue;
|
||||
additionalColorAttr?: string;
|
||||
size?: string | number;
|
||||
}
|
||||
|
||||
export function useItemIconBase(props: CommonIconProps) {
|
||||
const style = computed<Record<IconItemStyleName, IconItemStyleValue>>(() => {
|
||||
let defaultColor = 'var(--default-icon-color)';
|
||||
|
||||
if (props.defaultColor) {
|
||||
defaultColor = props.defaultColor;
|
||||
}
|
||||
|
||||
if (props.iconType === 'account') {
|
||||
return getAccountIconStyle(props.color, defaultColor, props.additionalColorAttr);
|
||||
} else if (props.iconType === 'category') {
|
||||
return getCategoryIconStyle(props.color, defaultColor, props.additionalColorAttr);
|
||||
} else {
|
||||
return getDefaultIconStyle(props.color, defaultColor, props.additionalColorAttr);
|
||||
}
|
||||
});
|
||||
|
||||
function getAccountIcon(iconId: string | number): string {
|
||||
if (isNumber(iconId)) {
|
||||
iconId = iconId.toString();
|
||||
}
|
||||
|
||||
if (!ALL_ACCOUNT_ICONS[iconId]) {
|
||||
return DEFAULT_ACCOUNT_ICON.icon;
|
||||
}
|
||||
|
||||
return ALL_ACCOUNT_ICONS[iconId].icon;
|
||||
}
|
||||
|
||||
function getCategoryIcon(iconId: string | number): string {
|
||||
if (isNumber(iconId)) {
|
||||
iconId = iconId.toString();
|
||||
}
|
||||
|
||||
if (!ALL_CATEGORY_ICONS[iconId]) {
|
||||
return DEFAULT_CATEGORY_ICON.icon;
|
||||
}
|
||||
|
||||
return ALL_CATEGORY_ICONS[iconId].icon;
|
||||
}
|
||||
|
||||
function getAccountIconStyle(color?: ColorValue | string, defaultColor?: ColorValue | string, additionalColorAttr?: string): Record<IconItemStyleName, IconItemStyleValue> {
|
||||
if (color && color !== DEFAULT_ACCOUNT_COLOR) {
|
||||
color = '#' + color;
|
||||
} else {
|
||||
color = defaultColor;
|
||||
}
|
||||
|
||||
const ret: Record<IconItemStyleName, IconItemStyleValue> = {
|
||||
color: color
|
||||
};
|
||||
|
||||
if (additionalColorAttr) {
|
||||
ret[additionalColorAttr] = color;
|
||||
}
|
||||
|
||||
if (props.size) {
|
||||
ret['font-size'] = props.size;
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
function getCategoryIconStyle(color?: ColorValue | string, defaultColor?: ColorValue | string, additionalColorAttr?: string): Record<IconItemStyleName, IconItemStyleValue> {
|
||||
if (color && color !== DEFAULT_CATEGORY_COLOR) {
|
||||
color = '#' + color;
|
||||
} else {
|
||||
color = defaultColor;
|
||||
}
|
||||
|
||||
const ret: Record<IconItemStyleName, IconItemStyleValue> = {
|
||||
color: color
|
||||
};
|
||||
|
||||
if (additionalColorAttr) {
|
||||
ret[additionalColorAttr] = color;
|
||||
}
|
||||
|
||||
if (props.size) {
|
||||
ret['font-size'] = props.size;
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
function getDefaultIconStyle(color?: ColorValue | string, defaultColor?: ColorValue | string, additionalColorAttr?: string): Record<IconItemStyleName, IconItemStyleValue> {
|
||||
if (color && color !== DEFAULT_ICON_COLOR) {
|
||||
color = '#' + color;
|
||||
} else {
|
||||
color = defaultColor;
|
||||
}
|
||||
|
||||
const ret: Record<IconItemStyleName, IconItemStyleValue> = {
|
||||
color: color
|
||||
};
|
||||
|
||||
if (additionalColorAttr) {
|
||||
ret[additionalColorAttr] = color;
|
||||
}
|
||||
|
||||
if (props.size) {
|
||||
ret['font-size'] = props.size;
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
return {
|
||||
style,
|
||||
getAccountIcon,
|
||||
getCategoryIcon
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { computed } from 'vue';
|
||||
|
||||
import type { LanguageOption } from '@/locales/index.ts';
|
||||
import { useI18n } from '@/locales/helpers.ts';
|
||||
|
||||
import { useSettingsStore } from '@/stores/setting.ts';
|
||||
|
||||
export interface LanguageSelectBaseProps {
|
||||
disabled?: boolean;
|
||||
includeSystemDefault?: boolean;
|
||||
useModelValue?: boolean;
|
||||
modelValue?: string;
|
||||
}
|
||||
|
||||
export interface LanguageSelectBaseEmits {
|
||||
(e: 'update:modelValue', value: string): void;
|
||||
}
|
||||
|
||||
export function useLanguageSelectButtonBase(props: LanguageSelectBaseProps, emit: LanguageSelectBaseEmits) {
|
||||
const { getCurrentLanguageTag, getCurrentLanguageDisplayName, getAllLanguageOptions, getLanguageInfo, setLanguage } = useI18n();
|
||||
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
const allLanguages = computed<LanguageOption[]>(() => getAllLanguageOptions(!!props.includeSystemDefault));
|
||||
|
||||
const currentLocale = computed<string>({
|
||||
get: () => getCurrentLanguageTag(),
|
||||
set: (value: string) => {
|
||||
const localeDefaultSettings = setLanguage(value);
|
||||
settingsStore.updateLocalizedDefaultSettings(localeDefaultSettings);
|
||||
}
|
||||
});
|
||||
|
||||
const currentLanguageName = computed<string>(() => {
|
||||
if (props.useModelValue && props.modelValue) {
|
||||
const languageInfo = getLanguageInfo(props.modelValue);
|
||||
|
||||
if (!languageInfo) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return languageInfo.displayName;
|
||||
} else {
|
||||
return getCurrentLanguageDisplayName()
|
||||
}
|
||||
});
|
||||
|
||||
function updateLanguage(languageTag: string): void {
|
||||
if (props.useModelValue) {
|
||||
emit('update:modelValue', languageTag);
|
||||
} else {
|
||||
currentLocale.value = languageTag;
|
||||
}
|
||||
}
|
||||
|
||||
function isLanguageSelected(languageTag: string): boolean {
|
||||
if (props.useModelValue) {
|
||||
return props.modelValue === languageTag;
|
||||
} else {
|
||||
return currentLocale.value === languageTag;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// computed states
|
||||
allLanguages,
|
||||
currentLocale,
|
||||
currentLanguageName,
|
||||
// functions
|
||||
updateLanguage,
|
||||
isLanguageSelected
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { ref, computed } from 'vue';
|
||||
|
||||
import type { YearMonth } from '@/core/datetime.ts';
|
||||
|
||||
import {
|
||||
getYearMonthObjectFromUnixTime,
|
||||
getYearMonthObjectFromString,
|
||||
getYearMonthStringFromObject,
|
||||
getCurrentUnixTime,
|
||||
getCurrentYear,
|
||||
getThisYearFirstUnixTime,
|
||||
getYearMonthFirstUnixTime,
|
||||
getYearMonthLastUnixTime
|
||||
} from '@/lib/datetime.ts';
|
||||
|
||||
import { useI18n } from '@/locales/helpers.ts';
|
||||
|
||||
export interface CommonMonthRangeSelectionProps {
|
||||
minTime?: string;
|
||||
maxTime?: string;
|
||||
title?: string;
|
||||
hint?: string;
|
||||
show: boolean;
|
||||
}
|
||||
|
||||
function getMonthRangeFromProps(props: CommonMonthRangeSelectionProps): { minDate: YearMonth; maxDate: YearMonth } {
|
||||
let minDate: YearMonth = getYearMonthObjectFromUnixTime(getThisYearFirstUnixTime());
|
||||
let maxDate: YearMonth = getYearMonthObjectFromUnixTime(getCurrentUnixTime());
|
||||
|
||||
if (props.minTime) {
|
||||
const yearMonth = getYearMonthObjectFromString(props.minTime);
|
||||
|
||||
if (yearMonth) {
|
||||
minDate = yearMonth;
|
||||
}
|
||||
}
|
||||
|
||||
if (props.maxTime) {
|
||||
const yearMonth = getYearMonthObjectFromString(props.maxTime);
|
||||
|
||||
if (yearMonth) {
|
||||
maxDate = yearMonth;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
minDate,
|
||||
maxDate
|
||||
};
|
||||
}
|
||||
|
||||
export function useMonthRangeSelectionBase(props: CommonMonthRangeSelectionProps) {
|
||||
const { formatUnixTimeToLongYearMonth, isLongDateMonthAfterYear } = useI18n();
|
||||
const { minDate, maxDate } = getMonthRangeFromProps(props);
|
||||
|
||||
const yearRange = ref<number[]>([
|
||||
2000,
|
||||
getCurrentYear() + 1
|
||||
]);
|
||||
|
||||
const dateRange = ref<YearMonth[]>([
|
||||
minDate,
|
||||
maxDate
|
||||
]);
|
||||
|
||||
const isYearFirst = computed<boolean>(() => isLongDateMonthAfterYear());
|
||||
const beginDateTime = computed<string>(() => formatUnixTimeToLongYearMonth(getYearMonthFirstUnixTime(dateRange.value[0])));
|
||||
const endDateTime = computed<string>(() => formatUnixTimeToLongYearMonth(getYearMonthLastUnixTime(dateRange.value[1])));
|
||||
|
||||
function getFinalMonthRange(): { minYearMonth: string, maxYearMonth: string } | null {
|
||||
if (!dateRange.value[0] || !dateRange.value[1]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (dateRange.value[0].year <= 0 || dateRange.value[0].month < 0 || dateRange.value[1].year <= 0 || dateRange.value[1].month < 0) {
|
||||
throw new Error('Date is too early');
|
||||
}
|
||||
|
||||
const minYearMonth = getYearMonthStringFromObject(dateRange.value[0]);
|
||||
const maxYearMonth = getYearMonthStringFromObject(dateRange.value[1]);
|
||||
|
||||
return {
|
||||
minYearMonth,
|
||||
maxYearMonth
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
// states
|
||||
yearRange,
|
||||
dateRange,
|
||||
// computed states
|
||||
isYearFirst,
|
||||
beginDateTime,
|
||||
endDateTime,
|
||||
// functions
|
||||
getFinalMonthRange
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { ref, computed, watch } from 'vue';
|
||||
|
||||
import { useI18n } from '@/locales/helpers.ts';
|
||||
|
||||
import { DEFAULT_CHART_COLORS } from '@/consts/color.ts';
|
||||
|
||||
import { isNumber } from '@/lib/common.ts';
|
||||
import { formatPercent } from '@/lib/numeral.ts';
|
||||
|
||||
export interface CommonPieChartDataItem {
|
||||
id: string;
|
||||
name: string;
|
||||
displayName: string;
|
||||
value: number;
|
||||
percent: number;
|
||||
actualPercent: number;
|
||||
color: string;
|
||||
sourceItem: Record<string, unknown>;
|
||||
displayPercent?: string;
|
||||
displayValue?: string;
|
||||
}
|
||||
|
||||
export interface CommonPieChartProps {
|
||||
skeleton?: boolean;
|
||||
items: Record<string, unknown>[];
|
||||
idField?: string;
|
||||
nameField: string;
|
||||
valueField: string;
|
||||
percentField?: string;
|
||||
colorField?: string;
|
||||
hiddenField?: string;
|
||||
minValidPercent?: number;
|
||||
defaultCurrency?: string;
|
||||
showValue?: boolean;
|
||||
enableClickItem?: boolean;
|
||||
}
|
||||
|
||||
export function usePieChartBase(props: CommonPieChartProps) {
|
||||
const { formatAmountWithCurrency } = useI18n();
|
||||
|
||||
const selectedIndex = ref<number>(0);
|
||||
|
||||
const validItems = computed<CommonPieChartDataItem[]>(() => {
|
||||
let totalValidValue = 0;
|
||||
|
||||
for (let i = 0; i < props.items.length; i++) {
|
||||
const item = props.items[i];
|
||||
const value = item[props.valueField];
|
||||
|
||||
if (isNumber(value) && value > 0 && (!props.hiddenField || !item[props.hiddenField])) {
|
||||
totalValidValue += value;
|
||||
}
|
||||
}
|
||||
|
||||
const validItems: CommonPieChartDataItem[] = [];
|
||||
|
||||
for (let i = 0; i < props.items.length; i++) {
|
||||
const item = props.items[i];
|
||||
const value = item[props.valueField];
|
||||
const percent = props.percentField ? item[props.percentField] : -1;
|
||||
|
||||
if (isNumber(value) && value > 0 &&
|
||||
(!props.hiddenField || !item[props.hiddenField]) &&
|
||||
(!props.minValidPercent || value / totalValidValue > props.minValidPercent)) {
|
||||
const finalItem: CommonPieChartDataItem = {
|
||||
id: (props.idField && item[props.idField]) ? item[props.idField] as string : item[props.nameField] as string,
|
||||
name: (props.idField && item[props.idField]) ? item[props.idField] as string : item[props.nameField] as string,
|
||||
displayName: item[props.nameField] as string,
|
||||
value: value,
|
||||
percent: (isNumber(percent) && percent >= 0) ? percent : (value / totalValidValue * 100),
|
||||
actualPercent: value / totalValidValue,
|
||||
color: (props.colorField && item[props.colorField]) ? item[props.colorField] as string : DEFAULT_CHART_COLORS[validItems.length % DEFAULT_CHART_COLORS.length],
|
||||
sourceItem: item
|
||||
};
|
||||
|
||||
finalItem.displayPercent = formatPercent(finalItem.percent, 2, '<0.01');
|
||||
finalItem.displayValue = formatAmountWithCurrency(finalItem.value, props.defaultCurrency);
|
||||
|
||||
validItems.push(finalItem);
|
||||
}
|
||||
}
|
||||
|
||||
return validItems;
|
||||
});
|
||||
|
||||
watch(() => props.items, () => {
|
||||
selectedIndex.value = 0;
|
||||
});
|
||||
|
||||
return {
|
||||
// states
|
||||
selectedIndex,
|
||||
// computed states
|
||||
validItems
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { useI18n } from '@/locales/helpers.ts';
|
||||
|
||||
import { useUserStore } from '@/stores/user.ts';
|
||||
|
||||
import type { TypeAndDisplayName } from '@/core/base.ts';
|
||||
import { sortNumbersArray } from '@/lib/common.ts';
|
||||
|
||||
export interface CommonScheduleFrequencySelectionProps {
|
||||
type: number;
|
||||
modelValue: string;
|
||||
disabled?: boolean;
|
||||
readonly?: boolean;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface AvailableMonthDay {
|
||||
day: number;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
export function useScheduleFrequencySelectionBase() {
|
||||
const { getAllWeekDays, getAllTransactionScheduledFrequencyTypes, getMonthdayShortName } = useI18n();
|
||||
const userStore = useUserStore();
|
||||
|
||||
const allTransactionScheduledFrequencyTypes = computed<TypeAndDisplayName[]>(() => getAllTransactionScheduledFrequencyTypes());
|
||||
const allWeekDays = computed<TypeAndDisplayName[]>(() => getAllWeekDays(userStore.currentUserFirstDayOfWeek));
|
||||
|
||||
const allAvailableMonthDays = computed<AvailableMonthDay[]>(() => {
|
||||
const allAvailableDays = [];
|
||||
|
||||
for (let i = 1; i <= 28; i++) {
|
||||
allAvailableDays.push({
|
||||
day: i,
|
||||
displayName: getMonthdayShortName(i),
|
||||
});
|
||||
}
|
||||
|
||||
return allAvailableDays;
|
||||
});
|
||||
|
||||
function getFrequencyValues(value: string): number[] {
|
||||
const values = value.split(',');
|
||||
const ret: number[] = [];
|
||||
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
if (values[i]) {
|
||||
ret.push(parseInt(values[i]));
|
||||
}
|
||||
}
|
||||
|
||||
return sortNumbersArray(ret);
|
||||
}
|
||||
|
||||
return {
|
||||
// computed states
|
||||
allTransactionScheduledFrequencyTypes,
|
||||
allWeekDays,
|
||||
allAvailableMonthDays,
|
||||
// functions
|
||||
getFrequencyValues
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { useI18n } from '@/locales/helpers.ts';
|
||||
|
||||
import type {
|
||||
YearMonth,
|
||||
TimeRangeAndDateType,
|
||||
YearUnixTime,
|
||||
YearQuarterUnixTime,
|
||||
YearMonthUnixTime
|
||||
} from '@/core/datetime.ts';
|
||||
import type { ColorValue } from '@/core/color.ts';
|
||||
import { DEFAULT_ICON_COLOR } from '@/consts/color.ts';
|
||||
import type { YearMonthItems } from '@/models/transaction.ts';
|
||||
|
||||
import { getAllDateRanges } from '@/lib/statistics.ts';
|
||||
|
||||
export interface CommonTrendsChartProps<T extends YearMonth> {
|
||||
items: YearMonthItems<T>[];
|
||||
startYearMonth: string;
|
||||
endYearMonth: string;
|
||||
sortingType: number;
|
||||
dateAggregationType: number;
|
||||
idField?: string;
|
||||
nameField: string;
|
||||
valueField: string;
|
||||
colorField?: string;
|
||||
hiddenField?: string;
|
||||
displayOrdersField?: string;
|
||||
translateName?: boolean;
|
||||
defaultCurrency?: string;
|
||||
enableClickItem?: boolean;
|
||||
}
|
||||
|
||||
export interface TrendsBarChartClickEvent {
|
||||
itemId: string;
|
||||
dateRange: TimeRangeAndDateType;
|
||||
}
|
||||
|
||||
export function useTrendsChartBase<T extends YearMonth>(props: CommonTrendsChartProps<T>) {
|
||||
const { tt } = useI18n();
|
||||
|
||||
const allDateRanges = computed<YearUnixTime[] | YearQuarterUnixTime[] | YearMonthUnixTime[]>(() => getAllDateRanges(props.items, props.startYearMonth, props.endYearMonth, props.dateAggregationType));
|
||||
|
||||
function getItemName(name: string): string {
|
||||
return props.translateName ? tt(name) : name;
|
||||
}
|
||||
|
||||
function getColor(color: string): ColorValue {
|
||||
if (color && color !== DEFAULT_ICON_COLOR) {
|
||||
color = '#' + color;
|
||||
}
|
||||
|
||||
return color;
|
||||
}
|
||||
|
||||
return {
|
||||
// computed states
|
||||
allDateRanges,
|
||||
// functions
|
||||
getItemName,
|
||||
getColor
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { type Ref } from 'vue';
|
||||
|
||||
import {
|
||||
type TwoLevelItemSelectionBaseProps,
|
||||
useTwoLevelItemSelectionBase
|
||||
} from '@/components/base/TwoLevelItemSelectionBase.ts';
|
||||
|
||||
import { getItemByKeyValue, getPrimaryValueBySecondaryValue } from '@/lib/common.ts';
|
||||
|
||||
export interface CommonTwoColumnListItemSelectionProps extends TwoLevelItemSelectionBaseProps {
|
||||
primaryValueField?: string;
|
||||
primaryHeaderField?: string;
|
||||
primaryHeaderI18n?: boolean;
|
||||
primaryFooterField?: string;
|
||||
primaryFooterI18n?: boolean;
|
||||
secondaryHeaderField?: string;
|
||||
secondaryHeaderI18n?: boolean;
|
||||
secondaryFooterField?: string;
|
||||
secondaryFooterI18n?: boolean;
|
||||
}
|
||||
|
||||
export function useTwoColumnListItemSelectionBase(props: CommonTwoColumnListItemSelectionProps) {
|
||||
const {
|
||||
filterContent,
|
||||
visibleItemsCount,
|
||||
filteredItems,
|
||||
getFilteredSubItems,
|
||||
isSecondaryValueSelected,
|
||||
getSelectedSecondaryItem,
|
||||
updateCurrentSecondaryValue
|
||||
} = useTwoLevelItemSelectionBase(props);
|
||||
|
||||
function getCurrentPrimaryValueBySecondaryValue(secondaryValue: unknown): unknown {
|
||||
return getPrimaryValueBySecondaryValue(props.items as Record<string, Record<string, unknown>[]>[], props.primarySubItemsField, props.primaryValueField, props.primaryHiddenField, props.secondaryValueField, props.secondaryHiddenField, secondaryValue);
|
||||
}
|
||||
|
||||
function getSelectedPrimaryItem(currentPrimaryValue: unknown): unknown {
|
||||
if (props.primaryValueField) {
|
||||
return getItemByKeyValue(props.items, currentPrimaryValue, props.primaryValueField);
|
||||
} else {
|
||||
return currentPrimaryValue;
|
||||
}
|
||||
}
|
||||
|
||||
function updateCurrentPrimaryValue(currentPrimaryValue: Ref<unknown>, item: unknown): void {
|
||||
if (props.primaryValueField) {
|
||||
currentPrimaryValue.value = (item as Record<string, unknown>)[props.primaryValueField];
|
||||
} else {
|
||||
currentPrimaryValue.value = item;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// states
|
||||
filterContent,
|
||||
// computed states
|
||||
visibleItemsCount,
|
||||
filteredItems,
|
||||
// functions
|
||||
getFilteredSubItems,
|
||||
getCurrentPrimaryValueBySecondaryValue,
|
||||
isSecondaryValueSelected,
|
||||
getSelectedPrimaryItem,
|
||||
getSelectedSecondaryItem,
|
||||
updateCurrentPrimaryValue,
|
||||
updateCurrentSecondaryValue
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { type Ref, ref, computed } from 'vue';
|
||||
|
||||
import { useI18n } from '@/locales/helpers.ts';
|
||||
|
||||
import { getItemByKeyValue } from '@/lib/common.ts';
|
||||
|
||||
export interface TwoLevelItemSelectionBaseProps {
|
||||
modelValue: unknown;
|
||||
primaryKeyField?: string;
|
||||
primaryTitleField?: string;
|
||||
primaryTitleI18n?: boolean;
|
||||
primaryIconField?: string;
|
||||
primaryIconType?: string;
|
||||
primaryColorField?: string;
|
||||
primaryHiddenField?: string;
|
||||
primarySubItemsField: string;
|
||||
secondaryKeyField?: string;
|
||||
secondaryValueField?: string;
|
||||
secondaryTitleField?: string;
|
||||
secondaryTitleI18n?: boolean;
|
||||
secondaryIconField?: string;
|
||||
secondaryIconType?: string;
|
||||
secondaryColorField?: string;
|
||||
secondaryHiddenField?: string;
|
||||
enableFilter?: boolean;
|
||||
filterPlaceholder?: string;
|
||||
filterNoItemsText?: string;
|
||||
items: Record<string, unknown>[];
|
||||
}
|
||||
|
||||
export function useTwoLevelItemSelectionBase(props: TwoLevelItemSelectionBaseProps) {
|
||||
const { ti } = useI18n();
|
||||
|
||||
const filterContent = ref<string>('');
|
||||
|
||||
const visibleItemsCount = computed<number>(() => {
|
||||
let count = 0;
|
||||
|
||||
for (const item of props.items) {
|
||||
if (props.primaryHiddenField && item[props.primaryHiddenField]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
count++;
|
||||
}
|
||||
|
||||
return count;
|
||||
});
|
||||
|
||||
const filteredItems = computed<Record<string, unknown>[]>(() => {
|
||||
const finalItems: Record<string, unknown>[] = [];
|
||||
const items = props.items;
|
||||
const lowerCaseFilterContent = filterContent.value?.toLowerCase() ?? '';
|
||||
|
||||
for (const item of items) {
|
||||
if (props.primaryHiddenField && item[props.primaryHiddenField]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!props.enableFilter || !lowerCaseFilterContent) {
|
||||
finalItems.push(item);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (props.primaryTitleField) {
|
||||
const title = ti(item[props.primaryTitleField] as string, !!props.primaryTitleI18n);
|
||||
|
||||
if (title.toLowerCase().indexOf(lowerCaseFilterContent) >= 0) {
|
||||
finalItems.push(item);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (props.primarySubItemsField) {
|
||||
if (getFilteredSubItems(item).length > 0) {
|
||||
finalItems.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return finalItems;
|
||||
});
|
||||
|
||||
function getFilteredSubItems(selectedPrimaryItem: unknown): Record<string, unknown>[] {
|
||||
const finalItems: Record<string, unknown>[] = [];
|
||||
|
||||
if (!selectedPrimaryItem || !props.primarySubItemsField) {
|
||||
return finalItems;
|
||||
}
|
||||
|
||||
const subItems = (selectedPrimaryItem as Record<string, unknown>)[props.primarySubItemsField] as Record<string, unknown>[];
|
||||
let primaryTitleHasFilterContent = false;
|
||||
|
||||
if (props.primaryTitleField) {
|
||||
const title = ti((selectedPrimaryItem as Record<string, unknown>)[props.primaryTitleField] as string, !!props.primaryTitleI18n);
|
||||
primaryTitleHasFilterContent = title.toLowerCase().indexOf(filterContent.value.toLowerCase()) >= 0;
|
||||
}
|
||||
|
||||
for (const subItem of subItems) {
|
||||
if (props.secondaryHiddenField && subItem[props.secondaryHiddenField]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!props.enableFilter || !filterContent.value) {
|
||||
finalItems.push(subItem);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (primaryTitleHasFilterContent) {
|
||||
finalItems.push(subItem);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (props.secondaryTitleField && filterContent.value) {
|
||||
const title = ti(subItem[props.secondaryTitleField] as string, !!props.secondaryTitleI18n);
|
||||
|
||||
if (title.toLowerCase().indexOf(filterContent.value.toLowerCase()) >= 0) {
|
||||
finalItems.push(subItem);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return finalItems;
|
||||
}
|
||||
|
||||
function isSecondaryValueSelected(currentSecondaryValue: unknown, subItem: unknown): boolean {
|
||||
if (props.secondaryValueField) {
|
||||
return currentSecondaryValue === (subItem as Record<string, unknown>)[props.secondaryValueField];
|
||||
} else {
|
||||
return currentSecondaryValue === subItem;
|
||||
}
|
||||
}
|
||||
|
||||
function getSelectedSecondaryItem(currentSecondaryValue: unknown, selectedPrimaryItem: unknown): unknown {
|
||||
if (currentSecondaryValue && selectedPrimaryItem && (selectedPrimaryItem as Record<string, unknown>)[props.primarySubItemsField]) {
|
||||
return getItemByKeyValue((selectedPrimaryItem as Record<string, unknown>)[props.primarySubItemsField] as Record<string, unknown>[], currentSecondaryValue, props.secondaryValueField as string);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function updateCurrentSecondaryValue(currentSecondaryValue: Ref<unknown>, subItem: unknown): void {
|
||||
if (props.secondaryValueField) {
|
||||
currentSecondaryValue.value = (subItem as Record<string, unknown>)[props.secondaryValueField];
|
||||
} else {
|
||||
currentSecondaryValue.value = subItem;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// states
|
||||
filterContent,
|
||||
// computed states
|
||||
visibleItemsCount,
|
||||
filteredItems,
|
||||
// functions
|
||||
getFilteredSubItems,
|
||||
isSecondaryValueSelected,
|
||||
getSelectedSecondaryItem,
|
||||
updateCurrentSecondaryValue
|
||||
};
|
||||
}
|
||||
@@ -8,112 +8,104 @@
|
||||
v-if="!mapSupported || !mapDependencyLoaded"></slot>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {
|
||||
copyObjectTo
|
||||
} from '@/lib/common.js';
|
||||
import {
|
||||
createMapHolder,
|
||||
initMapInstance,
|
||||
setMapCenterTo,
|
||||
setMapCenterMarker,
|
||||
removeMapCenterMarker
|
||||
} from '@/lib/map/index.js';
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, useTemplateRef } from 'vue';
|
||||
|
||||
export default {
|
||||
props: [
|
||||
'height',
|
||||
'mapClass',
|
||||
'mapStyle',
|
||||
'geoLocation'
|
||||
],
|
||||
expose: [
|
||||
'init'
|
||||
],
|
||||
data() {
|
||||
this.mapHolder = createMapHolder();
|
||||
import { useI18n } from '@/locales/helpers.ts';
|
||||
|
||||
return {
|
||||
mapSupported: !!this.mapHolder,
|
||||
mapDependencyLoaded: this.mapHolder && this.mapHolder.dependencyLoaded,
|
||||
mapInited: false,
|
||||
initCenter: {
|
||||
import type { MapInstance, MapPosition } from '@/lib/map/base.ts';
|
||||
import { createMapInstance } from '@/lib/map/index.ts';
|
||||
|
||||
const props = defineProps<{
|
||||
height?: string;
|
||||
mapClass?: string;
|
||||
mapStyle?: Record<string, string>;
|
||||
geoLocation?: MapPosition;
|
||||
}>();
|
||||
|
||||
const { tt, getCurrentLanguageInfo } = useI18n();
|
||||
|
||||
const mapContainer = useTemplateRef<HTMLElement>('mapContainer');
|
||||
const mapInstance = ref<MapInstance | null>(createMapInstance());
|
||||
const initCenter = ref<MapPosition>({
|
||||
latitude: 0,
|
||||
longitude: 0,
|
||||
},
|
||||
zoomLevel: 1
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
finalMapStyle() {
|
||||
const styles = copyObjectTo(this.mapStyle, {});
|
||||
longitude: 0
|
||||
});
|
||||
const zoomLevel = ref<number>(1);
|
||||
|
||||
if (this.height) {
|
||||
styles.height = this.height;
|
||||
const mapSupported = computed<boolean>(() => !!mapInstance.value);
|
||||
const mapDependencyLoaded = computed<boolean>(() => mapInstance.value?.dependencyLoaded || false);
|
||||
|
||||
const finalMapStyle = computed<Record<string, unknown>>(() => {
|
||||
const styles: Record<string, unknown> = Object.assign({}, props.mapStyle);
|
||||
|
||||
if (props.height) {
|
||||
styles['height'] = props.height;
|
||||
}
|
||||
|
||||
if (!this.mapSupported || !this.mapDependencyLoaded) {
|
||||
styles.height = '0';
|
||||
if (!mapSupported.value || !mapDependencyLoaded.value) {
|
||||
styles['height'] = '0';
|
||||
}
|
||||
|
||||
return styles;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
init() {
|
||||
});
|
||||
|
||||
function initMapView(): void {
|
||||
let isFirstInit = false;
|
||||
let centerChanged = false;
|
||||
|
||||
if (!this.mapSupported || !this.mapDependencyLoaded) {
|
||||
if (!mapSupported.value || !mapDependencyLoaded.value || !mapInstance.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.geoLocation && (this.geoLocation.longitude || this.geoLocation.latitude)) {
|
||||
if (this.initCenter.latitude !== this.geoLocation.latitude || this.initCenter.longitude !== this.geoLocation.longitude) {
|
||||
this.initCenter.latitude = this.geoLocation.latitude;
|
||||
this.initCenter.longitude = this.geoLocation.longitude;
|
||||
this.zoomLevel = this.mapHolder.defaultZoomLevel;
|
||||
if (props.geoLocation && (props.geoLocation.longitude || props.geoLocation.latitude)) {
|
||||
if (initCenter.value.latitude !== props.geoLocation.latitude || initCenter.value.longitude !== props.geoLocation.longitude) {
|
||||
initCenter.value.latitude = props.geoLocation.latitude;
|
||||
initCenter.value.longitude = props.geoLocation.longitude;
|
||||
zoomLevel.value = mapInstance.value.defaultZoomLevel;
|
||||
|
||||
centerChanged = true;
|
||||
}
|
||||
} else if (!this.geoLocation || (!this.geoLocation.longitude && !this.geoLocation.latitude)) {
|
||||
if (this.initCenter.latitude || this.initCenter.longitude) {
|
||||
this.initCenter.latitude = 0;
|
||||
this.initCenter.longitude = 0;
|
||||
this.zoomLevel = this.mapHolder.minZoomLevel;
|
||||
} else if (!props.geoLocation || (!props.geoLocation.longitude && !props.geoLocation.latitude)) {
|
||||
if (initCenter.value.latitude || initCenter.value.longitude) {
|
||||
initCenter.value.latitude = 0;
|
||||
initCenter.value.longitude = 0;
|
||||
zoomLevel.value = mapInstance.value.minZoomLevel;
|
||||
|
||||
centerChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.mapHolder.inited) {
|
||||
const languageInfo = this.$locale.getCurrentLanguageInfo();
|
||||
if (!mapInstance.value.inited) {
|
||||
const languageInfo = getCurrentLanguageInfo();
|
||||
|
||||
initMapInstance(this.mapHolder, this.$refs.mapContainer, {
|
||||
language: languageInfo ? languageInfo.alternativeLanguageTag : null,
|
||||
initCenter: this.initCenter,
|
||||
zoomLevel: this.zoomLevel,
|
||||
mapInstance.value.initMapInstance(mapContainer.value as HTMLElement, {
|
||||
language: languageInfo?.alternativeLanguageTag,
|
||||
initCenter: initCenter.value,
|
||||
zoomLevel: zoomLevel.value,
|
||||
text: {
|
||||
zoomIn: this.$t('Zoom in'),
|
||||
zoomOut: this.$t('Zoom out'),
|
||||
zoomIn: tt('Zoom in'),
|
||||
zoomOut: tt('Zoom out'),
|
||||
}
|
||||
});
|
||||
|
||||
if (this.mapHolder.inited) {
|
||||
if (mapInstance.value.inited) {
|
||||
isFirstInit = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (isFirstInit || centerChanged) {
|
||||
setMapCenterTo(this.mapHolder, this.initCenter, this.zoomLevel);
|
||||
mapInstance.value.setMapCenterTo(initCenter.value, zoomLevel.value);
|
||||
}
|
||||
|
||||
if (centerChanged && this.zoomLevel > this.mapHolder.minZoomLevel) {
|
||||
setMapCenterMarker(this.mapHolder, this.initCenter);
|
||||
} else if (centerChanged && this.zoomLevel <= this.mapHolder.minZoomLevel) {
|
||||
removeMapCenterMarker(this.mapHolder);
|
||||
}
|
||||
}
|
||||
if (centerChanged && zoomLevel.value > mapInstance.value.minZoomLevel) {
|
||||
mapInstance.value.setMapCenterMarker(initCenter.value);
|
||||
} else if (centerChanged && zoomLevel.value <= mapInstance.value.minZoomLevel) {
|
||||
mapInstance.value.removeMapCenterMarker();
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
initMapView
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
<div class="pin-code-input pin-code-input-outline"
|
||||
:class="{ 'pin-code-input-focued': codes[index].focused }" :key="index"
|
||||
v-for="(code, index) in codes">
|
||||
<input min="0" maxlength="1" pattern="[0-9]*"
|
||||
:ref="`pin-code-input-${index}`"
|
||||
<input ref="pin-code-input" min="0" maxlength="1" pattern="[0-9]*"
|
||||
:value="codes[index].value"
|
||||
:type="codes[index].inputType"
|
||||
:disabled="disabled ? 'disabled' : undefined"
|
||||
@@ -19,66 +18,52 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: [
|
||||
'modelValue',
|
||||
'disabled',
|
||||
'autofocus',
|
||||
'secure',
|
||||
'length'
|
||||
],
|
||||
emits: [
|
||||
'update:modelValue',
|
||||
'pincode:confirm'
|
||||
],
|
||||
data() {
|
||||
return {
|
||||
codes: []
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
finalPinCode() {
|
||||
let finalPinCode = '';
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, useTemplateRef } from 'vue';
|
||||
|
||||
for (let i = 0; i < this.codes.length; i++) {
|
||||
if (this.codes[i].value) {
|
||||
finalPinCode += this.codes[i].value;
|
||||
interface PinCode {
|
||||
value: string;
|
||||
inputType: string;
|
||||
inputTimer: unknown | null;
|
||||
focused: boolean;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string;
|
||||
length: number;
|
||||
disabled?: boolean;
|
||||
autofocus?: boolean;
|
||||
autoConfirm?: boolean;
|
||||
secure?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string): void;
|
||||
(e: 'pincode:confirm', value: string): void;
|
||||
}>();
|
||||
|
||||
const codes = ref<PinCode[]>([]);
|
||||
const pinCodeInputs = useTemplateRef<HTMLInputElement[]>('pin-code-input');
|
||||
|
||||
const finalPinCode = computed<string>(() => {
|
||||
let ret = '';
|
||||
|
||||
for (let i = 0; i < codes.value.length; i++) {
|
||||
if (codes.value[i].value) {
|
||||
ret += codes.value[i].value;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return finalPinCode;
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
'length': function (newValue) {
|
||||
this.init(newValue, this.modelValue);
|
||||
},
|
||||
'modelValue': function (newValue) {
|
||||
if (newValue === this.finalPinCode) {
|
||||
return;
|
||||
}
|
||||
return ret;
|
||||
});
|
||||
|
||||
this.init(this.length, newValue);
|
||||
},
|
||||
'codes': {
|
||||
handler() {
|
||||
this.$emit('update:modelValue', this.finalPinCode);
|
||||
},
|
||||
deep: true
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.init(this.length, this.modelValue);
|
||||
},
|
||||
methods: {
|
||||
init(length, value) {
|
||||
this.codes.length = 0;
|
||||
function init(length: number, value: string): void {
|
||||
codes.value.length = 0;
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
const code = {
|
||||
const code: PinCode = {
|
||||
value: '',
|
||||
inputType: 'tel',
|
||||
inputTimer: null,
|
||||
@@ -88,111 +73,119 @@ export default {
|
||||
if (value && value[i]) {
|
||||
code.value = value[i];
|
||||
|
||||
if (this.secure) {
|
||||
if (props.secure) {
|
||||
code.inputType = 'password';
|
||||
}
|
||||
}
|
||||
|
||||
this.codes.push(code);
|
||||
codes.value.push(code);
|
||||
}
|
||||
},
|
||||
autoFillText(index, text) {
|
||||
}
|
||||
|
||||
function autoFillText(index: number, text: string): void {
|
||||
let lastIndex = index;
|
||||
|
||||
for (let i = index, j = 0; i < this.codes.length && j < text.length; i++, j++) {
|
||||
for (let i = index, j = 0; i < codes.value.length && j < text.length; i++, j++) {
|
||||
if (text[j] < '0' || text[j] > '9') {
|
||||
this.codes[i].value = '';
|
||||
this.$forceUpdate();
|
||||
codes.value[i].value = '';
|
||||
break;
|
||||
}
|
||||
|
||||
this.codes[i].value = text[j];
|
||||
this.setInputType(i);
|
||||
codes.value[i].value = text[j];
|
||||
setInputType(i);
|
||||
lastIndex = i;
|
||||
}
|
||||
|
||||
this.setFocus(lastIndex);
|
||||
setFocus(lastIndex);
|
||||
|
||||
if (this.finalPinCode.length === this.length) {
|
||||
this.$emit('pincode:confirm', this.finalPinCode);
|
||||
if (finalPinCode.value.length === length) {
|
||||
emit('pincode:confirm', finalPinCode.value);
|
||||
}
|
||||
},
|
||||
setInputType(index) {
|
||||
const self = this;
|
||||
}
|
||||
|
||||
if (!self.secure) {
|
||||
function setInputType(index: number): void {
|
||||
if (!props.secure) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!self.codes[index].value) {
|
||||
self.codes[index].inputType = 'tel';
|
||||
if (!codes.value[index].value) {
|
||||
codes.value[index].inputType = 'tel';
|
||||
return;
|
||||
}
|
||||
|
||||
if (self.codes[index].inputTimer) {
|
||||
if (codes.value[index].inputTimer) {
|
||||
return;
|
||||
}
|
||||
|
||||
self.codes[index].inputTimer = setTimeout(() => {
|
||||
if (self.codes[index].value) {
|
||||
self.codes[index].inputType = 'password';
|
||||
codes.value[index].inputTimer = setTimeout(() => {
|
||||
if (codes.value[index].value) {
|
||||
codes.value[index].inputType = 'password';
|
||||
} else {
|
||||
self.codes[index].inputType = 'tel';
|
||||
codes.value[index].inputType = 'tel';
|
||||
}
|
||||
|
||||
self.codes[index].inputTimer = null;
|
||||
codes.value[index].inputTimer = null;
|
||||
}, 300);
|
||||
},
|
||||
setFocus(index) {
|
||||
const refId = `pin-code-input-${index}`;
|
||||
const ref = this.$refs[refId];
|
||||
}
|
||||
|
||||
if (ref && ref[0]) {
|
||||
ref[0].focus();
|
||||
ref[0].select();
|
||||
function setFocus(index: number): void {
|
||||
if (pinCodeInputs.value && pinCodeInputs.value[index]) {
|
||||
pinCodeInputs.value[index].focus();
|
||||
pinCodeInputs.value[index].select();
|
||||
}
|
||||
},
|
||||
setPreviousFocus(index) {
|
||||
}
|
||||
|
||||
function setPreviousFocus(index: number): void {
|
||||
if (index > 0) {
|
||||
this.setFocus(index - 1);
|
||||
setFocus(index - 1);
|
||||
}
|
||||
},
|
||||
setNextFocus(index) {
|
||||
if (index < this.length - 1) {
|
||||
this.setFocus(index + 1);
|
||||
}
|
||||
|
||||
function setNextFocus(index: number): void {
|
||||
if (index < props.length - 1) {
|
||||
setFocus(index + 1);
|
||||
}
|
||||
},
|
||||
onKeydown(index, event) {
|
||||
}
|
||||
|
||||
function onKeydown(index: number, event: KeyboardEvent): void {
|
||||
if (event.altKey || (event.key.indexOf('F') === 0 && (event.key.length === 2 || event.key.length === 3))) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' && this.finalPinCode.length === this.length) {
|
||||
this.$emit('pincode:confirm', this.finalPinCode);
|
||||
if (index <= 0 && (event.shiftKey && event.key === 'Tab')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (index >= props.length - 1 && (!event.shiftKey && event.key === 'Tab')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' && finalPinCode.value.length === props.length) {
|
||||
emit('pincode:confirm', finalPinCode.value);
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowLeft' || (event.shiftKey && event.key === 'Tab')) {
|
||||
this.setPreviousFocus(index);
|
||||
setPreviousFocus(index);
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowRight' || (!event.shiftKey && event.key === 'Tab')) {
|
||||
this.setNextFocus(index);
|
||||
setNextFocus(index);
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Home') {
|
||||
this.setFocus(0);
|
||||
setFocus(0);
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'End') {
|
||||
this.setFocus(this.length - 1);
|
||||
setFocus(props.length - 1);
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
@@ -202,13 +195,13 @@ export default {
|
||||
}
|
||||
|
||||
if (event.key === 'Backspace' || event.key === 'Delete' || event.key === 'Del') {
|
||||
for (let i = index; i < this.codes.length; i++) {
|
||||
this.codes[i].value = '';
|
||||
this.setInputType(i);
|
||||
for (let i = index; i < codes.value.length; i++) {
|
||||
codes.value[i].value = '';
|
||||
setInputType(i);
|
||||
}
|
||||
|
||||
if (event.code === 'Backspace') {
|
||||
this.setPreviousFocus(index);
|
||||
setPreviousFocus(index);
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
@@ -216,18 +209,19 @@ export default {
|
||||
}
|
||||
|
||||
if (event.key.length === 1 && '0' <= event.key && event.key <= '9') {
|
||||
this.codes[index].value = event.key;
|
||||
this.setInputType(index);
|
||||
this.setNextFocus(index);
|
||||
codes.value[index].value = event.key;
|
||||
setInputType(index);
|
||||
setNextFocus(index);
|
||||
|
||||
if (this.finalPinCode.length === this.length) {
|
||||
this.$emit('pincode:confirm', this.finalPinCode);
|
||||
if (props.autoConfirm && finalPinCode.value.length === props.length) {
|
||||
emit('pincode:confirm', finalPinCode.value);
|
||||
}
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
},
|
||||
onPaste(index, event) {
|
||||
}
|
||||
|
||||
function onPaste(index: number, event: ClipboardEvent): void {
|
||||
if (!event.clipboardData) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
@@ -240,22 +234,41 @@ export default {
|
||||
return;
|
||||
}
|
||||
|
||||
this.autoFillText(index, text);
|
||||
autoFillText(index, text);
|
||||
|
||||
event.preventDefault();
|
||||
},
|
||||
onInput(index, event) {
|
||||
if (!event.target.value) {
|
||||
}
|
||||
|
||||
function onInput(index: number, event: Event | { target: { value: string }, preventDefault: () => void }): void {
|
||||
if (!event.target || !(event.target as { value: string }).value) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
this.autoFillText(index, event.target.value);
|
||||
autoFillText(index, (event.target as { value: string }).value);
|
||||
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.length, newValue => {
|
||||
init(newValue, props.modelValue);
|
||||
});
|
||||
|
||||
watch(() => props.modelValue, newValue => {
|
||||
if (newValue === finalPinCode.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
init(props.length, newValue);
|
||||
});
|
||||
|
||||
watch(codes, () => {
|
||||
emit('update:modelValue', finalPinCode.value);
|
||||
}, {
|
||||
deep: true
|
||||
});
|
||||
|
||||
init(props.length, props.modelValue);
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
:label="label" :placeholder="placeholder"
|
||||
:persistent-placeholder="!!persistentPlaceholder"
|
||||
:rules="enableRules ? rules : []" v-model="currentValue" v-if="!hide"
|
||||
@keydown="onKeyUpDown" @keyup="onKeyUpDown" @paste="onPaste">
|
||||
@keydown="onKeyUpDown" @keyup="onKeyUpDown" @paste="onPaste" @click="onClick">
|
||||
<template #prepend-inner v-if="currency && prependText">
|
||||
<div>{{ prependText }}</div>
|
||||
</template>
|
||||
@@ -19,7 +19,7 @@
|
||||
:label="label" :placeholder="placeholder"
|
||||
:persistent-placeholder="!!persistentPlaceholder"
|
||||
:rules="enableRules ? rules : []" v-model="currentValue" v-if="hide"
|
||||
@keydown="onKeyUpDown" @keyup="onKeyUpDown" @paste="onPaste">
|
||||
@keydown="onKeyUpDown" @keyup="onKeyUpDown" @paste="onPaste" @click="onClick">
|
||||
<template #prepend-inner v-if="currency && prependText">
|
||||
<div>{{ prependText }}</div>
|
||||
</template>
|
||||
@@ -29,147 +29,113 @@
|
||||
</v-text-field>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapStores } from 'pinia';
|
||||
import { useSettingsStore } from '@/stores/setting.js';
|
||||
import { useUserStore } from '@/stores/user.js';
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue';
|
||||
|
||||
import transactionConstants from '@/consts/transaction.js';
|
||||
import { removeAll } from '@/lib/common.js';
|
||||
import logger from '@/lib/logger.js';
|
||||
import { useI18n } from '@/locales/helpers.ts';
|
||||
|
||||
export default {
|
||||
props: [
|
||||
'class',
|
||||
'color',
|
||||
'density',
|
||||
'currency',
|
||||
'showCurrency',
|
||||
'label',
|
||||
'placeholder',
|
||||
'persistentPlaceholder',
|
||||
'disabled',
|
||||
'readonly',
|
||||
'hide',
|
||||
'enableRules',
|
||||
'modelValue'
|
||||
],
|
||||
emits: [
|
||||
'update:modelValue'
|
||||
],
|
||||
data() {
|
||||
const self = this;
|
||||
const userStore = useUserStore();
|
||||
import type { CurrencyPrependAndAppendText } from '@/core/currency.ts';
|
||||
import { TRANSACTION_MIN_AMOUNT, TRANSACTION_MAX_AMOUNT } from '@/consts/transaction.ts';
|
||||
import { removeAll } from '@/lib/common.ts';
|
||||
import type { ComponentDensity } from '@/lib/ui/desktop.ts';
|
||||
import logger from '@/lib/logger.ts';
|
||||
|
||||
return {
|
||||
currentValue: self.getFormattedValue(userStore, self.modelValue),
|
||||
rules: [
|
||||
(v) => {
|
||||
const props = defineProps<{
|
||||
class?: string;
|
||||
color?: string;
|
||||
density?: ComponentDensity;
|
||||
currency: string;
|
||||
showCurrency?: boolean;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
persistentPlaceholder?: boolean;
|
||||
disabled?: boolean;
|
||||
readonly?: boolean;
|
||||
hide?: boolean;
|
||||
enableRules?: boolean;
|
||||
flipNegative?: boolean;
|
||||
modelValue: number;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: number): void;
|
||||
}>();
|
||||
|
||||
const {
|
||||
tt,
|
||||
getCurrentDecimalSeparator,
|
||||
getCurrentDigitGroupingSymbol,
|
||||
parseAmount,
|
||||
formatAmount,
|
||||
getAmountPrependAndAppendText
|
||||
} = useI18n();
|
||||
|
||||
const rules = [
|
||||
(v: string) => {
|
||||
if (v === '') {
|
||||
return self.$t('Amount value is not number');
|
||||
return tt('Amount value is not number');
|
||||
}
|
||||
|
||||
try {
|
||||
const val = self.$locale.parseAmount(userStore, v);
|
||||
const val = parseAmount(v);
|
||||
|
||||
if (Number.isNaN(val) || !Number.isFinite(val)) {
|
||||
return self.$t('Amount value is not number');
|
||||
return tt('Amount value is not number');
|
||||
}
|
||||
|
||||
return (val >= transactionConstants.minAmountNumber && val <= transactionConstants.maxAmountNumber) || self.$t('Amount value exceeds limitation');
|
||||
return (val >= TRANSACTION_MIN_AMOUNT && val <= TRANSACTION_MAX_AMOUNT) || tt('Amount value exceeds limitation');
|
||||
} catch (ex) {
|
||||
logger.warn('cannot parse amount in amount input, original value is ' + v, ex);
|
||||
return self.$t('Amount value is not number');
|
||||
return tt('Amount value is not number');
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapStores(useSettingsStore, useUserStore),
|
||||
extraClass() {
|
||||
let finalClass = this.class;
|
||||
];
|
||||
|
||||
if (this.color) {
|
||||
finalClass += ` text-${this.color}`;
|
||||
}
|
||||
const currentValue = ref<string>(getInitedFormattedValue(props.modelValue, props.flipNegative));
|
||||
|
||||
if (this.currency && this.prependText) {
|
||||
finalClass += ` has-pretend-text`;
|
||||
}
|
||||
|
||||
return finalClass;
|
||||
},
|
||||
prependText() {
|
||||
if (!this.currency || !this.showCurrency) {
|
||||
const prependText = computed<string | undefined>(() => {
|
||||
if (!props.currency || !props.showCurrency) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const texts = this.getDisplayCurrencyPrependAndAppendText();
|
||||
const texts = getDisplayCurrencyPrependAndAppendText();
|
||||
|
||||
if (!texts) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return texts.prependText;
|
||||
},
|
||||
appendText() {
|
||||
if (!this.currency || !this.showCurrency) {
|
||||
});
|
||||
|
||||
const appendText = computed<string | undefined>(() => {
|
||||
if (!props.currency || !props.showCurrency) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const texts = this.getDisplayCurrencyPrependAndAppendText();
|
||||
const texts = getDisplayCurrencyPrependAndAppendText();
|
||||
|
||||
if (!texts) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return texts.appendText;
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
'currency': function () {
|
||||
const newStringValue = this.getFormattedValue(this.userStore, this.modelValue);
|
||||
});
|
||||
|
||||
if (!(newStringValue === '0' && this.currentValue === '')) {
|
||||
this.currentValue = newStringValue;
|
||||
}
|
||||
},
|
||||
'modelValue': function (newValue) {
|
||||
const numericCurrentValue = this.$locale.parseAmount(this.userStore, this.currentValue);
|
||||
const extraClass = computed<string>(() => {
|
||||
let finalClass = props.class || '';
|
||||
|
||||
if (newValue !== numericCurrentValue) {
|
||||
const newStringValue = this.getFormattedValue(this.userStore, newValue);
|
||||
|
||||
if (!(newStringValue === '0' && this.currentValue === '')) {
|
||||
this.currentValue = newStringValue;
|
||||
}
|
||||
}
|
||||
},
|
||||
'currentValue': function (newValue) {
|
||||
let finalValue = '';
|
||||
|
||||
if (newValue) {
|
||||
const decimalSeparator = this.$locale.getCurrentDecimalSeparator(this.userStore);
|
||||
|
||||
for (let i = 0; i < newValue.length; i++) {
|
||||
if (!('0' <= newValue[i] && newValue[i] <= '9') && newValue[i] !== '-' && newValue[i] !== decimalSeparator) {
|
||||
break;
|
||||
if (props.color) {
|
||||
finalClass += ` text-${props.color}`;
|
||||
}
|
||||
|
||||
finalValue += newValue[i];
|
||||
}
|
||||
if (props.currency && prependText.value) {
|
||||
finalClass += ` has-pretend-text`;
|
||||
}
|
||||
|
||||
if (finalValue !== newValue) {
|
||||
this.currentValue = finalValue;
|
||||
} else {
|
||||
this.$emit('update:modelValue', this.$locale.parseAmount(this.userStore, finalValue));
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onKeyUpDown(e) {
|
||||
return finalClass;
|
||||
});
|
||||
|
||||
function onKeyUpDown(e: KeyboardEvent): void {
|
||||
if (e.altKey || e.ctrlKey || e.metaKey || (e.key.indexOf('F') === 0 && (e.key.length === 2 || e.key.length === 3))
|
||||
|| e.key === 'ArrowLeft' || e.key === 'ArrowRight'
|
||||
|| e.key === 'Home' || e.key === 'End' || e.key === 'Tab'
|
||||
@@ -177,15 +143,26 @@ export default {
|
||||
return;
|
||||
}
|
||||
|
||||
const digitGroupingSymbol = this.$locale.getCurrentDigitGroupingSymbol(this.userStore);
|
||||
const decimalSeparator = this.$locale.getCurrentDecimalSeparator(this.userStore);
|
||||
if (props.readonly || props.disabled) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
const digitGroupingSymbol = getCurrentDigitGroupingSymbol();
|
||||
const decimalSeparator = getCurrentDecimalSeparator();
|
||||
|
||||
if (!('0' <= e.key && e.key <= '9') && e.key !== '-' && e.key !== decimalSeparator) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
let str = e.target.value;
|
||||
if (!e.target) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = e.target as HTMLInputElement;
|
||||
|
||||
let str = target.value;
|
||||
|
||||
if (str.indexOf(digitGroupingSymbol) >= 0) {
|
||||
str = removeAll(str, digitGroupingSymbol);
|
||||
@@ -193,16 +170,16 @@ export default {
|
||||
|
||||
if (e.key === '-' && str.lastIndexOf('-') > 0) {
|
||||
const lastMinusPos = str.lastIndexOf('-');
|
||||
e.target.value = str.substring(0, lastMinusPos) + str.substring(lastMinusPos + 1, str.length);
|
||||
this.currentValue = e.target.value;
|
||||
target.value = str.substring(0, lastMinusPos) + str.substring(lastMinusPos + 1, str.length);
|
||||
currentValue.value = target.value;
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === decimalSeparator && str.indexOf(decimalSeparator) !== str.lastIndexOf(decimalSeparator)) {
|
||||
const lastDecimalSeparatorPos = str.lastIndexOf(decimalSeparator);
|
||||
e.target.value = str.substring(0, lastDecimalSeparatorPos) + str.substring(lastDecimalSeparatorPos + 1, str.length);
|
||||
this.currentValue = e.target.value;
|
||||
target.value = str.substring(0, lastDecimalSeparatorPos) + str.substring(lastDecimalSeparatorPos + 1, str.length);
|
||||
currentValue.value = target.value;
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
@@ -215,14 +192,14 @@ export default {
|
||||
}
|
||||
|
||||
str = (negative ? '-0' : '0') + str;
|
||||
e.target.value = str;
|
||||
this.currentValue = e.target.value;
|
||||
target.value = str;
|
||||
currentValue.value = target.value;
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
let decimalLength = 0;
|
||||
let decimalIndex = str.indexOf(decimalSeparator);
|
||||
const decimalIndex = str.indexOf(decimalSeparator);
|
||||
|
||||
if (decimalIndex >= 0) {
|
||||
decimalLength = str.length - str.indexOf(decimalSeparator) - 1;
|
||||
@@ -237,34 +214,36 @@ export default {
|
||||
str = str.substring(1);
|
||||
}
|
||||
|
||||
e.target.value = (negative ? '-' : '') + str;
|
||||
this.currentValue = e.target.value;
|
||||
target.value = (negative ? '-' : '') + str;
|
||||
currentValue.value = target.value;
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (decimalLength > 2) {
|
||||
e.target.value = str.substring(0, Math.min(decimalIndex + 3, str.length - 1));
|
||||
this.currentValue = e.target.value;
|
||||
target.value = str.substring(0, Math.min(decimalIndex + 3, str.length - 1));
|
||||
currentValue.value = target.value;
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const val = this.$locale.parseAmount(this.userStore, str);
|
||||
const finalValue = this.getValidFormattedValue(val, str, decimalIndex >= 0);
|
||||
const val = parseAmount(str);
|
||||
const finalValue = getValidFormattedValue(val, str, decimalIndex >= 0);
|
||||
|
||||
if (finalValue !== str) {
|
||||
e.target.value = finalValue;
|
||||
this.currentValue = finalValue;
|
||||
target.value = finalValue;
|
||||
currentValue.value = finalValue;
|
||||
e.preventDefault();
|
||||
}
|
||||
} catch (ex) {
|
||||
ex.target.value = '0';
|
||||
logger.warn('cannot parse amount in amount input, original value is ' + str, ex);
|
||||
target.value = '0';
|
||||
}
|
||||
},
|
||||
onPaste(e) {
|
||||
if (!e.clipboardData) {
|
||||
}
|
||||
|
||||
function onPaste(e: ClipboardEvent): void {
|
||||
if (!e.clipboardData || props.readonly || props.disabled) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
@@ -276,25 +255,36 @@ export default {
|
||||
return;
|
||||
}
|
||||
|
||||
const value = this.$locale.parseAmount(this.userStore, text);
|
||||
const textualValue = this.getFormattedValue(this.userStore, value);
|
||||
const decimalSeparator = this.$locale.getCurrentDecimalSeparator(this.userStore);
|
||||
const value = parseAmount(text);
|
||||
const textualValue = getFormattedValue(value);
|
||||
const decimalSeparator = getCurrentDecimalSeparator();
|
||||
const hasDecimalSeparator = text.indexOf(decimalSeparator) >= 0;
|
||||
|
||||
this.currentValue = this.getValidFormattedValue(value, textualValue, hasDecimalSeparator);
|
||||
currentValue.value = getValidFormattedValue(value, textualValue, hasDecimalSeparator);
|
||||
e.preventDefault();
|
||||
},
|
||||
getValidFormattedValue(value, textualValue, hasDecimalSeparator) {
|
||||
let maxLength = transactionConstants.maxAmountNumber.toString().length;
|
||||
}
|
||||
|
||||
function onClick(e: MouseEvent): void {
|
||||
if (!props.disabled && !props.readonly && props.modelValue === 0 && e.target instanceof HTMLInputElement) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
|
||||
if ((!input?.selectionStart && !input?.selectionEnd) || input?.selectionStart === input?.selectionEnd) {
|
||||
input?.select();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getValidFormattedValue(value: number, textualValue: string, hasDecimalSeparator: boolean): string {
|
||||
let maxLength = TRANSACTION_MAX_AMOUNT.toString().length;
|
||||
|
||||
if (value < 0) {
|
||||
maxLength = transactionConstants.minAmountNumber.toString().length;
|
||||
maxLength = TRANSACTION_MIN_AMOUNT.toString().length;
|
||||
}
|
||||
|
||||
if (value < transactionConstants.minAmountNumber) {
|
||||
return this.getFormattedValue(this.userStore, transactionConstants.minAmountNumber);
|
||||
} else if (value > transactionConstants.maxAmountNumber) {
|
||||
return this.getFormattedValue(this.userStore, transactionConstants.maxAmountNumber);
|
||||
if (value < TRANSACTION_MIN_AMOUNT) {
|
||||
return getFormattedValue(TRANSACTION_MIN_AMOUNT);
|
||||
} else if (value > TRANSACTION_MAX_AMOUNT) {
|
||||
return getFormattedValue(TRANSACTION_MAX_AMOUNT);
|
||||
}
|
||||
|
||||
if (!hasDecimalSeparator && textualValue.length > maxLength) {
|
||||
@@ -304,26 +294,98 @@ export default {
|
||||
}
|
||||
|
||||
return textualValue;
|
||||
},
|
||||
getFormattedValue(userStore, value) {
|
||||
}
|
||||
|
||||
function getInitedFormattedValue(value: number, flipNegative?: boolean): string {
|
||||
if (flipNegative) {
|
||||
value = -value;
|
||||
}
|
||||
|
||||
return getFormattedValue(value);
|
||||
}
|
||||
|
||||
function getFormattedValue(value: number): string {
|
||||
if (!Number.isNaN(value) && Number.isFinite(value)) {
|
||||
const digitGroupingSymbol = this.$locale.getCurrentDigitGroupingSymbol(userStore);
|
||||
return removeAll(this.$locale.formatAmount(userStore, value, this.currency), digitGroupingSymbol);
|
||||
const digitGroupingSymbol = getCurrentDigitGroupingSymbol();
|
||||
return removeAll(formatAmount(value, props.currency), digitGroupingSymbol);
|
||||
}
|
||||
|
||||
return '0';
|
||||
},
|
||||
getDisplayCurrencyPrependAndAppendText() {
|
||||
const numericCurrentValue = this.$locale.parseAmount(this.userStore, this.currentValue);
|
||||
}
|
||||
|
||||
function getDisplayCurrencyPrependAndAppendText(): CurrencyPrependAndAppendText | null {
|
||||
const numericCurrentValue = parseAmount(currentValue.value);
|
||||
const isPlural = numericCurrentValue !== 100 && numericCurrentValue !== -100;
|
||||
|
||||
return this.$locale.getAmountPrependAndAppendText(this.settingsStore, this.userStore, this.currency, isPlural);
|
||||
}
|
||||
}
|
||||
return getAmountPrependAndAppendText(props.currency, isPlural);
|
||||
}
|
||||
|
||||
watch(() => props.currency, () => {
|
||||
const newStringValue = getInitedFormattedValue(props.modelValue, props.flipNegative);
|
||||
|
||||
if (!(newStringValue === '0' && currentValue.value === '')) {
|
||||
currentValue.value = newStringValue;
|
||||
}
|
||||
});
|
||||
|
||||
watch(() => props.flipNegative, (newValue) => {
|
||||
const newStringValue = getInitedFormattedValue(props.modelValue, newValue);
|
||||
|
||||
if (!(newStringValue === '0' && currentValue.value === '')) {
|
||||
currentValue.value = newStringValue;
|
||||
}
|
||||
});
|
||||
|
||||
watch(() => props.modelValue, (newValue) => {
|
||||
if (props.flipNegative) {
|
||||
newValue = -newValue;
|
||||
}
|
||||
|
||||
const numericCurrentValue = parseAmount(currentValue.value);
|
||||
|
||||
if (newValue !== numericCurrentValue) {
|
||||
const newStringValue = getFormattedValue(newValue);
|
||||
|
||||
if (!(newStringValue === '0' && currentValue.value === '')) {
|
||||
currentValue.value = newStringValue;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
watch(currentValue, (newValue) => {
|
||||
let finalValue = '';
|
||||
|
||||
if (newValue) {
|
||||
const decimalSeparator = getCurrentDecimalSeparator();
|
||||
|
||||
for (let i = 0; i < newValue.length; i++) {
|
||||
if (!('0' <= newValue[i] && newValue[i] <= '9') && newValue[i] !== '-' && newValue[i] !== decimalSeparator) {
|
||||
break;
|
||||
}
|
||||
|
||||
finalValue += newValue[i];
|
||||
}
|
||||
}
|
||||
|
||||
if (finalValue !== newValue) {
|
||||
currentValue.value = finalValue;
|
||||
} else {
|
||||
let value: number = parseAmount(finalValue);
|
||||
|
||||
if (props.flipNegative) {
|
||||
value = -value;
|
||||
}
|
||||
|
||||
emit('update:modelValue', value);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.text-field-with-colored-label.has-pretend-text .v-field {
|
||||
grid-template-columns: max-content minmax(0, 1fr) min-content min-content;
|
||||
}
|
||||
|
||||
.text-field-with-colored-label.has-pretend-text .v-field__input {
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
@@ -10,31 +10,36 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: [
|
||||
'disabled',
|
||||
'buttons',
|
||||
'modelValue'
|
||||
],
|
||||
emits: [
|
||||
'update:modelValue'
|
||||
],
|
||||
computed: {
|
||||
value: {
|
||||
get: function () {
|
||||
return this.modelValue;
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
interface Button {
|
||||
name: string;
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: unknown;
|
||||
buttons: Button[];
|
||||
disabled?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: unknown): void;
|
||||
}>();
|
||||
|
||||
const value = computed<unknown>({
|
||||
get: () => {
|
||||
return props.modelValue;
|
||||
},
|
||||
set: function (value) {
|
||||
if (value === this.modelValue) {
|
||||
set: value => {
|
||||
if (value === props.modelValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.$emit('update:modelValue', value);
|
||||
emit('update:modelValue', value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
>
|
||||
<template #selection="{ item }">
|
||||
<v-label class="cursor-pointer" style="padding-top: 3px">
|
||||
<v-icon size="28" :icon="icons.square" :color="getFinalColor(item.raw)"/>
|
||||
<v-icon size="28" :icon="mdiSquareRounded" :color="getFinalColor(item.raw)"/>
|
||||
</v-label>
|
||||
</template>
|
||||
|
||||
@@ -23,12 +23,12 @@
|
||||
<div class="text-center" :key="colorInfo.color" v-for="colorInfo in row">
|
||||
<div class="cursor-pointer" @click="color = colorInfo.color">
|
||||
<v-icon class="ma-2" size="28"
|
||||
:icon="icons.square" :color="getFinalColor(colorInfo.color)"
|
||||
:icon="mdiSquareRounded" :color="getFinalColor(colorInfo.color)"
|
||||
v-if="!modelValue || modelValue !== colorInfo.color" />
|
||||
<v-badge class="right-bottom-icon" color="primary"
|
||||
location="bottom right" offset-x="8" offset-y="8" :icon="icons.checked"
|
||||
location="bottom right" offset-x="8" offset-y="8" :icon="mdiCheck"
|
||||
v-if="modelValue && modelValue === colorInfo.color">
|
||||
<v-icon class="ma-2" size="28" :icon="icons.square" :color="getFinalColor(colorInfo.color)" />
|
||||
<v-icon class="ma-2" size="28" :icon="mdiSquareRounded" :color="getFinalColor(colorInfo.color)" />
|
||||
</v-badge>
|
||||
</div>
|
||||
</div>
|
||||
@@ -38,75 +38,62 @@
|
||||
</v-select>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import colorConstants from '@/consts/color.js';
|
||||
import { arrayContainsFieldValue } from '@/lib/common.js';
|
||||
import { getColorsInRows } from '@/lib/color.js';
|
||||
import { scrollToSelectedItem } from '@/lib/ui.desktop.js';
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, useTemplateRef, nextTick } from 'vue';
|
||||
|
||||
import type { ColorValue, ColorInfo } from '@/core/color.ts';
|
||||
import { DEFAULT_ICON_COLOR } from '@/consts/color.ts';
|
||||
import { arrayContainsFieldValue } from '@/lib/common.ts';
|
||||
import { getColorsInRows } from '@/lib/color.ts';
|
||||
import { scrollToSelectedItem } from '@/lib/ui/desktop.ts';
|
||||
|
||||
import {
|
||||
mdiSquareRounded,
|
||||
mdiCheck
|
||||
} from '@mdi/js';
|
||||
|
||||
export default {
|
||||
props: [
|
||||
'modelValue',
|
||||
'disabled',
|
||||
'label',
|
||||
'columnCount',
|
||||
'allColorInfos'
|
||||
],
|
||||
emits: [
|
||||
'update:modelValue',
|
||||
],
|
||||
data() {
|
||||
const self = this;
|
||||
const props = defineProps<{
|
||||
modelValue: ColorValue;
|
||||
disabled?: boolean;
|
||||
label?: string;
|
||||
columnCount?: number;
|
||||
allColorInfos: ColorValue[];
|
||||
}>();
|
||||
|
||||
return {
|
||||
itemPerRow: self.columnCount || 7,
|
||||
icons: {
|
||||
square: mdiSquareRounded,
|
||||
checked: mdiCheck
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
allColorRows() {
|
||||
return getColorsInRows(this.allColorInfos, this.itemPerRow);
|
||||
},
|
||||
color: {
|
||||
get: function () {
|
||||
return this.modelValue;
|
||||
},
|
||||
set: function (value) {
|
||||
this.$emit('update:modelValue', value);
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
hasSelectedIcon(row) {
|
||||
return arrayContainsFieldValue(row, 'id', this.modelValue);
|
||||
},
|
||||
getFinalColor(color) {
|
||||
if (color && color !== colorConstants.defaultAccountColor) {
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: ColorValue): void;
|
||||
}>();
|
||||
|
||||
const dropdownMenu = useTemplateRef<HTMLElement>('dropdownMenu');
|
||||
const itemPerRow = ref<number>(props.columnCount || 7);
|
||||
|
||||
const allColorRows = computed<ColorInfo[][]>(() => getColorsInRows(props.allColorInfos, itemPerRow.value));
|
||||
|
||||
const color = computed<ColorValue>({
|
||||
get: () => props.modelValue,
|
||||
set: (value: ColorValue) => emit('update:modelValue', value)
|
||||
});
|
||||
|
||||
function hasSelectedIcon(row: ColorInfo[]): boolean {
|
||||
return arrayContainsFieldValue(row, 'id', props.modelValue);
|
||||
}
|
||||
|
||||
function getFinalColor(color: ColorValue): string {
|
||||
if (color && color !== DEFAULT_ICON_COLOR) {
|
||||
return '#' + color;
|
||||
} else {
|
||||
return 'var(--default-icon-color)';
|
||||
}
|
||||
},
|
||||
onMenuStateChanged(state) {
|
||||
const self = this;
|
||||
}
|
||||
|
||||
function onMenuStateChanged(state: boolean): void {
|
||||
if (state) {
|
||||
self.$nextTick(() => {
|
||||
if (self.$refs.dropdownMenu && self.$refs.dropdownMenu.parentElement) {
|
||||
scrollToSelectedItem(self.$refs.dropdownMenu.parentElement, null, '.row-has-selected-item');
|
||||
nextTick(() => {
|
||||
if (dropdownMenu.value && dropdownMenu.value.parentElement) {
|
||||
scrollToSelectedItem(dropdownMenu.value.parentElement, null, '.row-has-selected-item');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -7,84 +7,96 @@
|
||||
<v-card-text v-if="textContent" class="pa-4 pb-6">{{ textContent }}</v-card-text>
|
||||
<v-card-actions class="px-4 pb-4">
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn color="gray" @click="cancel">{{ $t('Cancel') }}</v-btn>
|
||||
<v-btn :color="finalColor" @click="confirm">{{ $t('OK') }}</v-btn>
|
||||
<v-btn color="gray" @click="cancel">{{ tt('Cancel') }}</v-btn>
|
||||
<v-btn :color="finalColor" @click="confirm">{{ tt('OK') }}</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { isString } from '@/lib/common.js';
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
|
||||
export default {
|
||||
props: [
|
||||
'show',
|
||||
'color',
|
||||
'title',
|
||||
'text'
|
||||
],
|
||||
emits: [
|
||||
'update:show'
|
||||
],
|
||||
expose: [
|
||||
'open'
|
||||
],
|
||||
data() {
|
||||
const self = this;
|
||||
import { useI18n } from '@/locales/helpers.ts';
|
||||
|
||||
return {
|
||||
showState: self.show,
|
||||
titleContent: self.title || self.$t('global.app.title'),
|
||||
textContent: self.text || '',
|
||||
finalColor: self.color || 'primary',
|
||||
resolve: null,
|
||||
reject: null
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
'showState': function (newValue) {
|
||||
this.$emit('update:show', newValue);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
open(title, text, options) {
|
||||
this.showState = true;
|
||||
import { isString, isObject } from '@/lib/common.ts';
|
||||
|
||||
if (isString(text)) {
|
||||
this.titleContent = this.$t(title, options);
|
||||
this.textContent = this.$t(text, options);
|
||||
const props = defineProps<{
|
||||
show?: boolean;
|
||||
color?: string;
|
||||
title?: string;
|
||||
text?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:show', value: boolean): void;
|
||||
}>();
|
||||
|
||||
const { tt } = useI18n();
|
||||
|
||||
const showState = ref<boolean>(false);
|
||||
const titleContent = ref<string>(props.title || tt('global.app.title'));
|
||||
const textContent = ref<string>(props.text || '');
|
||||
const finalColor = ref<string>(props.color || 'primary');
|
||||
|
||||
let resolveFunc: ((value?: unknown) => void) | null = null;
|
||||
let rejectFunc: ((reason?: unknown) => void) | null = null;
|
||||
|
||||
function open(titleOrText: string, textOrOptions?: string | Record<string, unknown>, options?: Record<string, unknown>): Promise<unknown> {
|
||||
showState.value = true;
|
||||
|
||||
if (!textOrOptions || isObject(textOrOptions)) { // only one parameter or second parameter is options
|
||||
titleContent.value = tt('global.app.title');
|
||||
|
||||
if (!textOrOptions) {
|
||||
textContent.value = tt(titleOrText);
|
||||
} else {
|
||||
options = text;
|
||||
this.titleContent = this.$t('global.app.title');
|
||||
this.textContent = this.$t(title, options);
|
||||
const actualOptions = textOrOptions as Record<string, unknown>;
|
||||
textContent.value = tt(titleOrText, actualOptions);
|
||||
}
|
||||
} else if (isString(textOrOptions)) { // second parameter is text
|
||||
if (!options) {
|
||||
titleContent.value = tt(titleOrText);
|
||||
textContent.value = tt(textOrOptions);
|
||||
} else {
|
||||
titleContent.value = tt(titleOrText, options);
|
||||
textContent.value = tt(textOrOptions, options);
|
||||
}
|
||||
}
|
||||
|
||||
if (options && options.color) {
|
||||
this.finalColor = options.color || 'primary';
|
||||
if (options && isString(options['color'])) {
|
||||
finalColor.value = (options['color'] as string) || 'primary';
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.resolve = resolve;
|
||||
this.reject = reject;
|
||||
resolveFunc = resolve;
|
||||
rejectFunc = reject;
|
||||
});
|
||||
},
|
||||
confirm() {
|
||||
if (this.resolve) {
|
||||
this.resolve();
|
||||
}
|
||||
|
||||
this.showState = false;
|
||||
this.$emit('update:show', false);
|
||||
},
|
||||
cancel() {
|
||||
if (this.reject) {
|
||||
this.reject();
|
||||
}
|
||||
|
||||
this.showState = false;
|
||||
this.$emit('update:show', false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function confirm(): void {
|
||||
if (resolveFunc) {
|
||||
resolveFunc();
|
||||
}
|
||||
|
||||
showState.value = false;
|
||||
emit('update:show', false);
|
||||
}
|
||||
|
||||
function cancel(): void {
|
||||
if (rejectFunc) {
|
||||
rejectFunc();
|
||||
}
|
||||
|
||||
showState.value = false;
|
||||
emit('update:show', false);
|
||||
}
|
||||
|
||||
watch(showState, newValue => {
|
||||
emit('update:show', newValue);
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
open
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
<template>
|
||||
<v-autocomplete
|
||||
item-title="displayName"
|
||||
item-value="currencyCode"
|
||||
auto-select-first
|
||||
persistent-placeholder
|
||||
:disabled="disabled"
|
||||
:label="label"
|
||||
:placeholder="placeholder"
|
||||
:items="allCurrencies"
|
||||
:no-data-text="tt('No results')"
|
||||
:custom-filter="filterCurrency"
|
||||
v-model="currentCurrencyValue"
|
||||
>
|
||||
<template #append-inner>
|
||||
<small class="text-field-append-text smaller">{{ currentCurrencyValue }}</small>
|
||||
</template>
|
||||
|
||||
<template #item="{ props, item }">
|
||||
<v-list-item :value="item.value" v-bind="props">
|
||||
<template #title>
|
||||
<v-list-item-title>
|
||||
<div class="d-flex align-center">
|
||||
<span>{{ item.title }}</span>
|
||||
<v-spacer style="min-width: 40px" />
|
||||
<v-icon :icon="mdiCheck" v-if="currentCurrencyValue === item.raw.currencyCode" />
|
||||
<small class="text-field-append-text" v-if="currentCurrencyValue !== item.raw.currencyCode">{{ item.raw.currencyCode }}</small>
|
||||
</div>
|
||||
</v-list-item-title>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</template>
|
||||
</v-autocomplete>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from '@/locales/helpers.ts';
|
||||
|
||||
import type { LocalizedCurrencyInfo } from '@/core/currency.ts';
|
||||
|
||||
import {
|
||||
mdiCheck
|
||||
} from '@mdi/js';
|
||||
|
||||
const props = defineProps<{
|
||||
disabled?: boolean;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
modelValue: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string): void;
|
||||
}>();
|
||||
|
||||
const { tt, getAllCurrencies } = useI18n();
|
||||
|
||||
const allCurrencies = computed<LocalizedCurrencyInfo[]>(() => getAllCurrencies());
|
||||
|
||||
const currentCurrencyValue = computed<string | null>({
|
||||
get: () => props.modelValue,
|
||||
set: (value: string | null) => {
|
||||
if (value === null) {
|
||||
emit('update:modelValue', '');
|
||||
} else {
|
||||
emit('update:modelValue', value);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function filterCurrency(value: string, query: string, item?: { value: unknown, raw: LocalizedCurrencyInfo }): boolean {
|
||||
if (!item) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const lowerCaseFilterContent = query.toLowerCase() || '';
|
||||
|
||||
if (!lowerCaseFilterContent) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return item.raw.displayName.toLowerCase().indexOf(lowerCaseFilterContent) >= 0
|
||||
|| item.raw.currencyCode.toLowerCase().indexOf(lowerCaseFilterContent) >= 0;
|
||||
}
|
||||
</script>
|
||||
@@ -19,7 +19,6 @@
|
||||
</template>
|
||||
<v-card-text class="mb-md-4 w-100 d-flex justify-center">
|
||||
<vue-date-picker inline enable-seconds auto-apply
|
||||
ref="datetimepicker"
|
||||
month-name-format="long"
|
||||
six-weeks="center"
|
||||
:clearable="false"
|
||||
@@ -39,185 +38,94 @@
|
||||
{{ getMonthShortName(text) }}
|
||||
</template>
|
||||
<template #am-pm-button="{ toggle, value }">
|
||||
<button class="dp__pm_am_button" tabindex="0" @click="toggle">{{ $t(`datetime.${value}.content`) }}</button>
|
||||
<button class="dp__pm_am_button" tabindex="0" @click="toggle">{{ tt(`datetime.${value}.content`) }}</button>
|
||||
</template>
|
||||
</vue-date-picker>
|
||||
</v-card-text>
|
||||
<v-card-text class="overflow-y-visible">
|
||||
<div class="w-100 d-flex justify-center gap-4">
|
||||
<v-btn :disabled="!dateRange[0] || !dateRange[1]" @click="confirm">{{ $t('OK') }}</v-btn>
|
||||
<v-btn color="secondary" variant="tonal" @click="cancel">{{ $t('Cancel') }}</v-btn>
|
||||
<v-btn :disabled="!dateRange[0] || !dateRange[1]" @click="confirm">{{ tt('OK') }}</v-btn>
|
||||
<v-btn color="secondary" variant="tonal" @click="cancel">{{ tt('Cancel') }}</v-btn>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup lang="ts">
|
||||
import { computed, watch } from 'vue';
|
||||
import { useTheme } from 'vuetify';
|
||||
|
||||
import { mapStores } from 'pinia';
|
||||
import { useUserStore } from '@/stores/user.js';
|
||||
import { useI18n } from '@/locales/helpers.ts';
|
||||
import { type CommonDateRangeSelectionProps, useDateRangeSelectionBase } from '@/components/base/DateRangeSelectionBase.ts';
|
||||
|
||||
import { useUserStore } from '@/stores/user.ts';
|
||||
|
||||
import { ThemeType } from '@/core/theme.ts';
|
||||
|
||||
import datetimeConstants from '@/consts/datetime.js';
|
||||
import { arrangeArrayWithNewStartIndex } from '@/lib/common.js';
|
||||
import {
|
||||
getCurrentUnixTime,
|
||||
getCurrentYear,
|
||||
getUnixTime,
|
||||
getLocalDatetimeFromUnixTime,
|
||||
getTodayFirstUnixTime,
|
||||
getDummyUnixTimeForLocalUsage,
|
||||
getActualUnixTimeForStore,
|
||||
getTimezoneOffsetMinutes,
|
||||
getBrowserTimezoneOffsetMinutes,
|
||||
getDateRangeByDateType
|
||||
} from '@/lib/datetime.js';
|
||||
getBrowserTimezoneOffsetMinutes
|
||||
} from '@/lib/datetime.ts';
|
||||
|
||||
export default {
|
||||
props: [
|
||||
'minTime',
|
||||
'maxTime',
|
||||
'title',
|
||||
'hint',
|
||||
'persistent',
|
||||
'show'
|
||||
],
|
||||
emits: [
|
||||
'update:show',
|
||||
'dateRange:change'
|
||||
],
|
||||
data() {
|
||||
const self = this;
|
||||
let minDate = getTodayFirstUnixTime();
|
||||
let maxDate = getCurrentUnixTime();
|
||||
interface DesktopDateRangeSelectionProps extends CommonDateRangeSelectionProps {
|
||||
persistent?: boolean;
|
||||
}
|
||||
|
||||
if (self.minTime) {
|
||||
minDate = self.minTime;
|
||||
}
|
||||
const props = defineProps<DesktopDateRangeSelectionProps>();
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:show', value: boolean): void;
|
||||
(e: 'dateRange:change', minUnixTime: number, maxUnixTime: number): void;
|
||||
(e: 'error', message: string): void;
|
||||
}>();
|
||||
|
||||
if (self.maxTime) {
|
||||
maxDate = self.maxTime;
|
||||
}
|
||||
const theme = useTheme();
|
||||
|
||||
return {
|
||||
yearRange: [
|
||||
2000,
|
||||
getCurrentYear() + 1
|
||||
],
|
||||
dateRange: [
|
||||
getLocalDatetimeFromUnixTime(getDummyUnixTimeForLocalUsage(minDate, getTimezoneOffsetMinutes(), getBrowserTimezoneOffsetMinutes())),
|
||||
getLocalDatetimeFromUnixTime(getDummyUnixTimeForLocalUsage(maxDate, getTimezoneOffsetMinutes(), getBrowserTimezoneOffsetMinutes()))
|
||||
]
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapStores(useUserStore),
|
||||
showState: {
|
||||
get: function () {
|
||||
return this.show;
|
||||
},
|
||||
set: function (value) {
|
||||
this.$emit('update:show', value);
|
||||
}
|
||||
},
|
||||
isDarkMode() {
|
||||
return this.globalTheme.global.name.value === 'dark';
|
||||
},
|
||||
firstDayOfWeek() {
|
||||
return this.userStore.currentUserFirstDayOfWeek;
|
||||
},
|
||||
dayNames() {
|
||||
return arrangeArrayWithNewStartIndex(this.$locale.getAllMinWeekdayNames(), this.firstDayOfWeek);
|
||||
},
|
||||
isYearFirst() {
|
||||
return this.$locale.isLongDateMonthAfterYear(this.userStore);
|
||||
},
|
||||
is24Hour() {
|
||||
return this.$locale.isLongTime24HourFormat(this.userStore);
|
||||
},
|
||||
beginDateTime() {
|
||||
const actualBeginUnixTime = getActualUnixTimeForStore(getUnixTime(this.dateRange[0]), getTimezoneOffsetMinutes(), getBrowserTimezoneOffsetMinutes());
|
||||
return this.$locale.formatUnixTimeToLongDateTime(this.userStore, actualBeginUnixTime);
|
||||
},
|
||||
endDateTime() {
|
||||
const actualEndUnixTime = getActualUnixTimeForStore(getUnixTime(this.dateRange[1]), getTimezoneOffsetMinutes(), getBrowserTimezoneOffsetMinutes());
|
||||
return this.$locale.formatUnixTimeToLongDateTime(this.userStore, actualEndUnixTime);
|
||||
},
|
||||
presetRanges() {
|
||||
const presetRanges = [];
|
||||
const { tt, getMonthShortName } = useI18n();
|
||||
const { yearRange, dateRange, dayNames, isYearFirst, is24Hour, beginDateTime, endDateTime, presetRanges, getFinalDateRange } = useDateRangeSelectionBase(props);
|
||||
|
||||
[
|
||||
datetimeConstants.allDateRanges.Today,
|
||||
datetimeConstants.allDateRanges.LastSevenDays,
|
||||
datetimeConstants.allDateRanges.LastThirtyDays,
|
||||
datetimeConstants.allDateRanges.ThisWeek,
|
||||
datetimeConstants.allDateRanges.ThisMonth,
|
||||
datetimeConstants.allDateRanges.ThisYear
|
||||
].forEach(dateRangeType => {
|
||||
const dateRange = getDateRangeByDateType(dateRangeType.type, this.firstDayOfWeek);
|
||||
const userStore = useUserStore();
|
||||
|
||||
presetRanges.push({
|
||||
label: this.$t(dateRangeType.name),
|
||||
value: [
|
||||
getLocalDatetimeFromUnixTime(getDummyUnixTimeForLocalUsage(dateRange.minTime, getTimezoneOffsetMinutes(), getBrowserTimezoneOffsetMinutes())),
|
||||
getLocalDatetimeFromUnixTime(getDummyUnixTimeForLocalUsage(dateRange.maxTime, getTimezoneOffsetMinutes(), getBrowserTimezoneOffsetMinutes()))
|
||||
]
|
||||
});
|
||||
});
|
||||
const isDarkMode = computed<boolean>(() => theme.global.name.value === ThemeType.Dark);
|
||||
const firstDayOfWeek = computed<number>(() => userStore.currentUserFirstDayOfWeek);
|
||||
const showState = computed<boolean>({
|
||||
get: () => props.show || false,
|
||||
set: (value) => emit('update:show', value)
|
||||
});
|
||||
|
||||
return presetRanges;
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
'minTime': function (newValue) {
|
||||
if (newValue) {
|
||||
this.dateRange[0] = getLocalDatetimeFromUnixTime(getDummyUnixTimeForLocalUsage(newValue, getTimezoneOffsetMinutes(), getBrowserTimezoneOffsetMinutes()));
|
||||
}
|
||||
},
|
||||
'maxTime': function (newValue) {
|
||||
if (newValue) {
|
||||
this.dateRange[1] = getLocalDatetimeFromUnixTime(getDummyUnixTimeForLocalUsage(newValue, getTimezoneOffsetMinutes(), getBrowserTimezoneOffsetMinutes()));
|
||||
}
|
||||
}
|
||||
},
|
||||
setup() {
|
||||
const theme = useTheme();
|
||||
function confirm(): void {
|
||||
try {
|
||||
const finalDateRange = getFinalDateRange();
|
||||
|
||||
return {
|
||||
globalTheme: theme
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
confirm() {
|
||||
if (!this.dateRange[0] || !this.dateRange[1]) {
|
||||
if (!finalDateRange) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentMinDate = this.dateRange[0];
|
||||
const currentMaxDate = this.dateRange[1];
|
||||
|
||||
let minUnixTime = getUnixTime(currentMinDate);
|
||||
let maxUnixTime = getUnixTime(currentMaxDate);
|
||||
|
||||
if (minUnixTime < 0 || maxUnixTime < 0) {
|
||||
this.$toast('Date is too early');
|
||||
return;
|
||||
}
|
||||
|
||||
minUnixTime = getActualUnixTimeForStore(minUnixTime, getTimezoneOffsetMinutes(), getBrowserTimezoneOffsetMinutes());
|
||||
maxUnixTime = getActualUnixTimeForStore(maxUnixTime, getTimezoneOffsetMinutes(), getBrowserTimezoneOffsetMinutes());
|
||||
|
||||
this.$emit('dateRange:change', minUnixTime, maxUnixTime);
|
||||
},
|
||||
cancel() {
|
||||
this.$emit('update:show', false);
|
||||
},
|
||||
getMonthShortName(month) {
|
||||
return this.$locale.getMonthShortName(month);
|
||||
emit('dateRange:change', finalDateRange.minUnixTime, finalDateRange.maxUnixTime);
|
||||
} catch (ex: unknown) {
|
||||
if (ex instanceof Error) {
|
||||
emit('error', ex.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function cancel(): void {
|
||||
emit('update:show', false);
|
||||
}
|
||||
|
||||
watch(() => props.minTime, (newValue) => {
|
||||
if (newValue) {
|
||||
dateRange.value[0] = getLocalDatetimeFromUnixTime(getDummyUnixTimeForLocalUsage(newValue, getTimezoneOffsetMinutes(), getBrowserTimezoneOffsetMinutes()));
|
||||
}
|
||||
});
|
||||
|
||||
watch(() => props.maxTime, (newValue) => {
|
||||
if (newValue) {
|
||||
dateRange.value[1] = getLocalDatetimeFromUnixTime(getDummyUnixTimeForLocalUsage(newValue, getTimezoneOffsetMinutes(), getBrowserTimezoneOffsetMinutes()));
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
<template>
|
||||
<v-select
|
||||
persistent-placeholder
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:clearable="modelValue ? clearable : false"
|
||||
:label="label"
|
||||
:menu-props="{ 'content-class': 'date-select-menu' }"
|
||||
v-model="dateTime"
|
||||
>
|
||||
<template #selection>
|
||||
<span class="text-truncate cursor-pointer">{{ displayTime }}</span>
|
||||
</template>
|
||||
|
||||
<template #no-data>
|
||||
<vue-date-picker inline vertical auto-apply
|
||||
ref="datepicker"
|
||||
month-name-format="long"
|
||||
model-type="yyyy-MM-dd"
|
||||
:clearable="true"
|
||||
:enable-time-picker="false"
|
||||
:dark="isDarkMode"
|
||||
:week-start="firstDayOfWeek"
|
||||
:year-range="yearRange"
|
||||
:day-names="dayNames"
|
||||
:year-first="isYearFirst"
|
||||
v-model="dateTime">
|
||||
<template #month="{ text }">
|
||||
{{ getMonthShortName(text) }}
|
||||
</template>
|
||||
<template #month-overlay-value="{ text }">
|
||||
{{ getMonthShortName(text) }}
|
||||
</template>
|
||||
</vue-date-picker>
|
||||
</template>
|
||||
</v-select>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import { useTheme } from 'vuetify';
|
||||
|
||||
import { useI18n } from '@/locales/helpers.ts';
|
||||
|
||||
import { useUserStore } from '@/stores/user.ts';
|
||||
|
||||
import { ThemeType } from '@/core/theme.ts';
|
||||
import { arrangeArrayWithNewStartIndex } from '@/lib/common.ts';
|
||||
import { getCurrentYear } from '@/lib/datetime.ts';
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue?: string;
|
||||
disabled?: boolean;
|
||||
readonly?: boolean;
|
||||
clearable?: boolean;
|
||||
label?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string): void;
|
||||
}>();
|
||||
|
||||
const theme = useTheme();
|
||||
const { tt, getAllMinWeekdayNames, getMonthShortName, formatDateToLongDate, isLongDateMonthAfterYear } = useI18n();
|
||||
|
||||
const userStore = useUserStore();
|
||||
|
||||
const yearRange = ref<number[]>([
|
||||
2000,
|
||||
getCurrentYear() + 1
|
||||
]);
|
||||
|
||||
const dateTime = computed<string>({
|
||||
get: () => props.modelValue ?? '',
|
||||
set: (value: string) => emit('update:modelValue', value)
|
||||
});
|
||||
|
||||
const isDarkMode = computed<boolean>(() => theme.global.name.value === ThemeType.Dark);
|
||||
const firstDayOfWeek = computed<number>(() => userStore.currentUserFirstDayOfWeek);
|
||||
const dayNames = computed<string[]>(() => arrangeArrayWithNewStartIndex(getAllMinWeekdayNames(), firstDayOfWeek.value));
|
||||
const isYearFirst = computed<boolean>(() => isLongDateMonthAfterYear());
|
||||
const displayTime = computed<string>(() => {
|
||||
if (props.modelValue) {
|
||||
return formatDateToLongDate(props.modelValue);
|
||||
} else {
|
||||
return tt('Unspecified');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.date-select-menu {
|
||||
max-height: inherit !important;
|
||||
}
|
||||
|
||||
.date-select-menu .dp__menu {
|
||||
border: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -30,20 +30,23 @@
|
||||
{{ getMonthShortName(text) }}
|
||||
</template>
|
||||
<template #am-pm-button="{ toggle, value }">
|
||||
<button class="dp__pm_am_button" tabindex="0" @click="toggle">{{ $t(`datetime.${value}.content`) }}</button>
|
||||
<button class="dp__pm_am_button" tabindex="0" @click="toggle">{{ tt(`datetime.${value}.content`) }}</button>
|
||||
</template>
|
||||
</vue-date-picker>
|
||||
</template>
|
||||
</v-select>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import { useTheme } from 'vuetify';
|
||||
|
||||
import { mapStores } from 'pinia';
|
||||
import { useUserStore } from '@/stores/user.js';
|
||||
import { useI18n } from '@/locales/helpers.ts';
|
||||
|
||||
import { arrangeArrayWithNewStartIndex } from '@/lib/common.js';
|
||||
import { useUserStore } from '@/stores/user.ts';
|
||||
|
||||
import { ThemeType } from '@/core/theme.ts';
|
||||
import { arrangeArrayWithNewStartIndex } from '@/lib/common.ts';
|
||||
import {
|
||||
getCurrentYear,
|
||||
getTimezoneOffsetMinutes,
|
||||
@@ -51,76 +54,52 @@ import {
|
||||
getLocalDatetimeFromUnixTime,
|
||||
getActualUnixTimeForStore,
|
||||
getUnixTime
|
||||
} from '@/lib/datetime.js';
|
||||
} from '@/lib/datetime.ts';
|
||||
|
||||
export default {
|
||||
props: [
|
||||
'modelValue',
|
||||
'disabled',
|
||||
'readonly',
|
||||
'label'
|
||||
],
|
||||
emits: [
|
||||
'update:modelValue',
|
||||
'error'
|
||||
],
|
||||
data() {
|
||||
return {
|
||||
yearRange: [
|
||||
const props = defineProps<{
|
||||
modelValue: number;
|
||||
disabled?: boolean;
|
||||
readonly?: boolean;
|
||||
label?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: number): void;
|
||||
(e: 'error', message: string): void;
|
||||
}>();
|
||||
|
||||
const theme = useTheme();
|
||||
const { tt, getAllMinWeekdayNames, getMonthShortName, formatUnixTimeToLongDateTime, isLongDateMonthAfterYear, isLongTime24HourFormat } = useI18n();
|
||||
|
||||
const userStore = useUserStore();
|
||||
|
||||
const yearRange = ref<number[]>([
|
||||
2000,
|
||||
getCurrentYear() + 1
|
||||
]
|
||||
}
|
||||
]);
|
||||
|
||||
const dateTime = computed<Date>({
|
||||
get: () => {
|
||||
return getLocalDatetimeFromUnixTime(props.modelValue);
|
||||
},
|
||||
computed: {
|
||||
...mapStores(useUserStore),
|
||||
dateTime: {
|
||||
get: function () {
|
||||
return getLocalDatetimeFromUnixTime(this.modelValue);
|
||||
},
|
||||
set: function (value) {
|
||||
set: (value: Date) => {
|
||||
const unixTime = getUnixTime(value);
|
||||
|
||||
if (unixTime < 0) {
|
||||
this.$emit('error', 'Date is too early');
|
||||
emit('error', 'Date is too early');
|
||||
return;
|
||||
}
|
||||
|
||||
this.$emit('update:modelValue', unixTime);
|
||||
emit('update:modelValue', unixTime);
|
||||
}
|
||||
},
|
||||
isDarkMode() {
|
||||
return this.globalTheme.global.name.value === 'dark';
|
||||
},
|
||||
firstDayOfWeek() {
|
||||
return this.userStore.currentUserFirstDayOfWeek;
|
||||
},
|
||||
dayNames() {
|
||||
return arrangeArrayWithNewStartIndex(this.$locale.getAllMinWeekdayNames(), this.firstDayOfWeek);
|
||||
},
|
||||
isYearFirst() {
|
||||
return this.$locale.isLongDateMonthAfterYear(this.userStore);
|
||||
},
|
||||
is24Hour() {
|
||||
return this.$locale.isLongTime24HourFormat(this.userStore);
|
||||
},
|
||||
displayTime() {
|
||||
return this.$locale.formatUnixTimeToLongDateTime(this.userStore, getActualUnixTimeForStore(this.modelValue, getTimezoneOffsetMinutes(), getBrowserTimezoneOffsetMinutes()))
|
||||
}
|
||||
},
|
||||
setup() {
|
||||
const theme = useTheme();
|
||||
});
|
||||
|
||||
return {
|
||||
globalTheme: theme
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
getMonthShortName(month) {
|
||||
return this.$locale.getMonthShortName(month);
|
||||
}
|
||||
}
|
||||
}
|
||||
const isDarkMode = computed<boolean>(() => theme.global.name.value === ThemeType.Dark);
|
||||
const firstDayOfWeek = computed<number>(() => userStore.currentUserFirstDayOfWeek);
|
||||
const dayNames = computed<string[]>(() => arrangeArrayWithNewStartIndex(getAllMinWeekdayNames(), firstDayOfWeek.value));
|
||||
const isYearFirst = computed<boolean>(() => isLongDateMonthAfterYear());
|
||||
const is24Hour = computed<boolean>(() => isLongTime24HourFormat());
|
||||
const displayTime = computed<string>(() => formatUnixTimeToLongDateTime(getActualUnixTimeForStore(getUnixTime(dateTime.value), getTimezoneOffsetMinutes(), getBrowserTimezoneOffsetMinutes())));
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
<div class="cursor-pointer" @click="icon = iconInfo.id">
|
||||
<ItemIcon class="ma-2" icon-type="fixed" :icon-id="iconInfo.icon" :color="color" v-if="!modelValue || modelValue !== iconInfo.id" />
|
||||
<v-badge class="right-bottom-icon" color="primary"
|
||||
location="bottom right" offset-x="8" offset-y="10" :icon="icons.checked"
|
||||
location="bottom right" offset-x="8" offset-y="10" :icon="mdiCheck"
|
||||
v-if="modelValue && modelValue === iconInfo.id">
|
||||
<ItemIcon class="ma-2" icon-type="fixed" :icon-id="iconInfo.icon" :color="color" />
|
||||
</v-badge>
|
||||
@@ -36,67 +36,55 @@
|
||||
</v-select>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { arrayContainsFieldValue } from '@/lib/common.js';
|
||||
import { getIconsInRows } from '@/lib/icon.js';
|
||||
import { scrollToSelectedItem } from '@/lib/ui.desktop.js';
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, useTemplateRef, nextTick } from 'vue';
|
||||
|
||||
import type { ColorValue } from '@/core/color.ts';
|
||||
import type { IconInfo, IconInfoWithId } from '@/core/icon.ts';
|
||||
import { arrayContainsFieldValue } from '@/lib/common.ts';
|
||||
import { getIconsInRows } from '@/lib/icon.ts';
|
||||
import { scrollToSelectedItem } from '@/lib/ui/desktop.ts';
|
||||
|
||||
import {
|
||||
mdiCheck
|
||||
} from '@mdi/js';
|
||||
|
||||
export default {
|
||||
props: [
|
||||
'modelValue',
|
||||
'disabled',
|
||||
'label',
|
||||
'iconType',
|
||||
'color',
|
||||
'columnCount',
|
||||
'allIconInfos'
|
||||
],
|
||||
emits: [
|
||||
'update:modelValue',
|
||||
],
|
||||
data() {
|
||||
const self = this;
|
||||
const props = defineProps<{
|
||||
modelValue: string;
|
||||
disabled?: boolean;
|
||||
label?: string;
|
||||
iconType: string;
|
||||
color: ColorValue;
|
||||
columnCount?: number;
|
||||
allIconInfos: Record<string, IconInfo>;
|
||||
}>();
|
||||
|
||||
return {
|
||||
itemPerRow: self.columnCount || 7,
|
||||
icons: {
|
||||
checked: mdiCheck
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
allIconRows() {
|
||||
return getIconsInRows(this.allIconInfos, this.itemPerRow);
|
||||
},
|
||||
icon: {
|
||||
get: function () {
|
||||
return this.modelValue;
|
||||
},
|
||||
set: function (value) {
|
||||
this.$emit('update:modelValue', value);
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
hasSelectedIcon(row) {
|
||||
return arrayContainsFieldValue(row, 'id', this.modelValue);
|
||||
},
|
||||
onMenuStateChanged(state) {
|
||||
const self = this;
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string): void;
|
||||
}>();
|
||||
|
||||
const dropdownMenu = useTemplateRef<HTMLElement>('dropdownMenu');
|
||||
const itemPerRow = ref<number>(props.columnCount || 7);
|
||||
|
||||
const allIconRows = computed<IconInfoWithId[][]>(() => getIconsInRows(props.allIconInfos, itemPerRow.value));
|
||||
|
||||
const icon = computed<string>({
|
||||
get: () => props.modelValue,
|
||||
set: (value: string) => emit('update:modelValue', value)
|
||||
});
|
||||
|
||||
function hasSelectedIcon(row: IconInfoWithId[]): boolean {
|
||||
return arrayContainsFieldValue(row, 'id', props.modelValue);
|
||||
}
|
||||
|
||||
function onMenuStateChanged(state: boolean): void {
|
||||
if (state) {
|
||||
self.$nextTick(() => {
|
||||
if (self.$refs.dropdownMenu && self.$refs.dropdownMenu.parentElement) {
|
||||
scrollToSelectedItem(self.$refs.dropdownMenu.parentElement, null, '.row-has-selected-item');
|
||||
nextTick(() => {
|
||||
if (dropdownMenu.value && dropdownMenu.value.parentElement) {
|
||||
scrollToSelectedItem(dropdownMenu.value.parentElement, null, '.row-has-selected-item');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<slot></slot>
|
||||
</i>
|
||||
<v-badge class="right-bottom-icon" color="secondary"
|
||||
location="bottom right" offset-y="4" :icon="icons.hide"
|
||||
location="bottom right" offset-y="4" :icon="mdiEyeOffOutline"
|
||||
v-if="hiddenStatus">
|
||||
<i class="item-icon" :class="classes" :style="style">
|
||||
<slot></slot>
|
||||
@@ -11,151 +11,35 @@
|
||||
</v-badge>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import iconConstants from '@/consts/icon.js';
|
||||
import colorConstants from '@/consts/color.js';
|
||||
import { isNumber } from '@/lib/common.js';
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { type CommonIconProps, useItemIconBase } from '@/components/base/ItemIconBase.ts';
|
||||
|
||||
import {
|
||||
mdiEyeOffOutline
|
||||
} from '@mdi/js';
|
||||
|
||||
export default {
|
||||
props: [
|
||||
'class',
|
||||
'iconType',
|
||||
'iconId',
|
||||
'color',
|
||||
'defaultColor',
|
||||
'additionalColorAttr',
|
||||
'size',
|
||||
'hiddenStatus'
|
||||
],
|
||||
data() {
|
||||
return {
|
||||
icons: {
|
||||
hide: mdiEyeOffOutline
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
classes() {
|
||||
let allClasses = this.class ? (this.class + ' ') : '';
|
||||
interface DesktopItemIconProps extends CommonIconProps {
|
||||
class?: string;
|
||||
hiddenStatus?: boolean;
|
||||
}
|
||||
|
||||
if (this.iconType === 'account') {
|
||||
allClasses += this.getAccountIcon(this.iconId);
|
||||
} else if (this.iconType === 'category') {
|
||||
allClasses += this.getCategoryIcon(this.iconId);
|
||||
} else if (this.iconType === 'fixed') {
|
||||
allClasses += this.iconId;
|
||||
const props = defineProps<DesktopItemIconProps>();
|
||||
const { style, getAccountIcon, getCategoryIcon } = useItemIconBase(props);
|
||||
|
||||
const classes = computed<string>(() => {
|
||||
let allClasses = props.class ? (props.class + ' ') : '';
|
||||
|
||||
if (props.iconType === 'account') {
|
||||
allClasses += getAccountIcon(props.iconId);
|
||||
} else if (props.iconType === 'category') {
|
||||
allClasses += getCategoryIcon(props.iconId);
|
||||
} else if (props.iconType === 'fixed') {
|
||||
allClasses += props.iconId;
|
||||
}
|
||||
|
||||
return allClasses;
|
||||
},
|
||||
style() {
|
||||
let defaultColor = 'var(--default-icon-color)';
|
||||
|
||||
if (this.defaultColor) {
|
||||
defaultColor = this.defaultColor;
|
||||
}
|
||||
|
||||
if (this.iconType === 'account') {
|
||||
return this.getAccountIconStyle(this.color, defaultColor, this.additionalColorAttr);
|
||||
} else if (this.iconType === 'category') {
|
||||
return this.getCategoryIconStyle(this.color, defaultColor, this.additionalColorAttr);
|
||||
} else {
|
||||
return this.getDefaultIconStyle(this.color, defaultColor, this.additionalColorAttr);
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getAccountIcon(iconId) {
|
||||
if (isNumber(iconId)) {
|
||||
iconId = iconId.toString();
|
||||
}
|
||||
|
||||
if (!iconConstants.allAccountIcons[iconId]) {
|
||||
return iconConstants.defaultAccountIcon.icon;
|
||||
}
|
||||
|
||||
return iconConstants.allAccountIcons[iconId].icon;
|
||||
},
|
||||
getCategoryIcon(iconId) {
|
||||
if (isNumber(iconId)) {
|
||||
iconId = iconId.toString();
|
||||
}
|
||||
|
||||
if (!iconConstants.allCategoryIcons[iconId]) {
|
||||
return iconConstants.defaultCategoryIcon.icon;
|
||||
}
|
||||
|
||||
return iconConstants.allCategoryIcons[iconId].icon;
|
||||
},
|
||||
getAccountIconStyle(color, defaultColor, additionalColorAttr) {
|
||||
if (color && color !== colorConstants.defaultAccountColor) {
|
||||
color = '#' + color;
|
||||
} else {
|
||||
color = defaultColor;
|
||||
}
|
||||
|
||||
const ret = {
|
||||
color: color
|
||||
};
|
||||
|
||||
if (additionalColorAttr) {
|
||||
ret[additionalColorAttr] = color;
|
||||
}
|
||||
|
||||
if (this.size) {
|
||||
ret['font-size'] = this.size;
|
||||
}
|
||||
|
||||
return ret;
|
||||
},
|
||||
getCategoryIconStyle(color, defaultColor, additionalColorAttr) {
|
||||
if (color && color !== colorConstants.defaultCategoryColor) {
|
||||
color = '#' + color;
|
||||
} else {
|
||||
color = defaultColor;
|
||||
}
|
||||
|
||||
const ret = {
|
||||
color: color
|
||||
};
|
||||
|
||||
if (additionalColorAttr) {
|
||||
ret[additionalColorAttr] = color;
|
||||
}
|
||||
|
||||
if (this.size) {
|
||||
ret['font-size'] = this.size;
|
||||
}
|
||||
|
||||
return ret;
|
||||
},
|
||||
getDefaultIconStyle(color, defaultColor, additionalColorAttr) {
|
||||
if (color && color !== colorConstants.defaultColor) {
|
||||
color = '#' + color;
|
||||
} else {
|
||||
color = defaultColor;
|
||||
}
|
||||
|
||||
const ret = {
|
||||
color: color
|
||||
};
|
||||
|
||||
if (additionalColorAttr) {
|
||||
ret[additionalColorAttr] = color;
|
||||
}
|
||||
|
||||
if (this.size) {
|
||||
ret['font-size'] = this.size;
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<v-select
|
||||
item-title="nativeDisplayName"
|
||||
item-value="languageTag"
|
||||
persistent-placeholder
|
||||
:disabled="disabled"
|
||||
:label="label"
|
||||
:placeholder="placeholder"
|
||||
:items="allLanguages"
|
||||
v-model="currentLocaleValue"
|
||||
>
|
||||
<template #item="{ props, item }">
|
||||
<v-list-item :value="item.value" v-bind="props">
|
||||
<template #title>
|
||||
<v-list-item-title>
|
||||
<div class="d-flex align-center">
|
||||
<span>{{ item.title }}</span>
|
||||
<v-spacer style="min-width: 40px" />
|
||||
<v-icon :icon="mdiCheck" v-if="isLanguageSelected(item.raw.languageTag)" />
|
||||
<span class="text-field-append-text" v-if="!isLanguageSelected(item.raw.languageTag)">{{ item.raw.displayName }}</span>
|
||||
</div>
|
||||
</v-list-item-title>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</template>
|
||||
</v-select>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { type LanguageSelectBaseProps, type LanguageSelectBaseEmits, useLanguageSelectButtonBase } from '@/components/base/LanguageSelectBase.ts';
|
||||
|
||||
import { useI18n } from '@/locales/helpers.ts';
|
||||
|
||||
import {
|
||||
mdiCheck
|
||||
} from '@mdi/js';
|
||||
|
||||
interface DesktopLanguageSelectProps extends LanguageSelectBaseProps {
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
const props = defineProps<DesktopLanguageSelectProps>();
|
||||
const emit = defineEmits<LanguageSelectBaseEmits>();
|
||||
|
||||
const { getCurrentLanguageTag } = useI18n();
|
||||
|
||||
const {
|
||||
allLanguages,
|
||||
updateLanguage,
|
||||
isLanguageSelected
|
||||
} = useLanguageSelectButtonBase(props, emit);
|
||||
|
||||
const currentLocaleValue = computed<string>({
|
||||
get: () => {
|
||||
if (props.useModelValue) {
|
||||
return props.modelValue ?? '';
|
||||
} else {
|
||||
return getCurrentLanguageTag()
|
||||
}
|
||||
},
|
||||
set: (value: string) => {
|
||||
updateLanguage(value);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<v-menu location="bottom" max-height="500">
|
||||
<template #activator="{ props }">
|
||||
<v-btn variant="text" :disabled="disabled" v-bind="props">{{ currentLanguageName }}</v-btn>
|
||||
</template>
|
||||
<v-list>
|
||||
<v-list-item :key="lang.languageTag" :value="lang.languageTag" v-for="lang in allLanguages">
|
||||
<v-list-item-title class="cursor-pointer" @click="updateLanguage(lang.languageTag)">
|
||||
<div class="d-flex align-center">
|
||||
<span>{{ lang.nativeDisplayName }}</span>
|
||||
<v-spacer style="min-width: 40px" />
|
||||
<v-icon :icon="mdiCheck" v-if="isLanguageSelected(lang.languageTag)" />
|
||||
<span class="text-field-append-text" v-if="!isLanguageSelected(lang.languageTag)">{{ lang.displayName }}</span>
|
||||
</div>
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { type LanguageSelectBaseProps, type LanguageSelectBaseEmits, useLanguageSelectButtonBase } from '@/components/base/LanguageSelectBase.ts';
|
||||
|
||||
import {
|
||||
mdiCheck
|
||||
} from '@mdi/js';
|
||||
|
||||
const props = defineProps<LanguageSelectBaseProps>();
|
||||
const emit = defineEmits<LanguageSelectBaseEmits>();
|
||||
|
||||
const {
|
||||
allLanguages,
|
||||
currentLanguageName,
|
||||
updateLanguage,
|
||||
isLanguageSelected
|
||||
} = useLanguageSelectButtonBase(props, emit);
|
||||
</script>
|
||||
@@ -26,7 +26,7 @@
|
||||
:dark="isDarkMode"
|
||||
:year-range="yearRange"
|
||||
:year-first="isYearFirst"
|
||||
v-model="startTime">
|
||||
v-model="dateRange[0]">
|
||||
<template #month="{ text }">
|
||||
{{ getMonthShortName(text) }}
|
||||
</template>
|
||||
@@ -42,7 +42,7 @@
|
||||
:dark="isDarkMode"
|
||||
:year-range="yearRange"
|
||||
:year-first="isYearFirst"
|
||||
v-model="endTime">
|
||||
v-model="dateRange[1]">
|
||||
<template #month="{ text }">
|
||||
{{ getMonthShortName(text) }}
|
||||
</template>
|
||||
@@ -55,131 +55,85 @@
|
||||
</v-card-text>
|
||||
<v-card-text class="overflow-y-visible">
|
||||
<div class="w-100 d-flex justify-center gap-4">
|
||||
<v-btn :disabled="!startTime || !endTime" @click="confirm">{{ $t('OK') }}</v-btn>
|
||||
<v-btn color="secondary" variant="tonal" @click="cancel">{{ $t('Cancel') }}</v-btn>
|
||||
<v-btn :disabled="!dateRange[0] || !dateRange[1]" @click="confirm">{{ tt('OK') }}</v-btn>
|
||||
<v-btn color="secondary" variant="tonal" @click="cancel">{{ tt('Cancel') }}</v-btn>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup lang="ts">
|
||||
import { computed, watch } from 'vue';
|
||||
import { useTheme } from 'vuetify';
|
||||
|
||||
import { mapStores } from 'pinia';
|
||||
import { useUserStore } from '@/stores/user.js';
|
||||
import { useI18n } from '@/locales/helpers.ts';
|
||||
import { type CommonMonthRangeSelectionProps, useMonthRangeSelectionBase } from '@/components/base/MonthRangeSelectionBase.ts';
|
||||
|
||||
import {
|
||||
getYearMonthObjectFromString,
|
||||
getYearMonthStringFromObject,
|
||||
getCurrentUnixTime,
|
||||
getCurrentYear,
|
||||
getThisYearFirstUnixTime,
|
||||
getYearMonthFirstUnixTime,
|
||||
getYearMonthLastUnixTime
|
||||
} from '@/lib/datetime.js';
|
||||
import { ThemeType } from '@/core/theme.ts';
|
||||
import { getYearMonthObjectFromString } from '@/lib/datetime.ts';
|
||||
|
||||
export default {
|
||||
props: [
|
||||
'minTime',
|
||||
'maxTime',
|
||||
'title',
|
||||
'hint',
|
||||
'persistent',
|
||||
'show'
|
||||
],
|
||||
emits: [
|
||||
'update:show',
|
||||
'dateRange:change'
|
||||
],
|
||||
data() {
|
||||
const self = this;
|
||||
let minDate = getThisYearFirstUnixTime();
|
||||
let maxDate = getCurrentUnixTime();
|
||||
interface DesktopMonthRangeSelectionProps extends CommonMonthRangeSelectionProps {
|
||||
persistent?: boolean;
|
||||
}
|
||||
|
||||
if (self.minTime) {
|
||||
minDate = getYearMonthObjectFromString(self.minTime);
|
||||
}
|
||||
const props = defineProps<DesktopMonthRangeSelectionProps>();
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:show', value: boolean): void;
|
||||
(e: 'dateRange:change', minYearMonth: string, maxYearMonth: string): void;
|
||||
(e: 'error', message: string): void;
|
||||
}>();
|
||||
|
||||
if (self.maxTime) {
|
||||
maxDate = getYearMonthObjectFromString(self.maxTime);
|
||||
}
|
||||
const theme = useTheme();
|
||||
|
||||
return {
|
||||
yearRange: [
|
||||
2000,
|
||||
getCurrentYear() + 1
|
||||
],
|
||||
startTime: minDate,
|
||||
endTime: maxDate
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapStores(useUserStore),
|
||||
showState: {
|
||||
get: function () {
|
||||
return this.show;
|
||||
},
|
||||
set: function (value) {
|
||||
this.$emit('update:show', value);
|
||||
}
|
||||
},
|
||||
isDarkMode() {
|
||||
return this.globalTheme.global.name.value === 'dark';
|
||||
},
|
||||
isYearFirst() {
|
||||
return this.$locale.isLongDateMonthAfterYear(this.userStore);
|
||||
},
|
||||
beginDateTime() {
|
||||
return this.$locale.formatUnixTimeToLongYearMonth(this.userStore, getYearMonthFirstUnixTime(this.startTime));
|
||||
},
|
||||
endDateTime() {
|
||||
return this.$locale.formatUnixTimeToLongYearMonth(this.userStore, getYearMonthLastUnixTime(this.endTime));
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
'minTime': function (newValue) {
|
||||
if (newValue) {
|
||||
this.startTime = getYearMonthObjectFromString(newValue);
|
||||
}
|
||||
},
|
||||
'maxTime': function (newValue) {
|
||||
if (newValue) {
|
||||
this.endTime = getYearMonthObjectFromString(newValue);
|
||||
}
|
||||
}
|
||||
},
|
||||
setup() {
|
||||
const theme = useTheme();
|
||||
const { tt, getMonthShortName } = useI18n();
|
||||
const { yearRange, dateRange, isYearFirst, beginDateTime, endDateTime, getFinalMonthRange } = useMonthRangeSelectionBase(props);
|
||||
|
||||
return {
|
||||
globalTheme: theme
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
confirm() {
|
||||
if (!this.startTime || !this.endTime) {
|
||||
const isDarkMode = computed<boolean>(() => theme.global.name.value === ThemeType.Dark);
|
||||
const showState = computed<boolean>({
|
||||
get: () => props.show || false,
|
||||
set: (value) => emit('update:show', value)
|
||||
});
|
||||
|
||||
function confirm(): void {
|
||||
try {
|
||||
const finalMonthRange = getFinalMonthRange();
|
||||
|
||||
if (!finalMonthRange) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.startTime.year <= 0 || this.startTime.month < 0 || this.endTime.year <= 0 || this.endTime.month < 0) {
|
||||
this.$toast('Date is too early');
|
||||
return;
|
||||
}
|
||||
|
||||
const minYearMonth = getYearMonthStringFromObject(this.startTime);
|
||||
const maxYearMonth = getYearMonthStringFromObject(this.endTime);
|
||||
|
||||
this.$emit('dateRange:change', minYearMonth, maxYearMonth);
|
||||
},
|
||||
cancel() {
|
||||
this.$emit('update:show', false);
|
||||
},
|
||||
getMonthShortName(month) {
|
||||
return this.$locale.getMonthShortName(month);
|
||||
emit('dateRange:change', finalMonthRange.minYearMonth, finalMonthRange.maxYearMonth);
|
||||
} catch (ex: unknown) {
|
||||
if (ex instanceof Error) {
|
||||
emit('error', ex.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function cancel(): void {
|
||||
emit('update:show', false);
|
||||
}
|
||||
|
||||
watch(() => props.minTime, (newValue) => {
|
||||
if (newValue) {
|
||||
const yearMonth = getYearMonthObjectFromString(newValue);
|
||||
|
||||
if (yearMonth) {
|
||||
dateRange.value[0] = yearMonth;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
watch(() => props.maxTime, (newValue) => {
|
||||
if (newValue) {
|
||||
const yearMonth = getYearMonthObjectFromString(newValue);
|
||||
|
||||
if (yearMonth) {
|
||||
dateRange.value[1] = yearMonth;
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<v-pagination :density="density"
|
||||
:disabled="disabled"
|
||||
:total-visible="totalVisible ?? 7"
|
||||
:length="totalPageCount"
|
||||
v-model="currentPage">
|
||||
<template #item="{ key, page, isActive }">
|
||||
<v-btn variant="text"
|
||||
:density="density"
|
||||
:disabled="disabled"
|
||||
:icon="true"
|
||||
:color="isActive ? 'primary' : 'default'"
|
||||
@click="currentPage = parseInt(page)"
|
||||
v-if="page !== '...'"
|
||||
>
|
||||
<span>{{ page }}</span>
|
||||
</v-btn>
|
||||
<v-btn variant="text"
|
||||
color="default"
|
||||
:density="density"
|
||||
:disabled="disabled"
|
||||
:icon="true"
|
||||
v-if="page === '...'"
|
||||
>
|
||||
<span>{{ page }}</span>
|
||||
<v-menu activator="parent"
|
||||
:disabled="disabled"
|
||||
:close-on-content-click="false"
|
||||
v-model="showMenus[key]">
|
||||
<v-list>
|
||||
<v-list-item class="text-sm" :density="density">
|
||||
<v-list-item-title class="cursor-pointer">
|
||||
<v-autocomplete width="100"
|
||||
item-title="page"
|
||||
item-value="page"
|
||||
auto-select-first="exact"
|
||||
:density="density"
|
||||
:items="allPages"
|
||||
:no-data-text="tt('No results')"
|
||||
v-model="currentPage"/>
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-pagination>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
|
||||
import { useI18n } from '@/locales/helpers.ts';
|
||||
|
||||
import type { ComponentDensity } from '@/lib/ui/desktop.ts';
|
||||
|
||||
const props = defineProps<{
|
||||
density?: ComponentDensity;
|
||||
disabled?: boolean;
|
||||
totalPageCount: number;
|
||||
totalVisible?: number;
|
||||
modelValue: number;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: number): void;
|
||||
}>();
|
||||
|
||||
const { tt } = useI18n();
|
||||
|
||||
const showMenus = ref<Record<string, boolean>>({});
|
||||
|
||||
const allPages = computed<{ page: number }[]>(() => {
|
||||
const pages = [];
|
||||
|
||||
for (let i = 1; i <= props.totalPageCount; i++) {
|
||||
pages.push({
|
||||
page: i
|
||||
});
|
||||
}
|
||||
|
||||
return pages;
|
||||
});
|
||||
|
||||
const currentPage = computed<number>({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => {
|
||||
if (value && value >= 1 && value <= props.totalPageCount) {
|
||||
emit('update:modelValue', value);
|
||||
|
||||
for (const key in showMenus.value) {
|
||||
if (!Object.prototype.hasOwnProperty.call(showMenus.value, key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
showMenus.value[key] = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
+118
-154
@@ -3,125 +3,99 @@
|
||||
@click="clickItem" @legendselectchanged="onLegendSelectChanged" />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import { useTheme } from 'vuetify';
|
||||
|
||||
import { mapStores } from 'pinia';
|
||||
import { useSettingsStore } from '@/stores/setting.js';
|
||||
import { useUserStore } from '@/stores/user.js';
|
||||
import type { ECElementEvent } from 'echarts/core';
|
||||
import type { CallbackDataParams } from 'echarts/types/dist/shared';
|
||||
|
||||
import colorConstants from '@/consts/color.js';
|
||||
import { formatPercent } from '@/lib/numeral.js';
|
||||
import { useI18n } from '@/locales/helpers.ts';
|
||||
import { type CommonPieChartDataItem, type CommonPieChartProps, usePieChartBase } from '@/components/base/PieChartBase.ts'
|
||||
|
||||
export default {
|
||||
props: [
|
||||
'skeleton',
|
||||
'items',
|
||||
'idField',
|
||||
'nameField',
|
||||
'valueField',
|
||||
'percentField',
|
||||
'colorField',
|
||||
'hiddenField',
|
||||
'minValidPercent',
|
||||
'defaultCurrency',
|
||||
'showValue',
|
||||
'enableClickItem'
|
||||
],
|
||||
emits: [
|
||||
'click'
|
||||
],
|
||||
data() {
|
||||
return {
|
||||
selectedLegends: null,
|
||||
selectedIndex: 0
|
||||
import type { ColorValue } from '@/core/color.ts';
|
||||
import { ThemeType } from '@/core/theme.ts';
|
||||
import { DEFAULT_ICON_COLOR } from '@/consts/color.ts';
|
||||
|
||||
interface DesktopPieChartDataItem extends CommonPieChartDataItem {
|
||||
itemStyle: {
|
||||
color: ColorValue;
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapStores(useSettingsStore, useUserStore),
|
||||
isDarkMode() {
|
||||
return this.globalTheme.global.name.value === 'dark';
|
||||
},
|
||||
itemsMap: function () {
|
||||
const map = {};
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
for (let i = 0; i < this.items.length; i++) {
|
||||
const item = this.items[i];
|
||||
const props = defineProps<CommonPieChartProps>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'click', value: Record<string, unknown>): void;
|
||||
}>();
|
||||
|
||||
const theme = useTheme();
|
||||
|
||||
const { formatAmountWithCurrency } = useI18n();
|
||||
const { selectedIndex, validItems } = usePieChartBase(props);
|
||||
|
||||
const selectedLegends = ref<Record<string, boolean> | null>(null);
|
||||
|
||||
const isDarkMode = computed<boolean>(() => theme.global.name.value === ThemeType.Dark);
|
||||
|
||||
const itemsMap = computed<Record<string, Record<string, unknown>>>(() => {
|
||||
const map: Record<string, Record<string, unknown>> = {};
|
||||
|
||||
for (let i = 0; i < props.items.length; i++) {
|
||||
const item = props.items[i];
|
||||
let id = '';
|
||||
|
||||
if (this.idField && item[this.idField]) {
|
||||
id = item[this.idField];
|
||||
if (props.idField && item[props.idField]) {
|
||||
id = item[props.idField] as string;
|
||||
} else {
|
||||
id = item[this.nameField];
|
||||
id = item[props.nameField] as string;;
|
||||
}
|
||||
|
||||
map[id] = item;
|
||||
}
|
||||
|
||||
return map;
|
||||
},
|
||||
validItems: function () {
|
||||
let totalValidValue = 0;
|
||||
});
|
||||
|
||||
for (let i = 0; i < this.items.length; i++) {
|
||||
const item = this.items[i];
|
||||
const seriesData = computed<DesktopPieChartDataItem[]>(() => {
|
||||
const ret: DesktopPieChartDataItem[] = [];
|
||||
|
||||
if (item[this.valueField] && item[this.valueField] > 0 && (!this.hiddenField || !item[this.hiddenField])) {
|
||||
totalValidValue += item[this.valueField];
|
||||
}
|
||||
}
|
||||
|
||||
const validItems = [];
|
||||
|
||||
for (let i = 0; i < this.items.length; i++) {
|
||||
const item = this.items[i];
|
||||
|
||||
if (item[this.valueField] && item[this.valueField] > 0 &&
|
||||
(!this.hiddenField || !item[this.hiddenField]) &&
|
||||
(!this.minValidPercent || item[this.valueField] / totalValidValue > this.minValidPercent)) {
|
||||
const finalItem = {
|
||||
id: (this.idField && item[this.idField]) ? item[this.idField] : item[this.nameField],
|
||||
name: (this.idField && item[this.idField]) ? item[this.idField] : item[this.nameField],
|
||||
displayName: item[this.nameField],
|
||||
value: item[this.valueField],
|
||||
percent: (item[this.percentField] > 0 || item[this.percentField] === 0 || item[this.percentField] === '0') ? item[this.percentField] : (item[this.valueField] / totalValidValue * 100),
|
||||
actualPercent: item[this.valueField] / totalValidValue,
|
||||
for (let i = 0; i < validItems.value.length; i++) {
|
||||
const item = validItems.value[i];
|
||||
ret.push({
|
||||
...item,
|
||||
itemStyle: {
|
||||
color: this.getColor(item[this.colorField] ? item[this.colorField] : colorConstants.defaultChartColors[validItems.length % colorConstants.defaultChartColors.length]),
|
||||
color: getColor(item.color),
|
||||
},
|
||||
selected: true,
|
||||
sourceItem: item
|
||||
};
|
||||
|
||||
finalItem.displayPercent = formatPercent(finalItem.percent, 2, '<0.01');
|
||||
finalItem.displayValue = this.getDisplayCurrency(finalItem.value, this.defaultCurrency);
|
||||
|
||||
validItems.push(finalItem);
|
||||
}
|
||||
selected: true
|
||||
});
|
||||
}
|
||||
|
||||
return validItems;
|
||||
},
|
||||
hasUnselectedItem: function () {
|
||||
for (let i = 0; i < this.validItems.length; i++) {
|
||||
const item = this.validItems[i];
|
||||
return ret;
|
||||
});
|
||||
|
||||
if (this.selectedLegends && !this.selectedLegends[item.id]) {
|
||||
const hasUnselectedItem = computed<boolean>(() => {
|
||||
for (let i = 0; i < validItems.value.length; i++) {
|
||||
const item = validItems.value[i];
|
||||
|
||||
if (selectedLegends.value && !selectedLegends.value[item.id]) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
firstItemAndHalfCurrentItemTotalPercent: function () {
|
||||
});
|
||||
|
||||
const firstItemAndHalfCurrentItemTotalPercent = computed<number>(() => {
|
||||
let totalValue = 0;
|
||||
let firstValue = null;
|
||||
let firstToCurrentTotalValue = 0;
|
||||
|
||||
for (let i = 0; i < this.validItems.length; i++) {
|
||||
const item = this.validItems[i];
|
||||
for (let i = 0; i < validItems.value.length; i++) {
|
||||
const item = validItems.value[i];
|
||||
|
||||
if (this.selectedLegends && !this.selectedLegends[item.id]) {
|
||||
if (selectedLegends.value && !selectedLegends.value[item.id]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -130,9 +104,9 @@ export default {
|
||||
}
|
||||
|
||||
if (firstValue !== null) {
|
||||
if (i < this.selectedIndex) {
|
||||
if (i < selectedIndex.value) {
|
||||
firstToCurrentTotalValue += item.value;
|
||||
} else if (i === this.selectedIndex) {
|
||||
} else if (i === selectedIndex.value) {
|
||||
firstToCurrentTotalValue += item.value / 2;
|
||||
}
|
||||
}
|
||||
@@ -145,24 +119,24 @@ export default {
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
},
|
||||
chartOptions: function () {
|
||||
const self = this;
|
||||
});
|
||||
|
||||
const chartOptions = computed<object>(() => {
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
backgroundColor: self.isDarkMode ? '#333' : '#fff',
|
||||
borderColor: self.isDarkMode ? '#333' : '#fff',
|
||||
backgroundColor: isDarkMode.value ? '#333' : '#fff',
|
||||
borderColor: isDarkMode.value ? '#333' : '#fff',
|
||||
textStyle: {
|
||||
color: self.isDarkMode ? '#eee' : '#333'
|
||||
color: isDarkMode.value ? '#eee' : '#333'
|
||||
},
|
||||
formatter: params => {
|
||||
const name = params.data ? params.data.displayName : '';
|
||||
const value = params.data ? params.data.displayValue : self.getDisplayCurrency(params.value);
|
||||
let percent = params.data ? params.data.displayPercent : (params.percent + '%');
|
||||
formatter: (params: CallbackDataParams) => {
|
||||
const dataItem = params.data as DesktopPieChartDataItem;
|
||||
const name = dataItem ? dataItem.displayName : '';
|
||||
const value = dataItem ? dataItem.displayValue : formatAmountWithCurrency(params.value as number);
|
||||
let percent = dataItem ? dataItem.displayPercent : (params.percent + '%');
|
||||
|
||||
if (self.hasUnselectedItem) {
|
||||
if (hasUnselectedItem.value) {
|
||||
percent = params.percent + '%';
|
||||
}
|
||||
|
||||
@@ -181,21 +155,22 @@ export default {
|
||||
},
|
||||
legend: {
|
||||
orient: 'horizontal',
|
||||
data: self.validItems.map(item => item.name),
|
||||
selected: self.selectedLegends,
|
||||
data: validItems.value.map(item => item.name),
|
||||
selected: selectedLegends.value,
|
||||
textStyle: {
|
||||
color: self.isDarkMode ? '#eee' : '#333'
|
||||
color: isDarkMode.value ? '#eee' : '#333'
|
||||
},
|
||||
formatter: id => {
|
||||
return self.itemsMap[id] && self.nameField && self.itemsMap[id][self.nameField] ? self.itemsMap[id][self.nameField] : id;
|
||||
formatter: (id: string) => {
|
||||
const item = itemsMap.value[id];
|
||||
return item && props.nameField && item[props.nameField] ? item[props.nameField] as string : id;
|
||||
}
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'pie',
|
||||
data: self.validItems,
|
||||
data: seriesData.value,
|
||||
top: 50,
|
||||
startAngle: -90 + self.firstItemAndHalfCurrentItemTotalPercent * 360,
|
||||
startAngle: -90 + firstItemAndHalfCurrentItemTotalPercent.value * 360,
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
shadowBlur: 10,
|
||||
@@ -204,18 +179,19 @@ export default {
|
||||
}
|
||||
},
|
||||
label: {
|
||||
color: self.isDarkMode ? '#eee' : '#333',
|
||||
formatter: params => {
|
||||
return params.data ? params.data.displayName : '';
|
||||
color: isDarkMode.value ? '#eee' : '#333',
|
||||
formatter: (params: CallbackDataParams) => {
|
||||
const dataItem = params.data as DesktopPieChartDataItem;
|
||||
return dataItem ? dataItem.displayName : '';
|
||||
}
|
||||
},
|
||||
animation: !self.skeleton
|
||||
animation: !props.skeleton
|
||||
}
|
||||
],
|
||||
media: [
|
||||
{
|
||||
query: {
|
||||
minWidth: 600
|
||||
minWidth: 600,
|
||||
},
|
||||
option: {
|
||||
legend: {
|
||||
@@ -228,70 +204,58 @@ export default {
|
||||
top: 0
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
'items': function () {
|
||||
this.selectedIndex = 0;
|
||||
}
|
||||
},
|
||||
setup() {
|
||||
const theme = useTheme();
|
||||
|
||||
return {
|
||||
globalTheme: theme
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
clickItem: function (e) {
|
||||
if (!this.enableClickItem || e.componentType !== 'series' || e.seriesType !=='pie') {
|
||||
});
|
||||
|
||||
function getColor(color: string): ColorValue {
|
||||
if (color && color !== DEFAULT_ICON_COLOR) {
|
||||
color = '#' + color;
|
||||
}
|
||||
|
||||
return color;
|
||||
}
|
||||
|
||||
function clickItem(e: ECElementEvent): void {
|
||||
if (!props.enableClickItem || e.componentType !== 'series' || e.seriesType !=='pie') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.event && e.event.target && e.event.target.currentStates && e.event.target.currentStates[0] && e.event.target.currentStates[0] === 'emphasis') {
|
||||
this.selectedIndex = e.dataIndex;
|
||||
selectedIndex.value = e.dataIndex;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!e.data || !e.data.sourceItem) {
|
||||
if (!e.data) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.$emit('click', e.data.sourceItem);
|
||||
},
|
||||
onLegendSelectChanged: function (e) {
|
||||
this.selectedLegends = e.selected;
|
||||
const selectedItem = this.validItems[this.selectedIndex];
|
||||
const data = e.data as object;
|
||||
|
||||
if (!selectedItem || !this.selectedLegends[selectedItem.id]) {
|
||||
if ('sourceItem' in data) {
|
||||
emit('click', data.sourceItem as Record<string, unknown>);
|
||||
}
|
||||
}
|
||||
|
||||
function onLegendSelectChanged(e: { selected: Record<string, boolean> }): void {
|
||||
selectedLegends.value = e.selected;
|
||||
const selectedItem = validItems.value[selectedIndex.value];
|
||||
|
||||
if (!selectedItem || !selectedLegends.value[selectedItem.id]) {
|
||||
let newSelectedIndex = 0;
|
||||
|
||||
for (let i = 0; i < this.validItems.length; i++) {
|
||||
const item = this.validItems[i];
|
||||
for (let i = 0; i < validItems.value.length; i++) {
|
||||
const item = validItems.value[i];
|
||||
|
||||
if (this.selectedLegends[item.id]) {
|
||||
if (selectedLegends.value[item.id]) {
|
||||
newSelectedIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
this.selectedIndex = newSelectedIndex;
|
||||
}
|
||||
},
|
||||
getColor: function (color) {
|
||||
if (color && color !== colorConstants.defaultColor) {
|
||||
color = '#' + color;
|
||||
}
|
||||
|
||||
return color;
|
||||
},
|
||||
getDisplayCurrency(value, currencyCode) {
|
||||
return this.$locale.formatAmountWithCurrency(this.settingsStore, this.userStore, value, currencyCode);
|
||||
}
|
||||
selectedIndex.value = newSelectedIndex;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -25,11 +25,11 @@
|
||||
</v-list>
|
||||
</div>
|
||||
<div class="schedule-frequency-value-container">
|
||||
<v-list v-if="frequencyType === allTemplateScheduledFrequencyTypes.Disabled.type">
|
||||
<v-list-item :title="$t('None')"></v-list-item>
|
||||
<v-list v-if="frequencyType === ScheduledTemplateFrequencyType.Disabled.type">
|
||||
<v-list-item :title="tt('None')"></v-list-item>
|
||||
</v-list>
|
||||
<v-list select-strategy="classic" v-model:selected="frequencyValue"
|
||||
v-else-if="frequencyType === allTemplateScheduledFrequencyTypes.Weekly.type">
|
||||
v-else-if="frequencyType === ScheduledTemplateFrequencyType.Weekly.type">
|
||||
<v-list-item :key="weekDay.type" :value="weekDay.type" :title="weekDay.displayName"
|
||||
:class="{ 'frequency-value-selected v-list-item--active text-primary': isFrequencyValueSelected(weekDay.type) }"
|
||||
v-for="weekDay in allWeekDays">
|
||||
@@ -39,7 +39,7 @@
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
<v-list select-strategy="classic" v-model:selected="frequencyValue"
|
||||
v-else-if="frequencyType === allTemplateScheduledFrequencyTypes.Monthly.type">
|
||||
v-else-if="frequencyType === ScheduledTemplateFrequencyType.Monthly.type">
|
||||
<v-list-item :key="monthDay.day" :value="monthDay.day" :title="monthDay.displayName"
|
||||
:class="{ 'frequency-value-selected v-list-item--active text-primary': isFrequencyValueSelected(monthDay.day) }"
|
||||
v-for="monthDay in allAvailableMonthDays">
|
||||
@@ -54,137 +54,100 @@
|
||||
</v-select>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapStores } from 'pinia';
|
||||
import { useUserStore } from '@/stores/user.js';
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, nextTick, useTemplateRef } from 'vue';
|
||||
|
||||
import templateConstants from '@/consts/template.js';
|
||||
import { sortNumbersArray } from '@/lib/common.js';
|
||||
import { scrollToSelectedItem } from '@/lib/ui.desktop.js';
|
||||
import { useI18n } from '@/locales/helpers.ts';
|
||||
import { type CommonScheduleFrequencySelectionProps, useScheduleFrequencySelectionBase } from '@/components/base/ScheduleFrequencySelectionBase.ts';
|
||||
|
||||
export default {
|
||||
props: [
|
||||
'type',
|
||||
'modelValue',
|
||||
'disabled',
|
||||
'readonly',
|
||||
'label'
|
||||
],
|
||||
emits: [
|
||||
'update:type',
|
||||
'update:modelValue'
|
||||
],
|
||||
data() {
|
||||
return {
|
||||
menuState: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapStores(useUserStore),
|
||||
allTransactionScheduledFrequencyTypes() {
|
||||
return this.$locale.getAllTransactionScheduledFrequencyTypes();
|
||||
},
|
||||
allTemplateScheduledFrequencyTypes() {
|
||||
return templateConstants.allTemplateScheduledFrequencyTypes;
|
||||
},
|
||||
allWeekDays() {
|
||||
return this.$locale.getAllWeekDays(this.firstDayOfWeek);
|
||||
},
|
||||
allAvailableMonthDays() {
|
||||
const allAvailableDays = [];
|
||||
import { useUserStore } from '@/stores/user.ts';
|
||||
|
||||
for (let i = 1; i <= 28; i++) {
|
||||
allAvailableDays.push({
|
||||
day: i,
|
||||
displayName: this.$locale.getMonthdayShortName(i),
|
||||
});
|
||||
}
|
||||
import { ScheduledTemplateFrequencyType } from '@/core/template.ts';
|
||||
import { sortNumbersArray } from '@/lib/common.ts';
|
||||
import { scrollToSelectedItem } from '@/lib/ui/desktop.ts';
|
||||
|
||||
return allAvailableDays;
|
||||
},
|
||||
firstDayOfWeek() {
|
||||
return this.userStore.currentUserFirstDayOfWeek;
|
||||
},
|
||||
frequencyType: {
|
||||
get: function () {
|
||||
return this.type;
|
||||
},
|
||||
set: function (value) {
|
||||
if (this.type !== value) {
|
||||
this.$emit('update:type', value);
|
||||
const props = defineProps<CommonScheduleFrequencySelectionProps>();
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:type', value: number): void;
|
||||
(e: 'update:modelValue', value: string): void;
|
||||
}>();
|
||||
|
||||
if (value === templateConstants.allTemplateScheduledFrequencyTypes.Weekly.type) {
|
||||
this.frequencyValue = [this.firstDayOfWeek];
|
||||
} else if (value === templateConstants.allTemplateScheduledFrequencyTypes.Monthly.type) {
|
||||
this.frequencyValue = [1];
|
||||
const { tt, getMultiMonthdayShortNames, getMultiWeekdayLongNames } = useI18n();
|
||||
const { allTransactionScheduledFrequencyTypes, allWeekDays, allAvailableMonthDays, getFrequencyValues } = useScheduleFrequencySelectionBase();
|
||||
|
||||
const userStore = useUserStore();
|
||||
|
||||
const dropdownMenu = useTemplateRef<HTMLElement>('dropdownMenu');
|
||||
|
||||
const menuState = ref<boolean>(false);
|
||||
|
||||
const firstDayOfWeek = computed<number>(() => userStore.currentUserFirstDayOfWeek);
|
||||
|
||||
const frequencyType = computed<number>({
|
||||
get: () => props.type,
|
||||
set: (value: number) => {
|
||||
if (props.type !== value) {
|
||||
emit('update:type', value);
|
||||
|
||||
if (value === ScheduledTemplateFrequencyType.Weekly.type) {
|
||||
frequencyValue.value = [firstDayOfWeek.value];
|
||||
} else if (value === ScheduledTemplateFrequencyType.Monthly.type) {
|
||||
frequencyValue.value = [1];
|
||||
} else {
|
||||
this.frequencyValue = [];
|
||||
frequencyValue.value = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
frequencyValue: {
|
||||
get: function () {
|
||||
const values = this.modelValue.split(',');
|
||||
const ret = [];
|
||||
});
|
||||
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
if (values[i]) {
|
||||
ret.push(parseInt(values[i]));
|
||||
}
|
||||
const frequencyValue = computed<number[]>({
|
||||
get: () => getFrequencyValues(props.modelValue),
|
||||
set: (value: number[]) => {
|
||||
emit('update:modelValue', sortNumbersArray(value).join(','));
|
||||
}
|
||||
});
|
||||
|
||||
return sortNumbersArray(ret);
|
||||
},
|
||||
set: function (value) {
|
||||
this.$emit('update:modelValue', sortNumbersArray(value).join(','));
|
||||
}
|
||||
},
|
||||
displayFrequency() {
|
||||
if (this.type === templateConstants.allTemplateScheduledFrequencyTypes.Disabled.type) {
|
||||
return this.$t('Disabled');
|
||||
} else if (this.type === templateConstants.allTemplateScheduledFrequencyTypes.Weekly.type) {
|
||||
if (this.frequencyValue.length) {
|
||||
return this.$t('format.misc.everyMultiDaysOfWeek', {
|
||||
days: this.$locale.getMultiWeekdayLongNames(this.frequencyValue, this.firstDayOfWeek)
|
||||
const displayFrequency = computed<string>(() => {
|
||||
if (frequencyType.value === ScheduledTemplateFrequencyType.Disabled.type) {
|
||||
return tt('Disabled');
|
||||
} else if (frequencyType.value === ScheduledTemplateFrequencyType.Weekly.type) {
|
||||
if (frequencyValue.value.length) {
|
||||
return tt('format.misc.everyMultiDaysOfWeek', {
|
||||
days: getMultiWeekdayLongNames(frequencyValue.value, firstDayOfWeek.value)
|
||||
});
|
||||
} else {
|
||||
return this.$t('Weekly');
|
||||
return tt('Weekly');
|
||||
}
|
||||
} else if (this.type === templateConstants.allTemplateScheduledFrequencyTypes.Monthly.type) {
|
||||
if (this.frequencyValue.length) {
|
||||
return this.$t('format.misc.everyMultiDaysOfMonth', {
|
||||
days: this.$locale.getMultiMonthdayShortNames(this.frequencyValue)
|
||||
} else if (frequencyType.value === ScheduledTemplateFrequencyType.Monthly.type) {
|
||||
if (frequencyValue.value.length) {
|
||||
return tt('format.misc.everyMultiDaysOfMonth', {
|
||||
days: getMultiMonthdayShortNames(frequencyValue.value)
|
||||
});
|
||||
} else {
|
||||
return this.$t('Monthly');
|
||||
return tt('Monthly');
|
||||
}
|
||||
} else {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onMenuStateChanged(state) {
|
||||
const self = this;
|
||||
});
|
||||
|
||||
if (state) {
|
||||
self.$nextTick(() => {
|
||||
if (self.$refs.dropdownMenu && self.$refs.dropdownMenu.parentElement) {
|
||||
scrollToSelectedItem(self.$refs.dropdownMenu.parentElement, '.schedule-frequency-value-container', '.frequency-value-selected');
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
isFrequencyValueSelected(value) {
|
||||
for (let i = 0; i < this.frequencyValue.length; i++) {
|
||||
if (this.frequencyValue[i] === value) {
|
||||
function isFrequencyValueSelected(value: number): boolean {
|
||||
for (let i = 0; i < frequencyValue.value.length; i++) {
|
||||
if (frequencyValue.value[i] === value) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function onMenuStateChanged(state: boolean): void {
|
||||
if (state) {
|
||||
nextTick(() => {
|
||||
if (dropdownMenu.value && dropdownMenu.value.parentElement) {
|
||||
scrollToSelectedItem(dropdownMenu.value.parentElement, '.schedule-frequency-value-container', '.frequency-value-selected');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -3,48 +3,56 @@
|
||||
{{ messageContent }}
|
||||
|
||||
<template #actions>
|
||||
<v-btn color="primary" variant="text" @click="showState = false">{{ $t('Close') }}</v-btn>
|
||||
<v-btn color="primary" variant="text" @click="showState = false">{{ tt('Close') }}</v-btn>
|
||||
</template>
|
||||
</v-snackbar>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: [
|
||||
'show',
|
||||
'message'
|
||||
],
|
||||
emits: [
|
||||
'update:show'
|
||||
],
|
||||
expose: [
|
||||
'showMessage',
|
||||
'showError'
|
||||
],
|
||||
data() {
|
||||
const self = this;
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
|
||||
return {
|
||||
showState: self.show,
|
||||
messageContent: self.message,
|
||||
resolve: null,
|
||||
reject: null
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
'showState': function (newValue) {
|
||||
this.$emit('update:show', newValue);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
showMessage(message, options) {
|
||||
this.showState = true;
|
||||
this.messageContent = this.$t(message, options);
|
||||
},
|
||||
showError(error) {
|
||||
this.showState = true;
|
||||
this.messageContent = this.$tError(error.message || error);
|
||||
}
|
||||
import { useI18n } from '@/locales/helpers.ts';
|
||||
|
||||
import type { ErrorResponse } from '@/core/api.ts';
|
||||
import { isObject, isString } from '@/lib/common.ts';
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:show', value: boolean): void;
|
||||
}>();
|
||||
|
||||
const { tt, te } = useI18n();
|
||||
|
||||
const showState= ref<boolean>(false);
|
||||
const messageContent = ref<string>('');
|
||||
|
||||
function showMessage(message: string, options?: Record<string, unknown>): void {
|
||||
showState.value = true;
|
||||
|
||||
if (options) {
|
||||
messageContent.value = tt(message, options);
|
||||
} else {
|
||||
messageContent.value = tt(message);
|
||||
}
|
||||
}
|
||||
|
||||
function showError(error: string | { message: string } | { error: ErrorResponse }): void {
|
||||
showState.value = true;
|
||||
|
||||
if (isObject(error) && 'message' in error && error.message) {
|
||||
messageContent.value = te(error.message);
|
||||
} else if (isObject(error) && 'error' in error) {
|
||||
messageContent.value = te(error);
|
||||
} else if (isString(error)) {
|
||||
messageContent.value = te(error);
|
||||
}
|
||||
}
|
||||
|
||||
watch(showState, (newValue) => {
|
||||
emit('update:show', newValue);
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
showMessage,
|
||||
showError
|
||||
});
|
||||
</script>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user